mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-08 07:52:12 +03:00
Compare commits
192 Commits
fix/v3851-
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc2dcd1a6 | ||
|
|
d6f315018a | ||
|
|
ce49d969ca | ||
|
|
6b587d0046 | ||
|
|
c1b34db50d | ||
|
|
ebdbd2c67d | ||
|
|
aa35d460dc | ||
|
|
1b97f42ba3 | ||
|
|
f12b87c80b | ||
|
|
c042a51884 | ||
|
|
d7721559a0 | ||
|
|
e8a91173de | ||
|
|
6d6b6027c5 | ||
|
|
600abe68d0 | ||
|
|
d4d2e68a1f | ||
|
|
25bc16d87e | ||
|
|
d857bd053a | ||
|
|
ce55151ca5 | ||
|
|
b345c7f6cd | ||
|
|
f9a1cc8a9b | ||
|
|
2b2d34eb53 | ||
|
|
92a617c23f | ||
|
|
9d1a896c60 | ||
|
|
a9f7598c60 | ||
|
|
d345520d72 | ||
|
|
7b2c9b5548 | ||
|
|
ec4f951e39 | ||
|
|
3858923f68 | ||
|
|
8c4fb8faf2 | ||
|
|
7da6e10c4e | ||
|
|
0df5be5b09 | ||
|
|
f40c77e837 | ||
|
|
c5d47dad8a | ||
|
|
366099a08c | ||
|
|
82f78b3b3b | ||
|
|
891cb26b2c | ||
|
|
c3945a724c | ||
|
|
008da6d19a | ||
|
|
488f57e9d3 | ||
|
|
c41ec7f862 | ||
|
|
6ff7b26277 | ||
|
|
74c2d26393 | ||
|
|
f8a0f9c1f8 | ||
|
|
2a6eff0aec | ||
|
|
d36d077a4d | ||
|
|
36be267a17 | ||
|
|
57d7c8bc88 | ||
|
|
3b7c541f72 | ||
|
|
5f9c358e9b | ||
|
|
ac94dd9bcf | ||
|
|
6aa3690dea | ||
|
|
85b8d128eb | ||
|
|
8c1dfc416d | ||
|
|
3d2bcc9f12 | ||
|
|
8c8d23a98f | ||
|
|
b0557543b8 | ||
|
|
bb8e75a00d | ||
|
|
04ba19fa62 | ||
|
|
57d9357d88 | ||
|
|
6e35ad01cc | ||
|
|
9cbc4f118e | ||
|
|
9271a34ec1 | ||
|
|
11e1c79e65 | ||
|
|
d6771779f7 | ||
|
|
16b0d4e3ad | ||
|
|
2265ce761f | ||
|
|
109cf0f26c | ||
|
|
5ba4247670 | ||
|
|
350ac8c12d | ||
|
|
7ae8bf4e05 | ||
|
|
627fcba605 | ||
|
|
ca2edfdca8 | ||
|
|
d63d25f96c | ||
|
|
2f6fdf16c7 | ||
|
|
9469fa5f59 | ||
|
|
9d92d71014 | ||
|
|
855eda16d3 | ||
|
|
406fbd3dcb | ||
|
|
774e6db396 | ||
|
|
4ef4e25fa7 | ||
|
|
a721fc7295 | ||
|
|
239d8fc67d | ||
|
|
4a37c7f46e | ||
|
|
8a95a2bced | ||
|
|
910f58c5cc | ||
|
|
f51cd295c8 | ||
|
|
ad500de9e3 | ||
|
|
1baa8c3630 | ||
|
|
ffdc736060 | ||
|
|
4866f927ad | ||
|
|
8729909622 | ||
|
|
0019a47f24 | ||
|
|
2e4a79ca50 | ||
|
|
4ec4ce410e | ||
|
|
7881e7eb72 | ||
|
|
c091534ffc | ||
|
|
d353870342 | ||
|
|
d9526cefea | ||
|
|
831ea040c3 | ||
|
|
a47d2e521e | ||
|
|
40c80756e4 | ||
|
|
f81ce2a23b | ||
|
|
35caeb31f2 | ||
|
|
c2d2b0ac14 | ||
|
|
52456a1cea | ||
|
|
0f5fc78d8a | ||
|
|
c9fb06e26c | ||
|
|
9ddb8e0a93 | ||
|
|
9af3ec5112 | ||
|
|
2c4ad3e557 | ||
|
|
49c4a620ca | ||
|
|
3f3d27e264 | ||
|
|
e1cf542378 | ||
|
|
c41d8755db | ||
|
|
7d0b264eda | ||
|
|
e2e330a058 | ||
|
|
8df944cd46 | ||
|
|
3740839e2a | ||
|
|
cb38bfa6ee | ||
|
|
6795783228 | ||
|
|
82c64d76d3 | ||
|
|
cdd07df700 | ||
|
|
032adb0809 | ||
|
|
2a6d45abea | ||
|
|
fdee0ec208 | ||
|
|
60ea5f8858 | ||
|
|
fa64266e32 | ||
|
|
df97d46f48 | ||
|
|
a986ef2e2b | ||
|
|
d6770bda0c | ||
|
|
fcddea7898 | ||
|
|
1a0375fba3 | ||
|
|
a628d28898 | ||
|
|
86e83d4138 | ||
|
|
7448590b81 | ||
|
|
51cd154da1 | ||
|
|
b1fd07df28 | ||
|
|
a334c9b0ce | ||
|
|
143c087d47 | ||
|
|
e243b04de2 | ||
|
|
bf0d902dfc | ||
|
|
84b345d9c0 | ||
|
|
450e92ecf7 | ||
|
|
500568a1cd | ||
|
|
7ed8ada432 | ||
|
|
451d4cd93c | ||
|
|
5ab1e9fe5c | ||
|
|
53b037051b | ||
|
|
c420a51df6 | ||
|
|
d6412532c4 | ||
|
|
a25ac4d979 | ||
|
|
089e70cbc5 | ||
|
|
7c119dd7ed | ||
|
|
6da2418247 | ||
|
|
93fdc16e72 | ||
|
|
7802f6ea16 | ||
|
|
6d556c2422 | ||
|
|
9704195417 | ||
|
|
752aac65d6 | ||
|
|
2c6e6cd13e | ||
|
|
f41a9bd835 | ||
|
|
26d20a0009 | ||
|
|
990aeca1db | ||
|
|
0ec7504024 | ||
|
|
5a34111125 | ||
|
|
62e2481eef | ||
|
|
5a490b19e2 | ||
|
|
4f4aa74199 | ||
|
|
01d97beb8b | ||
|
|
70f33e323c | ||
|
|
8d16a50df5 | ||
|
|
3a5641839e | ||
|
|
c8e2cb3ffc | ||
|
|
bb5c6d148e | ||
|
|
eb09e894cb | ||
|
|
1146c9b5b5 | ||
|
|
674d39137d | ||
|
|
290f723ec0 | ||
|
|
393c305a71 | ||
|
|
d337c5d30d | ||
|
|
0389b07257 | ||
|
|
e7b1448281 | ||
|
|
8e474914ea | ||
|
|
090ae83e12 | ||
|
|
6a91002b39 | ||
|
|
530096a3be | ||
|
|
cabbbe410a | ||
|
|
382e2e85d2 | ||
|
|
6b4519c317 | ||
|
|
afb91a83bd | ||
|
|
c1ac943c70 | ||
|
|
e26a649d26 |
129
.env.example
129
.env.example
@@ -55,10 +55,11 @@ INITIAL_PASSWORD=CHANGEME
|
||||
# loader (bin/cli/plugins.mjs) at a package tree — this one drives the server-side scanner.
|
||||
# OMNIROUTE_PLUGINS_DIR=/opt/omniroute/plugins
|
||||
|
||||
# Escape hatch for the test-context DATA_DIR guard (#10428). A test run that never
|
||||
# chose a DATA_DIR is redirected to a throwaway temp dir so it cannot open the
|
||||
# operator's real database. Set to 1 only for a deliberate run against the real
|
||||
# DATA_DIR — never for CI. Used by: src/lib/dataPaths.ts
|
||||
# Escape hatch for the test/eval DATA_DIR guard (#10428). A test or node eval/print
|
||||
# probe (-e/--eval/-p/--print, including --eval=/--print=) that never chose a DATA_DIR
|
||||
# is redirected to a throwaway temp dir so it cannot open the operator's real database.
|
||||
# Set to 1 only for a deliberate run against the real DATA_DIR — never for CI.
|
||||
# Used by: src/lib/dataPaths.ts
|
||||
# OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1
|
||||
|
||||
# Build provenance (#10427). OMNIROUTE_BUILD_SHA lets a container inject the artifact's git
|
||||
@@ -96,9 +97,11 @@ STORAGE_ENCRYPTION_KEY=
|
||||
# Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY.
|
||||
STORAGE_ENCRYPTION_KEY_VERSION=v1
|
||||
|
||||
# Automatic SQLite backup on startup.
|
||||
# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations.
|
||||
# Default: false (backups enabled) | Set true to skip backup on every restart.
|
||||
# Routine/pre-write SQLite backups.
|
||||
# Used by: src/lib/db/backup.ts. Set true only when those backups are managed externally.
|
||||
# This never disables the migration runner's mandatory, content-addressed safety snapshot
|
||||
# or its mass-migration guard for an existing persistent database.
|
||||
# Default: false (routine backups enabled).
|
||||
DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
|
||||
# ── Redis (Rate Limiting) ──
|
||||
@@ -240,7 +243,7 @@ PORT=20128
|
||||
# Used by: src/app/api/v1/relay/chat/completions/route.ts
|
||||
# RELAY_IP_PER_MINUTE=30
|
||||
|
||||
# Bundler selection for `npm run dev`. Set to 0 to fall back to webpack.
|
||||
# Bundler selection for `npm run dev` and `npm run build`. Set to 0 to fall back to webpack.
|
||||
# Default is 1 (Turbopack). PR #4092 had forced webpack because earlier
|
||||
# Turbopack 16.2.x panicked on the OmniRoute module graph with "internal error:
|
||||
# entered unreachable code: there must be a path to a root"
|
||||
@@ -250,8 +253,9 @@ PORT=20128
|
||||
# /api/v1/models, /api/mcp) and repeated HMR rebuilds: zero panics. Turbopack
|
||||
# also keeps dev memory far lower on the edit→rebuild loop (HMR rebuild RSS stays
|
||||
# ~flat vs webpack's monotonic growth), which mitigates the dev-server OOM on
|
||||
# this 60+ route app. The production build still uses webpack (build pipeline is
|
||||
# unaffected by this dev-only flag).
|
||||
# this 60+ route app. The production build (scripts/build/build-next-isolated.mjs)
|
||||
# reads the same flag: Turbopack by default, 0 builds with webpack (`npm run
|
||||
# build:contributor` sets it for you).
|
||||
OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# Disable systemd sd_notify (Type=notify / WatchdogSec=) even when running
|
||||
@@ -414,7 +418,9 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# provider dispatch. Heavyweight capacity is reserved before parsing; excess work
|
||||
# receives 503 + Retry-After instead of overlapping until the process OOMs.
|
||||
# Used by: src/shared/middleware/chatBodyAdmission.ts
|
||||
# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB).
|
||||
# Actual bodies at or above this size take the heavyweight lease (BYTE path,
|
||||
# including POST /v1/responses) and use the same #10437 healthy-headroom escape
|
||||
# as structure-heavy. Default 262144 (256 KB).
|
||||
# OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144
|
||||
# Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB).
|
||||
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
|
||||
@@ -422,6 +428,11 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# left unset, heavyweight admission is gated by OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES below
|
||||
# instead (an auto-derived byte budget), fixing coding-agent fan-out (multiple
|
||||
# subagents/CLIs) collapsing to an effective concurrency of ~1 and 503ing.
|
||||
# Two overlapping ~750k-token /v1/responses abort ~12 Gi heaps (#7849) — a
|
||||
# memory-budget warning, not a hard product max of 2. A healthy heap may admit
|
||||
# more via HEALTHY_HEADROOM. Tens of long SSE clients (40-50) is heap +
|
||||
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES / #10110. Multiply heaps with N independent
|
||||
# DATA_DIRs (#11024); never replicas>1 on one SQLite.
|
||||
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
|
||||
# Override for the auto-derived ingest byte budget (#503-fanout). Default: 25% of the
|
||||
# process's effective memory ceiling (V8 heap limit, or the tighter cgroup/container
|
||||
@@ -429,13 +440,15 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# 2 GiB; explicit overrides are clamped to the same safe range. Read
|
||||
# chatAdmission.maxInflightBytes/budgetSource at /api/monitoring/health before overriding.
|
||||
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES=134217728
|
||||
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate
|
||||
# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
|
||||
# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a
|
||||
# healthy heap it is admitted instead. Range (0, 1]. Default 0.75.
|
||||
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for BYTE and STRUCTURE
|
||||
# heavyweight admission (#10183, #10268, #10437): a concurrent heavyweight request
|
||||
# past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT is only shed with a retryable 503 when the
|
||||
# heap is ALSO under this much pressure — on a healthy heap it is admitted via
|
||||
# healthy-headroom instead. Range (0, 1]. Default 0.75.
|
||||
# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75
|
||||
# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
|
||||
# (#10437): once this many concurrent leases are active through the healthy-heap bypass,
|
||||
# (#10437, BYTE + STRUCTURE, including bodies >= OMNIROUTE_CHAT_LARGE_BODY_BYTES):
|
||||
# once this many concurrent leases are active through the healthy-heap bypass,
|
||||
# further busy requests fall through to the same bounded-wait/shed path used under real heap
|
||||
# pressure. 0 disables the bypass entirely. Default 1.
|
||||
# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1
|
||||
@@ -863,6 +876,17 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Legacy alias for OMNIROUTE_API_KEY.
|
||||
# ROUTER_API_KEY=
|
||||
|
||||
# Days of A2A task history to keep before the daily purge deletes a row.
|
||||
# Used by: src/lib/a2a/taskManager.ts (historyRetentionDays). Unset, non-numeric,
|
||||
# or <= 0 falls back to the default.
|
||||
# OMNIROUTE_A2A_HISTORY_RETENTION_DAYS=30
|
||||
|
||||
# Kill-switch for the A2A memory-hits observability feature (Orchestration Canvas
|
||||
# Fase 2). Set to "0" to skip the memory recall lookup entirely; any other value
|
||||
# (including unset) keeps it enabled.
|
||||
# Used by: src/lib/a2a/taskExecution.ts (collectMemoryHits).
|
||||
# OMNIROUTE_A2A_MEMORY_HITS=1
|
||||
|
||||
# Enable the offline/local Issue Agent recorded-triage endpoint.
|
||||
# Used by: src/app/api/issue-agent/runs/route.ts. Default: disabled.
|
||||
# OMNIROUTE_ISSUE_AGENT_ENABLED=false
|
||||
@@ -1151,11 +1175,6 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
# Trae OAuth token override. Used by: open-sse/executors/trae.ts.
|
||||
# TRAE_TOKEN=
|
||||
|
||||
# ── The Old LLM (theoldllm) ──
|
||||
# Playwright navigation timeout (ms) for the browser-backed token capture.
|
||||
# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s).
|
||||
# THEOLDLLM_NAV_TIMEOUT_MS=30000
|
||||
|
||||
# ── 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
|
||||
@@ -1283,7 +1302,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
|
||||
# Update these when providers release new CLI versions to avoid blocks.
|
||||
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)"
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)"
|
||||
|
||||
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
|
||||
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
|
||||
@@ -1313,6 +1332,16 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# Override Codex client version sent in headers independently of the
|
||||
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
|
||||
# CODEX_CLIENT_VERSION=0.144.1
|
||||
#
|
||||
# Override the advertised Claude Code client version independently of
|
||||
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
|
||||
# value; a UA-only override is not enough (#12417). Used by:
|
||||
# src/shared/constants/claudeCodeClient.ts.
|
||||
# CLAUDE_CODE_CLIENT_VERSION=2.1.259
|
||||
#
|
||||
# Override the advertised GitHub Copilot CLI version independently of
|
||||
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
|
||||
# GITHUB_COPILOT_CLI_VERSION=1.0.82
|
||||
|
||||
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
|
||||
# from the Codex Responses stream. These frames break the OpenAI SDK's
|
||||
@@ -1470,17 +1499,15 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev
|
||||
# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s)
|
||||
|
||||
# ── Claude TLS sidecar (Chromium-fingerprinted client) ──
|
||||
# Used by: open-sse/services/claudeTlsClient.ts — wire-level timeout for
|
||||
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
|
||||
# layered on top of it when the native library is wedged.
|
||||
# ── Claude TLS transport (Chromium-fingerprinted client) ──
|
||||
# Used by: open-sse/services/claudeTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace when the native request is wedged.
|
||||
# OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_CLAUDE_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Perplexity TLS sidecar (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.ts — wire-level timeout for
|
||||
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
|
||||
# layered on top of it when the native library is wedged.
|
||||
# ── Perplexity TLS transport (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.ts — native wreq-js request
|
||||
# timeout plus the absolute JS hard-deadline grace.
|
||||
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
|
||||
|
||||
@@ -1492,18 +1519,16 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
|
||||
# OMNIROUTE_PPLX_SEARCH_HINT=0
|
||||
|
||||
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
|
||||
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
||||
# top of it when the native library is wedged.
|
||||
# ── Grok web TLS transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace.
|
||||
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Notion web TLS sidecar (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/notionTlsClient.ts — wire-level timeout for the
|
||||
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
||||
# top of it when the native library is wedged. The notion-web executor raises
|
||||
# the wire timeout per-request to 180000 for long generations.
|
||||
# ── Notion web TLS transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/notionTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace. The notion-web executor raises the
|
||||
# native timeout per request to 180000 for long generations.
|
||||
# OMNIROUTE_NOTION_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_NOTION_TLS_GRACE_MS=10000
|
||||
|
||||
@@ -1521,6 +1546,15 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# request into the browser-backed path.
|
||||
# OMNIROUTE_BROWSER_POOL=on
|
||||
# WEB_COOKIE_USE_BROWSER=0
|
||||
# Obscura (https://github.com/h4ckf0r0day/obscura) is the primary headless
|
||||
# engine: a lightweight CDP server the pool and cloudflare-playground connect
|
||||
# to before falling back to Chromium. Unset OBSCURA_BIN to auto-detect from
|
||||
# PATH; set OBSCURA_CDP_ENDPOINT to reuse an already-running Obscura instead
|
||||
# of spawning one; set OBSCURA_PORT to pin the spawned serve port.
|
||||
# Used by: open-sse/services/obscura.ts
|
||||
# OBSCURA_BIN=
|
||||
# OBSCURA_CDP_ENDPOINT=
|
||||
# OBSCURA_PORT=
|
||||
|
||||
# ── Kimi Web (international kimi.ai Connect-RPC) ──
|
||||
# Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if
|
||||
@@ -1829,7 +1863,7 @@ APP_LOG_TO_FILE=true
|
||||
# short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6
|
||||
# AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working —
|
||||
# which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only
|
||||
# the full provider-id prefix (and drops providers whose alias is already canonical).
|
||||
# the full provider-id prefix (providers whose alias is already canonical keep their one id).
|
||||
# A client can override per request with GET /v1/models?prefix=alias instead.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts
|
||||
@@ -1899,6 +1933,11 @@ APP_LOG_TO_FILE=true
|
||||
# Default: true
|
||||
# MODEL_CATALOG_INCLUDE_NAMES=true
|
||||
|
||||
# Cold-path wait bound for a coalesced GET /v1/models catalog rebuild (#12627).
|
||||
# Used by: src/app/api/v1/models/catalogCache.ts
|
||||
# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available.
|
||||
# CATALOG_BUILD_TIMEOUT_MS=8000
|
||||
|
||||
# ── NanoBanana (Image Generation) ──
|
||||
# Polling config for async image generation jobs.
|
||||
# Used by: open-sse/handlers/imageGeneration.ts
|
||||
@@ -2267,6 +2306,16 @@ APP_LOG_TO_FILE=true
|
||||
# Cursor image-generation wall clock (ms). Default: 210000.
|
||||
# CURSOR_IMG_TIMEOUT_MS=210000
|
||||
|
||||
# UC (uncensored.com) image-generation result-poll cadence + wall clock (ms).
|
||||
# Used by: open-sse/handlers/imageGeneration/providers/ucImage.ts. Defaults: 2000 / 60000.
|
||||
# UC_IMAGE_POLL_INTERVAL_MS=2000
|
||||
# UC_IMAGE_POLL_TIMEOUT_MS=60000
|
||||
|
||||
# UC (uncensored.com) video-generation result-poll cadence + wall clock (ms).
|
||||
# Used by: open-sse/handlers/videoGeneration/providers/ucVideo.ts. Defaults: 3000 / 300000.
|
||||
# UC_VIDEO_POLL_INTERVAL_MS=3000
|
||||
# UC_VIDEO_POLL_TIMEOUT_MS=300000
|
||||
|
||||
# Shared-seat concurrency gate for Cursor image jobs. Default: 2.
|
||||
# CURSOR_IMG_MAX_CONCURRENT=2
|
||||
|
||||
|
||||
48
.github/actions/npm-ci-retry/action.yml
vendored
48
.github/actions/npm-ci-retry/action.yml
vendored
@@ -1,9 +1,45 @@
|
||||
name: npm ci with retry
|
||||
description: Run npm ci with retries for transient registry/network failures.
|
||||
description: >-
|
||||
Install dependencies. Restores node_modules from the Actions cache when the exact
|
||||
lockfile / runner / Node version / postinstall inputs match; otherwise runs npm ci
|
||||
with retries for transient registry/network failures and saves the tree for the
|
||||
next run.
|
||||
inputs:
|
||||
cache:
|
||||
description: Set to "false" to skip the node_modules cache and always run npm ci.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
- name: Resolve Node version for the cache key
|
||||
id: node
|
||||
shell: bash
|
||||
run: echo "version=$(node --version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# #8084 D3 (plan 3.8.51 task 5): every job used to pay ~80-90 s of `npm ci` even
|
||||
# with setup-node's npm tarball cache warm — 36 jobs per ci.yml run, ~55 min of
|
||||
# runner time per run just installing. A node_modules cache keyed on EVERYTHING
|
||||
# that shapes the tree lets a hit skip the install entirely.
|
||||
#
|
||||
# No restore-keys on purpose (same rule as the ESLint cache, #11600): a partial
|
||||
# tree from another lockfile / Node / postinstall script is exactly the kind of
|
||||
# silent drift a lockfile-pinned CI must never inherit. Exact key or a full npm ci.
|
||||
#
|
||||
# postinstall (scripts/build/postinstall.mjs + helpers) only mutates node_modules
|
||||
# on a plain install — its dist/ branch is gated on dist/ existing, which never
|
||||
# holds at install time in CI — so the cached tree already carries its effects.
|
||||
- name: Restore node_modules
|
||||
id: node-modules
|
||||
if: inputs.cache == 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node.outputs.version }}-${{ hashFiles('package-lock.json', '.npmrc', 'scripts/build/postinstall.mjs', 'scripts/build/postinstallSupport.mjs', 'scripts/build/colocateOptionals.mjs', 'scripts/build/wreqJsNative.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs') }}
|
||||
|
||||
- name: npm ci (with retry)
|
||||
if: steps.node-modules.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -15,7 +51,8 @@ runs:
|
||||
echo "npm ci attempt $attempt/$max_attempts after transient failure"
|
||||
fi
|
||||
|
||||
if npm ci; then
|
||||
# --no-audit: `audit:deps` is its own gate; the inline audit only adds latency.
|
||||
if npm ci --no-audit --no-fund; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -27,3 +64,8 @@ runs:
|
||||
sleep "$delay_seconds"
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
|
||||
- name: node_modules restored from cache
|
||||
if: steps.node-modules.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
run: echo "node_modules restored from cache (key hit) — npm ci skipped"
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -502,6 +502,11 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- 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
|
||||
# #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.
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -535,7 +535,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.8
|
||||
uses: github/codeql-action/upload-sarif@v4.37.9
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
20
.github/workflows/electron-release.yml
vendored
20
.github/workflows/electron-release.yml
vendored
@@ -187,6 +187,22 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
||||
|
||||
# The Linux leg produces x64 + arm64 installers from one x64 runner. npm
|
||||
# deliberately installs only host-compatible optional dependencies, so
|
||||
# hydrateNativeDeps cannot source the arm64 fork unless we fetch the exact
|
||||
# package pinned in package-lock before either build path runs.
|
||||
- name: Install Linux arm64 wreq binding for cross-package
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install --no-save --ignore-scripts --force --legacy-peer-deps \
|
||||
@wreq-js/binding-linux-arm64-gnu@3.2.0
|
||||
git diff --exit-code -- package.json package-lock.json
|
||||
mkdir -p "$RUNNER_TEMP/omniroute-wreq-verify"
|
||||
DATA_DIR="$RUNNER_TEMP/omniroute-wreq-verify" node --import tsx/esm --test \
|
||||
--test-name-pattern='wreq-js 3.2 manifest pins all nine' \
|
||||
tests/unit/wreq-native-manifest.test.ts
|
||||
|
||||
- name: Sanitize Windows home directory
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
@@ -235,9 +251,9 @@ jobs:
|
||||
# targets, and no unlisted files) byte-for-byte.
|
||||
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
|
||||
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
|
||||
# fsevents) carry linux forks. Replace them with the forks this
|
||||
# @wreq-js/binding-*, fsevents) carry linux forks. Replace them with the forks this
|
||||
# leg's own `npm ci` resolved, then assert every bundled native
|
||||
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# (better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# can service this leg's platform/arch before packaging starts.
|
||||
run: |
|
||||
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
|
||||
|
||||
89
.github/workflows/npm-publish.yml
vendored
89
.github/workflows/npm-publish.yml
vendored
@@ -573,3 +573,92 @@ jobs:
|
||||
fi
|
||||
npm publish --provenance --access public --ignore-scripts
|
||||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|
||||
|
||||
publish-opencode-plugin-v2:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Full history needed for auto-bump: git diff against previous release tag
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Auto-bump plugin-v2 version if plugin-v2 changed since last release
|
||||
id: bump
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
env:
|
||||
CURRENT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
# 1) Skip if current version is not yet published (no bump needed)
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
|
||||
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Find the previous release tag (exclude the current one)
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
|
||||
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag to compare — skipping bump."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3) Check if plugin-v2 dir actually changed since that tag
|
||||
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin-v2/"; then
|
||||
echo "⏭️ No plugin-v2 changes since $PREV_TAG — nothing to publish."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4) Auto-bump patch version
|
||||
npm version patch --no-git-tag-version --allow-same-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
|
||||
|
||||
- name: Install plugin-v2 dependencies
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Build plugin-v2
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
run: npm run clean && npm run build
|
||||
|
||||
- name: Test plugin-v2
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
run: npm test
|
||||
|
||||
- name: Publish @omniroute/opencode-plugin-v2 to npm
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
# Same hardened skip-check as the main job (no --silent flag).
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" = "$PKG_VERSION" ]; then
|
||||
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish --provenance --access public --ignore-scripts
|
||||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|
||||
|
||||
38
.github/workflows/opencode-plugin-ci.yml
vendored
38
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -5,10 +5,12 @@ on:
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
- "@omniroute/opencode-plugin-v2/**"
|
||||
pull_request:
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
- "@omniroute/opencode-plugin-v2/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -44,10 +46,33 @@ jobs:
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
||||
test-v2:
|
||||
name: Test v2 (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-plugin-v2/package-lock.json"
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
needs: [test, test-v2]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -55,12 +80,11 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
|
||||
- run: npm install --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- name: Build plugin-v2 artifact
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
run: npm ci --no-audit --no-fund && npm run build
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opencode-plugin-dist
|
||||
path: "@omniroute/opencode-plugin/dist"
|
||||
name: opencode-plugin-v2-dist
|
||||
path: "@omniroute/opencode-plugin-v2/dist"
|
||||
retention-days: 7
|
||||
|
||||
@@ -7,6 +7,7 @@ fi
|
||||
|
||||
# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift,
|
||||
# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast.
|
||||
sh scripts/check/check-git-identity.sh
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
|
||||
5867
.i18n-state.json
Normal file
5867
.i18n-state.json
Normal file
File diff suppressed because it is too large
Load Diff
10
.trivyignore
10
.trivyignore
@@ -18,13 +18,3 @@
|
||||
#
|
||||
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
|
||||
# patched base / bump the dep) over suppressing. Stale entries are debt.
|
||||
#
|
||||
# CVE-2025-68121 — Go stdlib crypto/tls (session-resumption certificate validation)
|
||||
# inside the PREBUILT bogdanfinn/tls-client v1.15.1 .so that tls-client-node's
|
||||
# postinstall downloads (built with go 1.24.1; fixed in 1.24.13). No upstream
|
||||
# rebuild exists (v1.15.1 is still the latest release) and nothing in this repo
|
||||
# can bump it. The binary is only loaded by the browser-TLS web-provider
|
||||
# executors (claude-web / grok-web / lmarena / perplexity-web / notion-web),
|
||||
# whose handshakes go through utls. Tracking issue: #12084. Revisit at the next
|
||||
# tls-client release or base-image bump and BEFORE the v3.8.51 tag (2026-09-15).
|
||||
CVE-2025-68121
|
||||
|
||||
4
@omniroute/opencode-plugin-v2/.gitignore
vendored
Normal file
4
@omniroute/opencode-plugin-v2/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
21
@omniroute/opencode-plugin-v2/LICENSE
Normal file
21
@omniroute/opencode-plugin-v2/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 OmniRoute contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
107
@omniroute/opencode-plugin-v2/README.md
Normal file
107
@omniroute/opencode-plugin-v2/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @omniroute/opencode-plugin-v2
|
||||
|
||||
OpenCode v2 plugin (`define({ id, setup })`, Promise API) that publishes the live OmniRoute catalog — models from `/v1/models`, combos from `/api/combos` (least-common-denominator join), auto-combos from `/api/combos/auto`, enrichment (names + pricing), and usable-provider filtering — into the v2 `catalog.transform`, with `key` + `env` auth via `integration.transform`.
|
||||
|
||||
Companion to `@omniroute/opencode-plugin` (OpenCode v1, same repo). The two packages are independent: this one carries its own catalog-mapping logic and the v1 plugin is left untouched.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install @omniroute/opencode-plugin-v2
|
||||
```
|
||||
|
||||
`opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"package": "@omniroute/opencode-plugin-v2",
|
||||
"options": {
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "http://localhost:20128"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
The plugin needs a gateway key to read the catalog, and looks for one in this
|
||||
order:
|
||||
|
||||
1. **The credential you connected in OpenCode.** The plugin registers an
|
||||
integration, so `opencode auth` (or the Connect action in the model picker)
|
||||
can store a key for it. Nothing is written to `opencode.json` — this is the
|
||||
recommended route.
|
||||
2. **`apiKey` in the plugin options**, when you want a per-project override.
|
||||
Remember that this puts the key in a config file you may be committing.
|
||||
3. **`OMNIROUTE_API_KEY` in the environment.**
|
||||
|
||||
If none of the three yields a key, the catalog is empty and the plugin says so
|
||||
once at startup rather than leaving you with a silent empty model list.
|
||||
|
||||
### The management token is a different key
|
||||
|
||||
Combos, provider health and enrichment (display names, pricing, free-tier
|
||||
budgets) come from the gateway's `/api/*` endpoints, which most deployments
|
||||
gate behind a **management** token rather than the inference key. Set it
|
||||
explicitly:
|
||||
|
||||
```json
|
||||
"options": {
|
||||
"baseURL": "http://localhost:20128",
|
||||
"managementReadToken": "<management read token>"
|
||||
}
|
||||
```
|
||||
|
||||
Left unset, `managementReadToken` falls back to `apiKey` for backwards
|
||||
compatibility. 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
|
||||
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 |
|
||||
|
||||
## Tool calling on Gemini models
|
||||
|
||||
Gemini answers `400 INVALID_ARGUMENT` — for the whole request, not just the
|
||||
offending tool — when a tool declaration carries `$schema` or
|
||||
`additionalProperties`. Anything that emits standard JSON Schema therefore
|
||||
breaks tool calling as soon as the chain routes to Gemini.
|
||||
|
||||
The plugin strips those keywords from tool schemas bound for a Gemini model of
|
||||
this provider, and leaves every other request untouched. A tool carrying a
|
||||
`$ref` is forwarded untouched instead of stripped: removing the reference
|
||||
would widen the schema to "accept anything". Set
|
||||
`"geminiSanitization": false` to turn it off.
|
||||
|
||||
## Migrating from the v1 plugin
|
||||
|
||||
The v2 plugin publishes provider id `X` bare. The v1 plugin published `opencode-X` (native-adapter gate). Sessions pinned to `opencode-X/...` must re-select the model under `X/...`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
9
@omniroute/opencode-plugin-v2/RELEASE.md
Normal file
9
@omniroute/opencode-plugin-v2/RELEASE.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Release process — `@omniroute/opencode-plugin-v2`
|
||||
|
||||
## Publishing
|
||||
|
||||
One package, no ordering: bump `@omniroute/opencode-plugin-v2` (`npm version patch`) and publish it. The plugin carries its own copy of the mapping logic, so a release never has to be coordinated with another package.
|
||||
|
||||
## Migration note (`opencode-X` → `X`)
|
||||
|
||||
The v1 plugin published provider id `opencode-X` (native-adapter gate). The v2 plugin publishes `X` bare. Sessions pinned to `opencode-X/...` resolve `ModelUnavailableError` — users must re-select the model under `X/...`.
|
||||
2364
@omniroute/opencode-plugin-v2/package-lock.json
generated
Normal file
2364
@omniroute/opencode-plugin-v2/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
@omniroute/opencode-plugin-v2/package.json
Normal file
67
@omniroute/opencode-plugin-v2/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin-v2",
|
||||
"version": "0.1.0",
|
||||
"description": "OmniRoute OpenCode plugin (v2 Promise API): catalog transform with models, combos, enrichment, and naming.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/*.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "1.18.29",
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.3"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "OmniRoute contributors",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute.git",
|
||||
"directory": "@omniroute/opencode-plugin-v2"
|
||||
},
|
||||
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin-v2#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"omniroute",
|
||||
"opencode",
|
||||
"opencode-plugin",
|
||||
"opencode-v2",
|
||||
"ai-sdk",
|
||||
"openai-compatible",
|
||||
"provider",
|
||||
"catalog",
|
||||
"combos",
|
||||
"gemini"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.18.29 <2"
|
||||
}
|
||||
}
|
||||
211
@omniroute/opencode-plugin-v2/src/cache.ts
Normal file
211
@omniroute/opencode-plugin-v2/src/cache.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type {
|
||||
OmniRouteEnrichmentEntry,
|
||||
OmniRouteEnrichmentMap,
|
||||
OmniRouteProviderConnection,
|
||||
OmniRouteRawAutoCombo,
|
||||
OmniRouteRawCombo,
|
||||
OmniRouteRawModelEntry,
|
||||
} from "./shared/index.js";
|
||||
|
||||
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
|
||||
|
||||
/**
|
||||
* Breather after a refresh whose models fetch came back empty (gateway down
|
||||
* or refusing). Transforms inside the window serve last-known-good without
|
||||
* re-firing the fetch suite. Short on purpose: it only guards the
|
||||
* pathological case, normal TTL expiry still refetches every window.
|
||||
*/
|
||||
export const UNREACHABLE_COOLDOWN_MS = 15_000 as const;
|
||||
|
||||
export interface CatalogSnapshot {
|
||||
models: OmniRouteRawModelEntry[];
|
||||
combos: OmniRouteRawCombo[];
|
||||
autoCombos: OmniRouteRawAutoCombo[];
|
||||
providers?: OmniRouteProviderConnection[];
|
||||
enrichment?: OmniRouteEnrichmentMap;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export const SNAPSHOT_FORMAT_VERSION = 2 as const;
|
||||
|
||||
/**
|
||||
* A raw snapshot entry is stale when it cannot be mapped to a publishable
|
||||
* model: no string `id` (unroutable) or a pre-mapped `api` block without a
|
||||
* valid `npm` package (the runner would reject it as `Unsupported package`).
|
||||
* Plain `/v1/models` entries carry no `api` block -- it is synthesized at
|
||||
* publish time -- so only a present-but-invalid block drops the entry.
|
||||
*/
|
||||
export function isStaleSnapshotModel(entry: unknown): boolean {
|
||||
if (!entry || typeof entry !== "object") return true;
|
||||
const id = (entry as { id?: unknown }).id;
|
||||
if (typeof id !== "string" || id.length === 0) return true;
|
||||
const api = (entry as { api?: unknown }).api;
|
||||
if (api === undefined) return false;
|
||||
if (!api || typeof api !== "object") return true;
|
||||
const npm = (api as { npm?: unknown }).npm;
|
||||
return typeof npm !== "string" || npm.length === 0;
|
||||
}
|
||||
|
||||
interface DiskSnapshotV2 {
|
||||
v: 2;
|
||||
identityFingerprint: string;
|
||||
models: OmniRouteRawModelEntry[];
|
||||
combos: OmniRouteRawCombo[];
|
||||
autoCombos?: OmniRouteRawAutoCombo[];
|
||||
providers?: OmniRouteProviderConnection[];
|
||||
/**
|
||||
* Display names, provider labels, pricing and free-tier budgets, as
|
||||
* `[key, entry]` pairs (a Map does not survive JSON). Persisted because a
|
||||
* cold start otherwise publishes raw model ids until the first refresh
|
||||
* completes — which is the moment the snapshot exists to cover.
|
||||
*/
|
||||
enrichment?: [string, OmniRouteEnrichmentEntry][];
|
||||
writtenAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling on what one snapshot may occupy on disk. A gateway with thousands of
|
||||
* models makes this file grow without bound otherwise; past the cap the
|
||||
* enrichment overlay is dropped first (it is rebuilt on the next refresh)
|
||||
* rather than losing the catalog itself.
|
||||
*/
|
||||
const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let i = value.length;
|
||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1;
|
||||
return i === value.length ? value : value.slice(0, i);
|
||||
}
|
||||
|
||||
function normalizeBaseURL(baseURL: string): string {
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
parsed.hash = "";
|
||||
parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return trimTrailingSlashes(baseURL);
|
||||
}
|
||||
}
|
||||
|
||||
export function memoryCacheKey(baseURL: string, credentialId: string): string {
|
||||
return `${baseURL}::${createHash("sha256").update(credentialId).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function snapshotIdentityFingerprint(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
managementReadToken: string
|
||||
): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([normalizeBaseURL(baseURL), apiKey, managementReadToken]))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function diskSnapshotPath(providerId: string): string {
|
||||
// OPENCODE_DATA_DIR is honoured verbatim when set: whoever controls the
|
||||
// process environment already chooses where the process writes, so
|
||||
// resolving it further would only surprise. The providerId segment stays
|
||||
// bounded by the options schema (letters, digits, '.', '_' and '-'; never
|
||||
// "." or ".."), keeping the file inside <dir>/plugins/.
|
||||
const dir = process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode");
|
||||
return join(dir, "plugins", `omniroute-${providerId}.json`);
|
||||
}
|
||||
|
||||
export async function readDiskSnapshot(
|
||||
providerId: string,
|
||||
identityFingerprint: string,
|
||||
logger?: { warn: (message: string) => void }
|
||||
): Promise<CatalogSnapshot | undefined> {
|
||||
try {
|
||||
const body = await readFile(diskSnapshotPath(providerId), "utf8");
|
||||
const parsed = JSON.parse(body) as Partial<DiskSnapshotV2>;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.v !== "number" ||
|
||||
parsed.v < SNAPSHOT_FORMAT_VERSION ||
|
||||
typeof parsed.identityFingerprint !== "string" ||
|
||||
parsed.identityFingerprint !== identityFingerprint
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(parsed.models) ||
|
||||
parsed.models.length === 0 ||
|
||||
!Array.isArray(parsed.combos)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const stale = (parsed.models as unknown[]).filter(isStaleSnapshotModel).length;
|
||||
const models = (parsed.models as OmniRouteRawModelEntry[]).filter(
|
||||
(entry) => !isStaleSnapshotModel(entry)
|
||||
);
|
||||
if (stale > 0) {
|
||||
logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries without api block`);
|
||||
}
|
||||
if (models.length === 0) return undefined;
|
||||
return {
|
||||
models,
|
||||
combos: parsed.combos as OmniRouteRawCombo[],
|
||||
autoCombos: Array.isArray(parsed.autoCombos)
|
||||
? (parsed.autoCombos as OmniRouteRawAutoCombo[])
|
||||
: [],
|
||||
providers: Array.isArray(parsed.providers)
|
||||
? (parsed.providers as OmniRouteProviderConnection[])
|
||||
: [],
|
||||
// A snapshot written before this field existed, or one whose overlay was
|
||||
// dropped for size, simply starts unenriched and recovers on the first
|
||||
// refresh — the same state as before it was persisted at all.
|
||||
enrichment: Array.isArray(parsed.enrichment)
|
||||
? new Map(parsed.enrichment as [string, OmniRouteEnrichmentEntry][])
|
||||
: undefined,
|
||||
fetchedAt: typeof parsed.writtenAt === "number" ? parsed.writtenAt : Date.now(),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeDiskSnapshot(
|
||||
providerId: string,
|
||||
snapshot: CatalogSnapshot,
|
||||
identityFingerprint: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (snapshot.models.length === 0) return;
|
||||
const file = diskSnapshotPath(providerId);
|
||||
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const envelope: DiskSnapshotV2 = {
|
||||
v: 2,
|
||||
identityFingerprint,
|
||||
models: snapshot.models,
|
||||
combos: snapshot.combos,
|
||||
autoCombos: snapshot.autoCombos,
|
||||
providers: snapshot.providers ?? [],
|
||||
enrichment: snapshot.enrichment ? [...snapshot.enrichment.entries()] : undefined,
|
||||
writtenAt: Date.now(),
|
||||
};
|
||||
let payload = JSON.stringify(envelope);
|
||||
if (payload.length > 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 {
|
||||
// Best-effort: callers already hold the in-memory entry.
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearDiskSnapshot(providerId: string): Promise<boolean> {
|
||||
try {
|
||||
await unlink(diskSnapshotPath(providerId));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
798
@omniroute/opencode-plugin-v2/src/catalog.ts
Normal file
798
@omniroute/opencode-plugin-v2/src/catalog.ts
Normal file
@@ -0,0 +1,798 @@
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import { type HostContract, detectHostContract, emitsLegacyFields } from "./compat.js";
|
||||
import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import {
|
||||
type ApiFormatV2,
|
||||
type LogLevel,
|
||||
type Logger,
|
||||
type OmniRouteAutoCombosFetcher,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
type OmniRouteEnrichmentMap,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteProviderConnection,
|
||||
type OmniRouteProvidersFetcher,
|
||||
type OmniRouteRawAutoCombo,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
applyEnrichment,
|
||||
buildCanonicalToAliasMap,
|
||||
canonicalDedupSet,
|
||||
createLogger,
|
||||
defaultOmniRouteEnrichmentFetcher,
|
||||
defaultOmniRouteProvidersFetcher,
|
||||
ensureV1Suffix,
|
||||
isUsableCombo,
|
||||
isUsableRawModelId,
|
||||
lookupEnrichment,
|
||||
mapAutoComboToModelV2,
|
||||
mapComboToModelV2,
|
||||
mapRawModelToModelV2,
|
||||
usableProviderAliasSet,
|
||||
} from "./shared/index.js";
|
||||
|
||||
export type ModelsFetcher = OmniRouteModelsFetcher;
|
||||
export type CombosFetcher = OmniRouteCombosFetcher;
|
||||
export type AutoCombosFetcher = OmniRouteAutoCombosFetcher;
|
||||
export type ProvidersFetcher = OmniRouteProvidersFetcher;
|
||||
export type EnrichmentFetcher = OmniRouteEnrichmentFetcher;
|
||||
|
||||
export interface EndpointTimeouts {
|
||||
models?: number;
|
||||
combos?: number;
|
||||
autoCombos?: number;
|
||||
enrichment?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedOptions {
|
||||
providerId: string;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
managementReadToken?: string;
|
||||
timeoutMs: number;
|
||||
timeouts?: EndpointTimeouts;
|
||||
logger?: Logger;
|
||||
logLevel?: LogLevel;
|
||||
startupDebug?: boolean;
|
||||
modelCacheTtlMs: number;
|
||||
/** v1 parity: prefix the display name with the upstream provider label. */
|
||||
providerTag?: boolean;
|
||||
displayName?: string;
|
||||
apiFormat?: ApiFormatV2;
|
||||
visibleModels?: string[];
|
||||
hiddenModels?: string[];
|
||||
usableOnly: boolean;
|
||||
enrichment?: OmniRouteEnrichmentMap | boolean;
|
||||
/**
|
||||
* Shared collision-warning dedupe set keyed `cacheKey::comboKey`. When
|
||||
* omitted a fresh per-publish set is used. index.ts passes one setup-wide
|
||||
* set so a repeated publish (stale replay + refresh) warns once per key.
|
||||
*/
|
||||
collisionWarned?: Set<string>;
|
||||
}
|
||||
|
||||
export interface CatalogFetchers {
|
||||
fetcher?: ModelsFetcher;
|
||||
combosFetcher?: CombosFetcher;
|
||||
autoCombosFetcher?: AutoCombosFetcher;
|
||||
providersFetcher?: ProvidersFetcher;
|
||||
enrichmentFetcher?: EnrichmentFetcher;
|
||||
models?: ModelsFetcher;
|
||||
combos?: CombosFetcher;
|
||||
autoCombos?: AutoCombosFetcher;
|
||||
providers?: ProvidersFetcher;
|
||||
enrichment?: EnrichmentFetcher;
|
||||
/**
|
||||
* Called when a gateway source cannot be read. Without it this function
|
||||
* degrades silently — the catalog publishes with raw ids and no combos and
|
||||
* nothing says why, which is the failure the plugin path reports.
|
||||
*/
|
||||
onSourceError?: (endpoint: string, reason: string) => void;
|
||||
}
|
||||
|
||||
// The shared mappers speak the legacy (`Provider.models[id]`) `Model` shape
|
||||
// (imported from `@opencode-ai/sdk/v2`, also re-exported by the plugin root
|
||||
// as `ModelV2`); the real v2 `CatalogDraft` carries `ModelV2Info` instead.
|
||||
// Convert the fields 1:1 at the draft boundary -- NEVER `as unknown as` the
|
||||
// whole model.
|
||||
//
|
||||
// Binary-compat note: the prod binary (beta-17823) reads a top-level
|
||||
// `package` field on both Model and Provider structs (`package:a.Package`,
|
||||
// gated by `isAISDK = startsWith("aisdk:")`), with a model-to-provider
|
||||
// fallback (`package: u.package ?? s.package`). The pinned SDK types
|
||||
// (1.18.29) only know the `api` block, so the binary field is published via
|
||||
// the typed extensions below (spread/Object.assign, never `any`).
|
||||
export const BINARY_AISDK_PREFIX = "aisdk:";
|
||||
|
||||
/** Top-level `package` as the legacy contract expects it (`aisdk:<npm>`). */
|
||||
export interface BinaryCompatPackage {
|
||||
package: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy contract keeps on the model/provider itself what the `api` block
|
||||
* carries in the pinned types: the aisdk package, the endpoint (as
|
||||
* `settings.baseURL`) and the per-request headers. None of these keys collide
|
||||
* with a key of `ModelV2Info`/`ProviderV2Info`, so both field sets can be
|
||||
* published on the same object.
|
||||
*/
|
||||
export interface BinaryCompatFields extends BinaryCompatPackage {
|
||||
settings: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Legacy variants read their options from `settings`, not `headers`/`body`. */
|
||||
export type BinaryCompatVariant = ModelV2Info["variants"][number] & {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BinaryCompatModel = ModelV2Info & BinaryCompatFields;
|
||||
export type BinaryCompatProvider = ProviderV2Info &
|
||||
BinaryCompatPackage & {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function toBinaryPackage(npm: string): string {
|
||||
return npm.startsWith(BINARY_AISDK_PREFIX) ? npm : `${BINARY_AISDK_PREFIX}${npm}`;
|
||||
}
|
||||
export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"] {
|
||||
if (!api || typeof api.npm !== "string" || api.npm.length === 0) {
|
||||
throw new Error(
|
||||
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
|
||||
);
|
||||
}
|
||||
return { id: api.id, type: "aisdk", package: api.npm, url: api.url };
|
||||
}
|
||||
|
||||
function legacyCostToInfoCost(cost: LegacyModelV2["cost"]): ModelV2Info["cost"] {
|
||||
return [{ input: cost.input, output: cost.output, cache: cost.cache }];
|
||||
}
|
||||
|
||||
function legacyCapabilitiesToInfoCapabilities(
|
||||
caps: LegacyModelV2["capabilities"]
|
||||
): ModelV2Info["capabilities"] {
|
||||
const input: string[] = [];
|
||||
if (caps.input.text) input.push("text");
|
||||
if (caps.input.audio) input.push("audio");
|
||||
if (caps.input.image) input.push("image");
|
||||
if (caps.input.video) input.push("video");
|
||||
if (caps.input.pdf) input.push("pdf");
|
||||
const output: string[] = [];
|
||||
if (caps.output.text) output.push("text");
|
||||
if (caps.output.audio) output.push("audio");
|
||||
if (caps.output.image) output.push("image");
|
||||
if (caps.output.video) output.push("video");
|
||||
if (caps.output.pdf) output.push("pdf");
|
||||
return { tools: caps.toolcall, input, output };
|
||||
}
|
||||
|
||||
function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info {
|
||||
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
|
||||
id,
|
||||
headers: {},
|
||||
body: body as Record<string, unknown>,
|
||||
}));
|
||||
const parsed = Date.parse(m.release_date);
|
||||
return {
|
||||
id: modelID,
|
||||
providerID,
|
||||
...(m.family !== undefined ? { family: m.family } : {}),
|
||||
name: m.name,
|
||||
api: legacyApiToInfoApi(m.api),
|
||||
capabilities: legacyCapabilitiesToInfoCapabilities(m.capabilities),
|
||||
request: { headers: { ...m.headers }, body: { ...m.options } },
|
||||
variants,
|
||||
time: { released: Number.isNaN(parsed) ? 0 : parsed },
|
||||
cost: legacyCostToInfoCost(m.cost),
|
||||
status: m.status,
|
||||
enabled: true,
|
||||
limit: { ...m.limit },
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublishCounts {
|
||||
models: number;
|
||||
combos: number;
|
||||
autoCombos: number;
|
||||
}
|
||||
|
||||
export interface ModelListFilter {
|
||||
exact: Set<string>;
|
||||
suffixes: Set<string>;
|
||||
}
|
||||
|
||||
export function compileModelListFilter(list?: string[]): ModelListFilter | undefined {
|
||||
if (!list || list.length === 0) return undefined;
|
||||
const exact = new Set<string>();
|
||||
const suffixes = new Set<string>();
|
||||
for (const id of list) {
|
||||
if (id.includes("/")) {
|
||||
exact.add(id);
|
||||
} else {
|
||||
suffixes.add(id);
|
||||
}
|
||||
}
|
||||
if (exact.size === 0 && suffixes.size === 0) return undefined;
|
||||
return { exact, suffixes };
|
||||
}
|
||||
|
||||
function matchesSuffix(id: string, suffixes: Set<string>): boolean {
|
||||
if (suffixes.size === 0) return false;
|
||||
const slash = id.indexOf("/");
|
||||
const suffix = slash > 0 ? id.slice(slash + 1) : id;
|
||||
return suffixes.has(suffix);
|
||||
}
|
||||
|
||||
export function passesModelAllowlist(
|
||||
id: string,
|
||||
visible?: ModelListFilter,
|
||||
hidden?: ModelListFilter
|
||||
): boolean {
|
||||
if (hidden) {
|
||||
if (hidden.exact.has(id) || matchesSuffix(id, hidden.suffixes)) return false;
|
||||
}
|
||||
if (visible) {
|
||||
if (!visible.exact.has(id) && !matchesSuffix(id, visible.suffixes)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelListFilter): boolean {
|
||||
if (!visible) return true;
|
||||
const steps = Array.isArray(combo.models) ? combo.models : [];
|
||||
if (steps.length === 0) return true;
|
||||
let sawResolvableMember = false;
|
||||
for (const step of steps) {
|
||||
if (step?.kind === "combo-ref") continue;
|
||||
const modelId = typeof step?.model === "string" ? step.model : "";
|
||||
if (modelId.length === 0) continue;
|
||||
sawResolvableMember = true;
|
||||
if (visible.exact.has(modelId) || matchesSuffix(modelId, visible.suffixes)) return true;
|
||||
}
|
||||
if (!sawResolvableMember) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the `api` block onto the legacy top-level fields. Only the `aisdk`
|
||||
* variant of `ModelApi`/`ProviderApi` carries a package, so the caller narrows
|
||||
* before calling; a `native` api has no legacy equivalent and publishes
|
||||
* nothing (the legacy contract has no native models).
|
||||
*/
|
||||
function legacyModelFields(info: ModelV2Info): BinaryCompatFields | undefined {
|
||||
if (info.api.type !== "aisdk") return undefined;
|
||||
const settings: Record<string, unknown> = {
|
||||
...(info.api.settings ?? {}),
|
||||
...info.request.body,
|
||||
};
|
||||
if (info.api.url !== undefined) settings.baseURL = info.api.url;
|
||||
return {
|
||||
package: toBinaryPackage(info.api.package),
|
||||
settings,
|
||||
headers: { ...info.request.headers },
|
||||
};
|
||||
}
|
||||
|
||||
/** `{id, headers, body}` (pinned types) plus `{settings}` (legacy contract). */
|
||||
function legacyVariants(variants: ModelV2Info["variants"]): BinaryCompatVariant[] {
|
||||
return variants.map((variant) => ({ ...variant, settings: { ...variant.body } }));
|
||||
}
|
||||
|
||||
function assignModelFields(
|
||||
target: ModelV2Info,
|
||||
source: LegacyModelV2,
|
||||
contract: HostContract
|
||||
): void {
|
||||
const info = legacyToInfo(target.providerID || source.providerID, target.id || source.id, source);
|
||||
target.name = info.name;
|
||||
target.api = info.api;
|
||||
target.capabilities = info.capabilities;
|
||||
target.request = info.request;
|
||||
target.variants = info.variants;
|
||||
target.time = info.time;
|
||||
target.cost = info.cost;
|
||||
target.status = info.status;
|
||||
target.enabled = info.enabled;
|
||||
target.limit = info.limit;
|
||||
if (info.family !== undefined) {
|
||||
target.family = info.family;
|
||||
}
|
||||
if (!emitsLegacyFields(contract)) return;
|
||||
const legacy = legacyModelFields(info);
|
||||
if (legacy !== undefined) {
|
||||
Object.assign(target, legacy);
|
||||
target.variants = legacyVariants(info.variants);
|
||||
}
|
||||
}
|
||||
|
||||
function assignProviderFields(
|
||||
target: ProviderV2Info,
|
||||
source: { name: string; api: ProviderV2Info["api"]; integrationID: string },
|
||||
contract: HostContract
|
||||
): void {
|
||||
target.name = source.name;
|
||||
target.api = source.api;
|
||||
target.integrationID = source.integrationID;
|
||||
if (!emitsLegacyFields(contract)) return;
|
||||
// The legacy contract defaults `Provider.Info.package` to `""` and model
|
||||
// resolution falls back to it (`package: model.package ?? provider.package`),
|
||||
// so the provider carries the same `aisdk:<npm>` value as its models, and
|
||||
// the endpoint as `settings.baseURL`.
|
||||
if (source.api.type !== "aisdk") return;
|
||||
const settings: Record<string, unknown> = { ...(source.api.settings ?? {}) };
|
||||
if (source.api.url !== undefined) settings.baseURL = source.api.url;
|
||||
Object.assign(target, { package: toBinaryPackage(source.api.package), settings });
|
||||
}
|
||||
|
||||
/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */
|
||||
function isCapabilityEnabled(value: boolean | { field: string }): boolean {
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combo steps reach us from the gateway with a shape the SDK types do not
|
||||
* describe (`kind`, `comboName`, `model` appear per step kind). One reader
|
||||
* keeps that single untyped boundary in one place instead of scattering casts.
|
||||
*/
|
||||
function readStepField(step: unknown, key: "kind" | "comboName" | "model"): unknown {
|
||||
return (step as Record<string, unknown> | null | undefined)?.[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display-name + pricing overlay. A caller may hand over a
|
||||
* ready-made map (tests, pre-resolved overlays) or turn the fetch off; a
|
||||
* failed fetch soft-fails to an empty map so the catalog still publishes,
|
||||
* with mapper-default names and zeroed pricing rather than nothing at all.
|
||||
*/
|
||||
async function resolveEnrichmentOverlay(
|
||||
opts: ResolvedOptions,
|
||||
fetchers: CatalogFetchers | undefined,
|
||||
log: Logger
|
||||
): Promise<OmniRouteEnrichmentMap> {
|
||||
if (opts.enrichment instanceof Map) return opts.enrichment;
|
||||
if (opts.enrichment === false) return new Map();
|
||||
const fetchEnrichment =
|
||||
fetchers?.enrichmentFetcher ?? fetchers?.enrichment ?? defaultOmniRouteEnrichmentFetcher;
|
||||
try {
|
||||
return await fetchEnrichment(
|
||||
opts.baseURL,
|
||||
opts.managementReadToken ?? opts.apiKey,
|
||||
opts.timeouts?.enrichment ?? opts.timeoutMs,
|
||||
fetchers?.onSourceError
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] enrichment fetch failed, continuing without names/pricing: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider aliases worth publishing when `usableOnly` is on.
|
||||
* Gated on the flag, so the default configuration issues no request at all.
|
||||
* The filter subtracts: a failed or empty connections fetch yields
|
||||
* `undefined` and keeps the whole catalog, because only a prefix proven not
|
||||
* provisioned may be dropped.
|
||||
*/
|
||||
async function resolveUsableAliases(
|
||||
opts: ResolvedOptions,
|
||||
providersFetcher: OmniRouteProvidersFetcher | undefined,
|
||||
onSourceError: ((endpoint: string, reason: string) => void) | undefined,
|
||||
enrichment: OmniRouteEnrichmentMap,
|
||||
timeoutMs: number,
|
||||
log: Logger
|
||||
): Promise<ReturnType<typeof usableProviderAliasSet> | undefined> {
|
||||
if (!opts.usableOnly) return undefined;
|
||||
let rawConnections: OmniRouteProviderConnection[];
|
||||
try {
|
||||
const fetchProviders = providersFetcher ?? defaultOmniRouteProvidersFetcher;
|
||||
rawConnections = await fetchProviders(
|
||||
opts.baseURL,
|
||||
opts.managementReadToken ?? opts.apiKey,
|
||||
timeoutMs,
|
||||
onSourceError
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] providers fetch failed, usableOnly filter disabled for this refresh: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
rawConnections = [];
|
||||
}
|
||||
return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined;
|
||||
}
|
||||
|
||||
/** Everything the combo publishing pass reads, passed as one value. */
|
||||
interface PublishContext {
|
||||
draft: CatalogDraft;
|
||||
opts: ResolvedOptions;
|
||||
log: Logger;
|
||||
providerId: string;
|
||||
hostContract: HostContract;
|
||||
enrichment: OmniRouteEnrichmentMap;
|
||||
rawModelById: Map<string, OmniRouteRawModelEntry>;
|
||||
publishedKeys: Set<string>;
|
||||
publishedModelIds: Map<string, string>;
|
||||
visibleFilter: ReturnType<typeof compileModelListFilter>;
|
||||
hiddenFilter: ReturnType<typeof compileModelListFilter>;
|
||||
usable: ReturnType<typeof usableProviderAliasSet> | undefined;
|
||||
canonicalToAlias: ReturnType<typeof buildCanonicalToAliasMap>;
|
||||
combosFetcher: CatalogFetchers["combos"] | undefined;
|
||||
combosTimeout: number;
|
||||
/** Shared with the auto-combos pass: one collision warning per key, per run. */
|
||||
warnedCombos: Set<string>;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the gateway's combos and publish them, resolving nested combo-refs to
|
||||
* a fixpoint first: a combo whose members are themselves combos only knows its
|
||||
* lowest common denominator once those are known. Combos that never resolve
|
||||
* are dropped rather than published with a fabricated capability set, and
|
||||
* reported once.
|
||||
*
|
||||
* Returns the number published, or `undefined` when the combos fetch failed —
|
||||
* the caller then publishes a models-only catalog instead of an empty one.
|
||||
*/
|
||||
async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
|
||||
const {
|
||||
draft,
|
||||
opts,
|
||||
log,
|
||||
providerId: X,
|
||||
hostContract,
|
||||
enrichment,
|
||||
rawModelById,
|
||||
publishedKeys,
|
||||
publishedModelIds,
|
||||
visibleFilter,
|
||||
hiddenFilter,
|
||||
usable,
|
||||
canonicalToAlias,
|
||||
combosFetcher,
|
||||
combosTimeout,
|
||||
warnedCombos,
|
||||
cacheKey,
|
||||
} = ctx;
|
||||
let rawCombos: OmniRouteRawCombo[];
|
||||
try {
|
||||
rawCombos = combosFetcher
|
||||
? await combosFetcher(opts.baseURL, opts.managementReadToken ?? opts.apiKey, combosTimeout)
|
||||
: [];
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] combos fetch failed, falling back to models-only catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let comboCount = 0;
|
||||
// Ported from v1 (fixpoint 8 passes + warn once per (cacheKey, comboKey)
|
||||
// + intentional-dedup exception). Nested combo-refs resolve against the
|
||||
// friendly combo name; unresolvable combos are dropped (never published
|
||||
// with a fabricated empty LCD) and reported once.
|
||||
const MAX_COMBO_PASSES = 8;
|
||||
const pending = rawCombos.filter((combo) => {
|
||||
if (!combo || !combo.id) return false;
|
||||
if (combo.isHidden === true) return false;
|
||||
if (usable && !isUsableCombo(combo, usable)) return false;
|
||||
if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false;
|
||||
// Deny wins for combos too: a user who hides an id expects it gone from
|
||||
// the picker whether it is a model or a combo built on it.
|
||||
if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false;
|
||||
return true;
|
||||
});
|
||||
const resolvedByName = new Map<string, LegacyModelV2>();
|
||||
let unresolved: typeof pending = [];
|
||||
|
||||
for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) {
|
||||
const stillPending: typeof pending = [];
|
||||
for (const combo of pending) {
|
||||
const memberSteps = Array.isArray(combo.models) ? combo.models : [];
|
||||
const memberEntries: OmniRouteRawModelEntry[] = [];
|
||||
let deferred = false;
|
||||
for (const step of memberSteps) {
|
||||
const kind = readStepField(step, "kind");
|
||||
if (kind === "combo-ref") {
|
||||
const comboName = readStepField(step, "comboName");
|
||||
if (typeof comboName !== "string" || comboName.length === 0) continue;
|
||||
const nested = resolvedByName.get(comboName);
|
||||
if (!nested) {
|
||||
deferred = true;
|
||||
break;
|
||||
}
|
||||
memberEntries.push(synthesizeNestedMember(comboName, nested));
|
||||
continue;
|
||||
}
|
||||
const modelId = readStepField(step, "model");
|
||||
if (typeof modelId !== "string" || modelId.length === 0) continue;
|
||||
const member = rawModelById.get(modelId);
|
||||
if (member) memberEntries.push(member);
|
||||
}
|
||||
if (deferred) {
|
||||
stillPending.push(combo);
|
||||
continue;
|
||||
}
|
||||
const mapped = mapComboToModelV2(combo, memberEntries, X, opts.baseURL, opts.apiFormat);
|
||||
applyEnrichment(mapped, lookupEnrichment(combo.id, enrichment, canonicalToAlias), {
|
||||
isCombo: true,
|
||||
});
|
||||
const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id;
|
||||
const key = X + "/" + mid;
|
||||
if (publishedKeys.has(key)) {
|
||||
// Intentional dedup (v1 parity): `/v1/models` pre-mirrors combos as
|
||||
// raw entries, so the combo's friendly NAME matches the overwritten
|
||||
// entry's model id (bare or provider-prefixed, endsWith to cover
|
||||
// both). Only warn on a genuine accidental collision (name differs
|
||||
// from the entry it overwrites).
|
||||
const existingId = publishedModelIds.get(key) ?? "";
|
||||
const friendly =
|
||||
typeof combo.name === "string" && combo.name.trim().length > 0
|
||||
? combo.name.trim()
|
||||
: combo.id;
|
||||
const isIntentionalDedup =
|
||||
existingId === friendly ||
|
||||
existingId === X + "/" + friendly ||
|
||||
existingId.endsWith("/" + friendly);
|
||||
if (!isIntentionalDedup) {
|
||||
const dedupeKey = `${cacheKey}::${key}`;
|
||||
if (!warnedCombos.has(dedupeKey)) {
|
||||
warnedCombos.add(dedupeKey);
|
||||
log.warn(`[omniroute-v2] combo key "${key}" collides with a model id; combo wins.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
draft.model.update(X, mid, (m) => {
|
||||
assignModelFields(m, mapped, hostContract);
|
||||
});
|
||||
publishedKeys.add(key);
|
||||
publishedModelIds.set(key, mapped.id);
|
||||
comboCount += 1;
|
||||
const lookupName =
|
||||
typeof combo.name === "string" && combo.name.trim().length > 0
|
||||
? combo.name.trim()
|
||||
: combo.id;
|
||||
if (!resolvedByName.has(lookupName)) resolvedByName.set(lookupName, mapped);
|
||||
}
|
||||
if (stillPending.length === pending.length) {
|
||||
unresolved = stillPending;
|
||||
break;
|
||||
}
|
||||
unresolved = stillPending;
|
||||
pending.length = 0;
|
||||
pending.push(...stillPending);
|
||||
}
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
log.warn(
|
||||
`[omniroute-v2] ${unresolved.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; dropped to avoid over-claiming.`
|
||||
);
|
||||
}
|
||||
return comboCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a raw-model entry from an already-resolved nested combo so a
|
||||
* parent combo's LCD folds the whole nested capability vector (context,
|
||||
* output, modalities, capabilities) instead of only direct raw members.
|
||||
* v1 parity (combo member synthesis at nested resolution time).
|
||||
*/
|
||||
function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteRawModelEntry {
|
||||
const inputModalities: string[] = [];
|
||||
if (nested.capabilities.input.text) inputModalities.push("text");
|
||||
if (nested.capabilities.input.audio) inputModalities.push("audio");
|
||||
if (nested.capabilities.input.image) inputModalities.push("image");
|
||||
if (nested.capabilities.input.video) inputModalities.push("video");
|
||||
if (nested.capabilities.input.pdf) inputModalities.push("pdf");
|
||||
const outputModalities: string[] = [];
|
||||
if (nested.capabilities.output.text) outputModalities.push("text");
|
||||
if (nested.capabilities.output.audio) outputModalities.push("audio");
|
||||
if (nested.capabilities.output.image) outputModalities.push("image");
|
||||
if (nested.capabilities.output.video) outputModalities.push("video");
|
||||
if (nested.capabilities.output.pdf) outputModalities.push("pdf");
|
||||
return {
|
||||
id: `combo-ref:${name}`,
|
||||
context_length: nested.limit.context,
|
||||
max_output_tokens: nested.limit.output,
|
||||
...(nested.limit.input !== undefined ? { max_input_tokens: nested.limit.input } : {}),
|
||||
owned_by: "combo",
|
||||
input_modalities: inputModalities,
|
||||
output_modalities: outputModalities,
|
||||
capabilities: {
|
||||
temperature: nested.capabilities.temperature,
|
||||
// A raw entry carries plain flags; the mapped model widens them to
|
||||
// `boolean | { field }` (custom reasoning/thinking field). Every
|
||||
// non-false form means the capability is present, which is all the
|
||||
// LCD fold reads.
|
||||
reasoning: isCapabilityEnabled(nested.capabilities.reasoning),
|
||||
thinking: isCapabilityEnabled(nested.capabilities.interleaved),
|
||||
attachment: nested.capabilities.attachment,
|
||||
tool_calling: nested.capabilities.toolcall,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishCatalog(
|
||||
draft: CatalogDraft,
|
||||
opts: ResolvedOptions,
|
||||
fetchers?: CatalogFetchers
|
||||
): Promise<PublishCounts> {
|
||||
const X = opts.providerId;
|
||||
const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn"));
|
||||
const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs;
|
||||
const combosTimeout = opts.timeouts?.combos ?? opts.timeoutMs;
|
||||
// v1 parity keeps the 5s auto-combos budget when no per-endpoint value is
|
||||
// set (P2 resolves it in index.ts; direct publishCatalog callers may only
|
||||
// pass timeoutMs).
|
||||
const autoCombosTimeout = opts.timeouts?.autoCombos ?? 5_000;
|
||||
// The contract is discovered from the object the host seeds into the
|
||||
// provider draft, which the host fills before any model is published. The
|
||||
// verdict is then reused for every model: the model seed carries no
|
||||
// discriminating key, and a single provider/model pair always speaks one
|
||||
// contract.
|
||||
let hostContract: HostContract = "unknown";
|
||||
draft.provider.update(X, (p) => {
|
||||
hostContract = detectHostContract(p);
|
||||
assignProviderFields(
|
||||
p,
|
||||
{
|
||||
name: opts.displayName ?? "OmniRoute",
|
||||
api: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: ensureV1Suffix(opts.baseURL),
|
||||
},
|
||||
integrationID: X,
|
||||
},
|
||||
hostContract
|
||||
);
|
||||
});
|
||||
log.debug(`[omniroute-v2] host catalog contract detected: ${hostContract}`);
|
||||
|
||||
const modelsFetcher = fetchers?.fetcher ?? fetchers?.models;
|
||||
const combosFetcher = fetchers?.combosFetcher ?? fetchers?.combos;
|
||||
const autoCombosFetcher = fetchers?.autoCombosFetcher ?? fetchers?.autoCombos;
|
||||
const providersFetcher = fetchers?.providersFetcher ?? fetchers?.providers;
|
||||
|
||||
let rawModels: OmniRouteRawModelEntry[];
|
||||
try {
|
||||
rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : [];
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { models: 0, combos: 0, autoCombos: 0 };
|
||||
}
|
||||
|
||||
const visibleFilter = compileModelListFilter(opts.visibleModels);
|
||||
const hiddenFilter = compileModelListFilter(opts.hiddenModels);
|
||||
|
||||
const enrichment = await resolveEnrichmentOverlay(opts, fetchers, log);
|
||||
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
|
||||
const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias);
|
||||
|
||||
const usable = await resolveUsableAliases(
|
||||
opts,
|
||||
providersFetcher,
|
||||
fetchers?.onSourceError,
|
||||
enrichment,
|
||||
modelsTimeout,
|
||||
log
|
||||
);
|
||||
|
||||
const rawModelById = new Map<string, OmniRouteRawModelEntry>();
|
||||
for (const entry of rawModels) {
|
||||
if (entry.id) rawModelById.set(entry.id, entry);
|
||||
}
|
||||
|
||||
const publishedKeys = new Set<string>();
|
||||
// Mapped model id per published key (models and combos alike). Mirrors
|
||||
// v1's `models[comboKey]` lookup so the intentional-dedup check sees the
|
||||
// overwritten entry's id, not just key presence.
|
||||
const publishedModelIds = new Map<string, string>();
|
||||
let modelCount = 0;
|
||||
for (const entry of rawModels) {
|
||||
if (!entry.id) continue;
|
||||
if (canonicalDedup.has(entry.id)) continue;
|
||||
if (usable && !isUsableRawModelId(entry.id, usable)) continue;
|
||||
if (!passesModelAllowlist(entry.id, visibleFilter, hiddenFilter)) continue;
|
||||
const mapped = mapRawModelToModelV2(entry, {
|
||||
providerId: X,
|
||||
baseURL: opts.baseURL,
|
||||
apiFormat: opts.apiFormat,
|
||||
});
|
||||
applyEnrichment(mapped, lookupEnrichment(entry.id, enrichment, canonicalToAlias), {
|
||||
providerTag: opts.providerTag !== false,
|
||||
});
|
||||
const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id;
|
||||
draft.model.update(X, mid, (m) => {
|
||||
assignModelFields(m, mapped, hostContract);
|
||||
});
|
||||
publishedKeys.add(X + "/" + mid);
|
||||
publishedModelIds.set(X + "/" + mid, mapped.id);
|
||||
modelCount += 1;
|
||||
}
|
||||
|
||||
const warnedCombos = opts.collisionWarned ?? new Set<string>();
|
||||
const cacheKey = `${opts.baseURL}::${opts.providerId}`;
|
||||
const comboCount = await publishCombos({
|
||||
draft,
|
||||
opts,
|
||||
log,
|
||||
providerId: X,
|
||||
hostContract,
|
||||
enrichment,
|
||||
rawModelById,
|
||||
publishedKeys,
|
||||
publishedModelIds,
|
||||
visibleFilter,
|
||||
hiddenFilter,
|
||||
usable,
|
||||
canonicalToAlias,
|
||||
combosFetcher,
|
||||
combosTimeout,
|
||||
warnedCombos,
|
||||
cacheKey,
|
||||
});
|
||||
if (comboCount === undefined) return { models: modelCount, combos: 0, autoCombos: 0 };
|
||||
|
||||
// Migration: v1 published opencode-X; v2 publishes X bare. Sessions pinned
|
||||
// opencode-X resolve ModelUnavailableError -- see RELEASE.md migration note.
|
||||
// Re-publishing under "opencode-"+X here is FORBIDDEN: a double
|
||||
// publish would double chat entries in the picker.
|
||||
|
||||
// Auto combos: virtual server-side entries from /api/combos/auto, keyed
|
||||
// "auto" / "auto/<variant>" (v1 parity). Fail-open: a fetcher throw keeps
|
||||
// models + combos and only warns - old gateways may not serve the
|
||||
// endpoint at all (the default fetcher maps 404 to [] itself).
|
||||
let rawAutoCombos: OmniRouteRawAutoCombo[];
|
||||
try {
|
||||
rawAutoCombos = autoCombosFetcher
|
||||
? await autoCombosFetcher(
|
||||
opts.baseURL,
|
||||
opts.managementReadToken ?? opts.apiKey,
|
||||
autoCombosTimeout
|
||||
)
|
||||
: [];
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] auto combos fetch failed, falling back to models+combos catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { models: modelCount, combos: comboCount, autoCombos: 0 };
|
||||
}
|
||||
|
||||
let autoComboCount = 0;
|
||||
for (const autoCombo of rawAutoCombos) {
|
||||
if (!autoCombo || !autoCombo.id) continue;
|
||||
if (autoCombo.isHidden === true) continue;
|
||||
// Auto combos are catalog entries like any other: an id a user asked to
|
||||
// hide must stay hidden, and an allowlist that excludes it must exclude
|
||||
// it. They used to skip both filters entirely.
|
||||
if (!passesModelAllowlist(autoCombo.id, visibleFilter, hiddenFilter)) continue;
|
||||
if (usable && !isUsableRawModelId(autoCombo.id, usable)) continue;
|
||||
const mapped = mapAutoComboToModelV2(autoCombo, X, opts.baseURL, opts.apiFormat);
|
||||
applyEnrichment(mapped, lookupEnrichment(autoCombo.id, enrichment, canonicalToAlias), {
|
||||
isCombo: true,
|
||||
isAutoCombo: true,
|
||||
});
|
||||
const key = X + "/" + mapped.id;
|
||||
if (publishedKeys.has(key)) {
|
||||
const dedupeKey = `${cacheKey}::${key}`;
|
||||
if (!warnedCombos.has(dedupeKey)) {
|
||||
warnedCombos.add(dedupeKey);
|
||||
log.warn(
|
||||
`[omniroute-v2] auto combo key "${key}" collides with a model id; auto combo wins.`
|
||||
);
|
||||
}
|
||||
}
|
||||
draft.model.update(X, mapped.id, (m) => {
|
||||
assignModelFields(m, mapped, hostContract);
|
||||
});
|
||||
publishedKeys.add(key);
|
||||
publishedModelIds.set(key, mapped.id);
|
||||
autoComboCount += 1;
|
||||
}
|
||||
|
||||
return { models: modelCount, combos: comboCount, autoCombos: autoComboCount };
|
||||
}
|
||||
66
@omniroute/opencode-plugin-v2/src/compat.ts
Normal file
66
@omniroute/opencode-plugin-v2/src/compat.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isTransformHolder(value: unknown): value is { transform: unknown } {
|
||||
return isObject(value) && "transform" in value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog domain is the one this plugin cannot work without. The
|
||||
* integration domain carries the credential flow and the `aisdk` domain the
|
||||
* tool-schema cleaning: a host missing either still gets its catalog, so
|
||||
* neither is asserted here — each is probed where it is used.
|
||||
*/
|
||||
export function assertContext(ctx: unknown): void {
|
||||
if (!isObject(ctx)) {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
|
||||
}
|
||||
if (!isTransformHolder(ctx.catalog) || typeof ctx.catalog.transform !== "function") {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx.catalog.transform must be a function");
|
||||
}
|
||||
if (!isObject(ctx.options)) {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog contract spoken by the running host.
|
||||
*
|
||||
* opencode v2 is a moving target: the catalog contract changed between the
|
||||
* binary that ships today and the SDK types this package pins. Rather than
|
||||
* keying off a version list (which goes stale on the next release), the
|
||||
* contract is discovered at runtime from the object the host seeds into the
|
||||
* draft.
|
||||
*
|
||||
* - `legacy-package` — the seed carries a top-level `package` and no `api`
|
||||
* block. Observed on `@opencode-ai/cli` 0.0.0-beta-17823, whose
|
||||
* `Provider.Info.empty` is `{id, name, activation, package}`.
|
||||
* - `sdk-api` — the seed carries an `api` block. This is the contract of the
|
||||
* pinned `@opencode-ai/plugin`/`@opencode-ai/sdk` types.
|
||||
* - `unknown` — neither or both. The caller publishes the superset.
|
||||
*/
|
||||
export type HostContract = "legacy-package" | "sdk-api" | "unknown";
|
||||
|
||||
export function detectHostContract(seed: unknown): HostContract {
|
||||
if (!isObject(seed)) return "unknown";
|
||||
const hasApi = "api" in seed;
|
||||
const hasPackage = "package" in seed;
|
||||
if (hasApi && !hasPackage) return "sdk-api";
|
||||
if (hasPackage && !hasApi) return "legacy-package";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to publish the legacy top-level fields (`package`, `settings`,
|
||||
* `headers`, `variants[].settings`) next to the `api`-block fields.
|
||||
*
|
||||
* A host proven to speak the legacy contract gets them because it needs them;
|
||||
* an unrecognised host gets them because the superset is the safer default
|
||||
* (both field sets have been observed to survive an unknown-key write). A host
|
||||
* that speaks the `api` contract does not, so a future strict schema cannot
|
||||
* reject the write on an excess property.
|
||||
*/
|
||||
export function emitsLegacyFields(contract: HostContract): boolean {
|
||||
return contract !== "sdk-api";
|
||||
}
|
||||
101
@omniroute/opencode-plugin-v2/src/credentials.ts
Normal file
101
@omniroute/opencode-plugin-v2/src/credentials.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { Logger } from "./shared/index.js";
|
||||
|
||||
/** Where a resolved key came from, so the failure message can name the fix. */
|
||||
export type ApiKeyOrigin = "connection" | "option" | "env" | "missing";
|
||||
|
||||
export interface ResolvedApiKey {
|
||||
key: string;
|
||||
origin: ApiKeyOrigin;
|
||||
}
|
||||
|
||||
const ENV_VAR = "OMNIROUTE_API_KEY";
|
||||
|
||||
/**
|
||||
* `ctx.integration.connection` is newer than the `key`/`env` methods this
|
||||
* plugin registers, so a host that predates it exposes `integration` without
|
||||
* it. Probing the shape keeps the plugin loadable on both.
|
||||
*/
|
||||
function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined {
|
||||
const connection = (ctx.integration as Partial<PluginContext["integration"]>).connection;
|
||||
if (
|
||||
connection === undefined ||
|
||||
typeof connection.active !== "function" ||
|
||||
typeof connection.resolve !== "function"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the credential the user stored through the host's own auth flow.
|
||||
*
|
||||
* The plugin advertises `key` and `env` methods on its integration, so a user
|
||||
* can connect it from the UI; without this lookup that connection would only
|
||||
* feed inference and the catalog fetches would still need a key pasted into
|
||||
* the config file.
|
||||
*
|
||||
* Returns `undefined` (never throws) when there is no connection, when the
|
||||
* host is too old to expose one, or when the stored credential is an OAuth
|
||||
* grant — this plugin authenticates the gateway with a bearer key, and an
|
||||
* access token from an unrelated grant is not one.
|
||||
*/
|
||||
async function keyFromConnection(
|
||||
ctx: PluginContext,
|
||||
integrationID: string,
|
||||
log: Logger
|
||||
): Promise<string | undefined> {
|
||||
const connection = connectionApi(ctx);
|
||||
if (connection === undefined) return undefined;
|
||||
try {
|
||||
const active = await connection.active(integrationID);
|
||||
if (active === undefined) return undefined;
|
||||
const credential = await connection.resolve(active);
|
||||
if (credential === undefined) return undefined;
|
||||
if (credential.type !== "key") {
|
||||
log.warn(
|
||||
`[omniroute-v2] ignoring the stored ${credential.type} credential: this plugin authenticates with an API key`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return credential.key.length > 0 ? credential.key : undefined;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] could not read the stored credential: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the gateway key, preferring the credential the host holds over one
|
||||
* written in config. A key in `opencode.json` still wins over the environment
|
||||
* so an explicit per-project override keeps working.
|
||||
*/
|
||||
export async function resolveApiKey(
|
||||
ctx: PluginContext,
|
||||
integrationID: string,
|
||||
optionKey: string | undefined,
|
||||
log: Logger
|
||||
): Promise<ResolvedApiKey> {
|
||||
const stored = await keyFromConnection(ctx, integrationID, log);
|
||||
if (stored !== undefined) return { key: stored, origin: "connection" };
|
||||
if (optionKey !== undefined && optionKey.length > 0) return { key: optionKey, origin: "option" };
|
||||
const fromEnv = process.env[ENV_VAR];
|
||||
if (fromEnv !== undefined && fromEnv.length > 0) return { key: fromEnv, origin: "env" };
|
||||
return { key: "", origin: "missing" };
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing key produces an empty catalog and no error the user can see, so
|
||||
* say it once, and name the three ways to supply one.
|
||||
*/
|
||||
export function warnIfMissing(resolved: ResolvedApiKey, integrationID: string, log: Logger): void {
|
||||
if (resolved.origin !== "missing") return;
|
||||
log.warn(
|
||||
`[omniroute-v2] no API key for "${integrationID}": the catalog will be empty. ` +
|
||||
`Connect the integration from opencode, set "apiKey" in the plugin options, ` +
|
||||
`or export ${ENV_VAR}.`
|
||||
);
|
||||
}
|
||||
41
@omniroute/opencode-plugin-v2/src/enrichment-report.ts
Normal file
41
@omniroute/opencode-plugin-v2/src/enrichment-report.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Logger } from "./shared/index.js";
|
||||
|
||||
/** What the catalog loses when a given gateway source cannot be read. */
|
||||
function consequenceOf(endpoint: string): string {
|
||||
if (endpoint.includes("/api/providers")) {
|
||||
return "the usable-provider filter is disabled for this refresh, so unprovisioned providers stay listed";
|
||||
}
|
||||
return "model names, provider tags, canonical dedupe and pricing are degraded";
|
||||
}
|
||||
|
||||
/**
|
||||
* A source the gateway refuses is not fatal — the catalog still publishes —
|
||||
* but staying quiet about it is: the picker then shows raw ids, or lists
|
||||
* providers that cannot serve, with nothing telling the user why. Say it once
|
||||
* per endpoint so a refresh loop cannot spam the log.
|
||||
*
|
||||
* `usingFallbackToken` is true when no `managementReadToken` was configured and
|
||||
* the inference key stands in for it, which is the usual reason a gateway
|
||||
* answers 401/403 on `/api/*` — the advice differs from a token that was set
|
||||
* and still got rejected.
|
||||
*/
|
||||
export function createSourceErrorReporter(
|
||||
log: Logger,
|
||||
usingFallbackToken: boolean
|
||||
): (endpoint: string, reason: string) => void {
|
||||
const warned = new Set<string>();
|
||||
return (endpoint, reason) => {
|
||||
if (warned.has(endpoint)) return;
|
||||
warned.add(endpoint);
|
||||
const unauthorized = reason.includes("401") || reason.includes("403");
|
||||
const hint = !unauthorized
|
||||
? ""
|
||||
: usingFallbackToken
|
||||
? ` These endpoints need a management token: set "managementReadToken" in the plugin options ` +
|
||||
`(it currently falls back to "apiKey", which a gateway usually rejects here).`
|
||||
: ` The configured "managementReadToken" was rejected — check it grants read access to /api/*.`;
|
||||
log.warn(
|
||||
`[omniroute-v2] gateway source ${endpoint} unavailable (${reason}): ${consequenceOf(endpoint)}.${hint}`
|
||||
);
|
||||
};
|
||||
}
|
||||
43
@omniroute/opencode-plugin-v2/src/gemini-language.ts
Normal file
43
@omniroute/opencode-plugin-v2/src/gemini-language.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider";
|
||||
import { type Logger, isGeminiModelId, sanitizeToolInputSchemas } from "./shared/index.js";
|
||||
|
||||
type CallOptions = Parameters<LanguageModelV3["doGenerate"]>[0];
|
||||
|
||||
/**
|
||||
* Gemini answers `400 INVALID_ARGUMENT` — for the entire request, not just the
|
||||
* offending tool — when a tool declaration carries `$schema` or
|
||||
* `additionalProperties`. Anything upstream that emits standard JSON Schema
|
||||
* therefore breaks tool calling as soon as the chain routes to Gemini. A
|
||||
* `$ref` is forwarded untouched instead: stripping it would widen the schema
|
||||
* to "accept anything", which is worse than letting the gateway answer. The
|
||||
* v1 plugin dealt with this by wrapping `fetch` and rewriting the JSON body; the
|
||||
* v2 home for it is the language model, where the tools are still structured
|
||||
* data and no re-parsing is needed.
|
||||
*
|
||||
* Returns the model untouched when it is not bound for Gemini, so the wrapper
|
||||
* costs nothing on every other chain.
|
||||
*/
|
||||
export function sanitizeToolSchemasFor<T extends LanguageModelV3 | undefined>(
|
||||
language: T,
|
||||
modelId: string,
|
||||
log: Logger
|
||||
): T {
|
||||
if (language === undefined) return language;
|
||||
if (!isGeminiModelId(modelId)) return language;
|
||||
|
||||
const clean = (options: CallOptions): CallOptions => {
|
||||
const tools = sanitizeToolInputSchemas(options.tools);
|
||||
if (tools === undefined) return options;
|
||||
log.debug(
|
||||
`[omniroute-v2] stripped Gemini-incompatible schema keywords from ${tools.length} tool declaration(s) for ${modelId}`
|
||||
);
|
||||
return { ...options, tools } as CallOptions;
|
||||
};
|
||||
|
||||
// Prototype-linked so every other member of the model — including accessors
|
||||
// and anything a future SDK version adds — keeps working untouched.
|
||||
const wrapped: LanguageModelV3 = Object.create(language as object) as LanguageModelV3;
|
||||
wrapped.doGenerate = (options) => language.doGenerate(clean(options));
|
||||
wrapped.doStream = (options) => language.doStream(clean(options));
|
||||
return wrapped as T;
|
||||
}
|
||||
539
@omniroute/opencode-plugin-v2/src/index.ts
Normal file
539
@omniroute/opencode-plugin-v2/src/index.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import {
|
||||
optionalTierFingerprint,
|
||||
catalogContentFingerprint,
|
||||
createLogger,
|
||||
defaultOmniRouteAutoCombosFetcher,
|
||||
defaultOmniRouteCombosFetcher,
|
||||
defaultOmniRouteEnrichmentFetcher,
|
||||
defaultOmniRouteModelsFetcher,
|
||||
defaultOmniRouteProvidersFetcher,
|
||||
type OmniRouteEnrichmentMap,
|
||||
type OmniRouteProviderConnection,
|
||||
} from "./shared/index.js";
|
||||
import type {
|
||||
OmniRouteRawAutoCombo,
|
||||
OmniRouteRawCombo,
|
||||
OmniRouteRawModelEntry,
|
||||
} from "./shared/index.js";
|
||||
import type { ResolvedOptions } from "./catalog.js";
|
||||
import { publishCatalog } from "./catalog.js";
|
||||
import {
|
||||
DEFAULT_MODEL_CACHE_TTL_MS,
|
||||
UNREACHABLE_COOLDOWN_MS,
|
||||
memoryCacheKey,
|
||||
readDiskSnapshot,
|
||||
snapshotIdentityFingerprint,
|
||||
writeDiskSnapshot,
|
||||
type CatalogSnapshot,
|
||||
} from "./cache.js";
|
||||
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";
|
||||
|
||||
/**
|
||||
* A fetch result that says whether it succeeded. Returning a bare `[]` on
|
||||
* failure makes an outage indistinguishable from a gateway that legitimately
|
||||
* has no combos — and the difference decides whether the last known value
|
||||
* should be kept or dropped.
|
||||
*/
|
||||
type SourceResult<T> = { ok: true; value: T } | { ok: false };
|
||||
|
||||
interface RefreshState {
|
||||
entries: Map<string, CatalogSnapshot>;
|
||||
inFlight: Map<string, Promise<CatalogSnapshot>>;
|
||||
fingerprint: string | undefined;
|
||||
/** Digest of the optional tier, so a reload only follows a real change. */
|
||||
optionalFingerprint: string | undefined;
|
||||
/**
|
||||
* When the last refresh found the gateway unreachable, skip the network
|
||||
* until this timestamp and serve last-known-good instead. Without it every
|
||||
* transform past TTL re-fires the full fetch suite against a gateway that
|
||||
* just proved it cannot answer — a self-inflicted retry storm.
|
||||
*/
|
||||
unreachableUntil: number;
|
||||
}
|
||||
|
||||
function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
|
||||
return {
|
||||
providerId: parsed.providerId,
|
||||
baseURL: parsed.baseURL,
|
||||
apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "",
|
||||
managementReadToken: parsed.managementReadToken,
|
||||
timeoutMs: parsed.timeoutMs,
|
||||
timeouts: parsed.timeouts,
|
||||
logLevel: parsed.logLevel,
|
||||
startupDebug: parsed.startupDebug,
|
||||
providerTag: parsed.providerTag,
|
||||
modelCacheTtlMs:
|
||||
typeof parsed.modelCacheTtlMs === "number" && parsed.modelCacheTtlMs > 0
|
||||
? parsed.modelCacheTtlMs
|
||||
: DEFAULT_MODEL_CACHE_TTL_MS,
|
||||
displayName: parsed.displayName,
|
||||
apiFormat: parsed.apiFormat,
|
||||
visibleModels: parsed.visibleModels,
|
||||
hiddenModels: parsed.hiddenModels,
|
||||
usableOnly: parsed.usableOnly,
|
||||
enrichment: parsed.enrichment,
|
||||
};
|
||||
}
|
||||
|
||||
export default define({
|
||||
id: PLUGIN_ID,
|
||||
setup: async (ctx: PluginContext) => {
|
||||
assertContext(ctx);
|
||||
const parsed = parsePluginOptions(ctx.options);
|
||||
const X = parsed.providerId;
|
||||
const resolved = toResolvedOptions(parsed);
|
||||
const timeouts = resolveTimeouts(parsed);
|
||||
const log = createLogger(parsed.startupDebug ? "debug" : (parsed.logLevel ?? "warn"));
|
||||
resolved.logger = log;
|
||||
resolved.logLevel = parsed.logLevel;
|
||||
resolved.startupDebug = parsed.startupDebug;
|
||||
log.info(`[omniroute-v2] init providerId=${X}`);
|
||||
|
||||
// v1 parity port: in-memory TTL + disk snapshot. The memory key
|
||||
// `baseURL::sha256(creds)` isolates credential tuples (prod vs
|
||||
// staging); the TTL is checked in the transform before any fetch;
|
||||
// concurrent calls share the refresh promise in the setup closure keyed
|
||||
// by (providerId, baseURL); the disk snapshot feeds warm-startup and
|
||||
// the offline fallback. The existing in-memory keep-last-good is kept.
|
||||
const state: RefreshState = {
|
||||
entries: new Map(),
|
||||
inFlight: new Map(),
|
||||
fingerprint: undefined,
|
||||
optionalFingerprint: undefined,
|
||||
unreachableUntil: 0,
|
||||
};
|
||||
|
||||
// The credential the host holds wins over one written in config, so a
|
||||
// user who connected the integration from the UI never has to paste a
|
||||
// key into `opencode.json`. Reading it is async and the transforms must
|
||||
// register synchronously, so the lookup happens on the first publish;
|
||||
// until then the option/env key resolved above stands in.
|
||||
const credentialsOf = (): { cacheKey: string; identityFingerprint: string } => ({
|
||||
cacheKey: memoryCacheKey(
|
||||
resolved.baseURL,
|
||||
`${resolved.apiKey}\0${resolved.managementReadToken ?? resolved.apiKey}`
|
||||
),
|
||||
identityFingerprint: snapshotIdentityFingerprint(
|
||||
resolved.baseURL,
|
||||
resolved.apiKey,
|
||||
resolved.managementReadToken ?? resolved.apiKey
|
||||
),
|
||||
});
|
||||
let { cacheKey, identityFingerprint } = credentialsOf();
|
||||
|
||||
// Both keys are derived from the credential: two credentials must never
|
||||
// share a snapshot, so they are recomputed whenever the key moves.
|
||||
let credentialChecked = false;
|
||||
let apiKeyOrigin: ApiKeyOrigin = resolved.apiKey.length > 0 ? "option" : "missing";
|
||||
const ensureCredential = async (): Promise<void> => {
|
||||
// Settled once a key is in hand: re-reading on every refresh would let
|
||||
// a mid-session change silently repoint the snapshot keys.
|
||||
if (credentialChecked && apiKeyOrigin !== "missing") return;
|
||||
const next = await resolveApiKey(ctx, X, parsed.apiKey, log);
|
||||
const moved = next.key !== resolved.apiKey;
|
||||
resolved.apiKey = next.key;
|
||||
apiKeyOrigin = next.origin;
|
||||
if (moved) ({ cacheKey, identityFingerprint } = credentialsOf());
|
||||
if (!credentialChecked) warnIfMissing(next, X, log);
|
||||
else if (moved) log.info(`[omniroute-v2] API key picked up from the ${next.origin} source`);
|
||||
credentialChecked = true;
|
||||
};
|
||||
|
||||
const fetchModelsSafe = async (): Promise<OmniRouteRawModelEntry[]> => {
|
||||
try {
|
||||
return await defaultOmniRouteModelsFetcher(
|
||||
resolved.baseURL,
|
||||
resolved.apiKey,
|
||||
timeouts.models
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
// Failures are reported once per endpoint (with the management-token hint
|
||||
// when the inference key stands in), so a gated `/api/*` degrades loudly
|
||||
// rather than silently. Declared before the wrappers that use it.
|
||||
const reportSourceError = createSourceErrorReporter(
|
||||
log,
|
||||
resolved.managementReadToken === undefined
|
||||
);
|
||||
const fetchCombosSafe = async (): Promise<SourceResult<OmniRouteRawCombo[]>> => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await defaultOmniRouteCombosFetcher(
|
||||
resolved.baseURL,
|
||||
resolved.managementReadToken ?? resolved.apiKey,
|
||||
timeouts.combos
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
reportSourceError("/api/combos", reason);
|
||||
log.warn(`[omniroute-v2] combos fetch failed, keeping the last known combos: ${reason}`);
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
// Providers connections follow the same rule: gated on usableOnly (no
|
||||
// request when false, v1 parity), soft-fail to [] so the filter degrades
|
||||
// to keep-all instead of hiding the catalog.
|
||||
const fetchProvidersSafe = async (): Promise<SourceResult<OmniRouteProviderConnection[]>> => {
|
||||
if (!resolved.usableOnly) return { ok: true, value: [] };
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await defaultOmniRouteProvidersFetcher(
|
||||
resolved.baseURL,
|
||||
resolved.managementReadToken ?? resolved.apiKey,
|
||||
timeouts.models,
|
||||
reportSourceError
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] providers fetch failed, keeping the last known provider list: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
// Enrichment follows the same rule: gated on the option (default on,
|
||||
// v1 parity), soft-fail to an empty map so names/pricing degrade to
|
||||
// mapper defaults instead of hiding the catalog.
|
||||
const fetchEnrichmentSafe = async (): Promise<SourceResult<OmniRouteEnrichmentMap>> => {
|
||||
if (resolved.enrichment === false) return { ok: true, value: new Map() };
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await defaultOmniRouteEnrichmentFetcher(
|
||||
resolved.baseURL,
|
||||
resolved.managementReadToken ?? resolved.apiKey,
|
||||
timeouts.enrichment,
|
||||
reportSourceError
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] enrichment fetch failed, keeping the last known names/pricing: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
const fetchAutoCombosSafe = async (): Promise<SourceResult<OmniRouteRawAutoCombo[]>> => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await defaultOmniRouteAutoCombosFetcher(
|
||||
resolved.baseURL,
|
||||
resolved.managementReadToken ?? resolved.apiKey,
|
||||
timeouts.autoCombos,
|
||||
log,
|
||||
reportSourceError
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
// The default fetcher reports the refusal itself (with the
|
||||
// management-token hint); this warn is the fallback for injected
|
||||
// stubs that throw without reporting.
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`[omniroute-v2] auto combos fetch failed, keeping the last known ones: ${reason}`);
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch in two tiers. Models are what a catalog *is*: without them there
|
||||
* is nothing to publish. Everything else — combos, auto-combos, the
|
||||
* provider list, the enrichment overlay — improves an already usable
|
||||
* catalog, so awaiting any of them before publishing makes the catalog
|
||||
* hostage to the slowest source: a gateway that accepts the connection
|
||||
* and never answers one endpoint kept everything unpublished until that
|
||||
* fetch's own timeout fired, which is longer than some hosts stay alive.
|
||||
*
|
||||
* The optional tier therefore keeps running after the publish and upgrades
|
||||
* the stored snapshot when it lands, so the next transform serves the
|
||||
* complete catalog.
|
||||
*/
|
||||
async function refreshSnapshot(): Promise<CatalogSnapshot> {
|
||||
// Models are what a catalog *is*; everything else improves one that
|
||||
// already works. Combos used to sit here too, so a gateway slow to
|
||||
// answer /api/combos held the whole picker back — the very thing the
|
||||
// staged publish exists to prevent.
|
||||
const essential = fetchModelsSafe();
|
||||
const optional = Promise.all([
|
||||
fetchCombosSafe(),
|
||||
fetchAutoCombosSafe(),
|
||||
fetchProvidersSafe(),
|
||||
fetchEnrichmentSafe(),
|
||||
]);
|
||||
const models = await essential;
|
||||
const previous = state.entries.get(cacheKey);
|
||||
// A gateway that just failed everything gets a short breather: serving
|
||||
// last-known-good for a few seconds beats hammering it on every
|
||||
// transform while it is down. Arms whenever the models fetch comes back
|
||||
// empty — with or without a prior entry to serve — so a totally dead
|
||||
// gateway stops getting hit every window. Partial degradation (models
|
||||
// healthy, an optional tier failed) still retries normally next window.
|
||||
if (models.length === 0) {
|
||||
state.unreachableUntil = Date.now() + UNREACHABLE_COOLDOWN_MS;
|
||||
}
|
||||
// Carry every source forward until its replacement lands, and keep the
|
||||
// old value when a fetch FAILED — but honour a gateway that legitimately
|
||||
// returns nothing, which is a different answer from "I could not ask".
|
||||
const snapshot: CatalogSnapshot = {
|
||||
models,
|
||||
combos: previous?.combos ?? [],
|
||||
autoCombos: previous?.autoCombos ?? [],
|
||||
providers: previous?.providers ?? [],
|
||||
enrichment: previous?.enrichment ?? new Map(),
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
if (models.length > 0) {
|
||||
state.entries.set(cacheKey, snapshot);
|
||||
await writeDiskSnapshot(X, snapshot, identityFingerprint);
|
||||
}
|
||||
void optional.then(
|
||||
(parts) => upgradeWithOptional(snapshot, parts),
|
||||
(err) => {
|
||||
// The wrappers never reject; a throw here would be a bug in them, and
|
||||
// an unhandled rejection is a worse way to learn about it.
|
||||
log.warn(
|
||||
`[omniroute-v2] optional catalog sources failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold late optional data into the snapshot that was published without it.
|
||||
* Skipped when a newer refresh has already replaced that snapshot, so a
|
||||
* slow tier can never resurrect a stale catalog.
|
||||
*/
|
||||
async function upgradeWithOptional(
|
||||
base: CatalogSnapshot,
|
||||
[combos, autoCombos, providers, enrichment]: [
|
||||
SourceResult<OmniRouteRawCombo[]>,
|
||||
SourceResult<OmniRouteRawAutoCombo[]>,
|
||||
SourceResult<OmniRouteProviderConnection[]>,
|
||||
SourceResult<OmniRouteEnrichmentMap>,
|
||||
]
|
||||
): Promise<void> {
|
||||
if (state.entries.get(cacheKey) !== base) return;
|
||||
// Per source: a success replaces (even with an empty answer — that is
|
||||
// the gateway's answer), a failure keeps what we had.
|
||||
const upgraded: CatalogSnapshot = {
|
||||
...base,
|
||||
combos: combos.ok ? combos.value : base.combos,
|
||||
autoCombos: autoCombos.ok ? autoCombos.value : base.autoCombos,
|
||||
providers: providers.ok ? providers.value : base.providers,
|
||||
enrichment: enrichment.ok ? enrichment.value : base.enrichment,
|
||||
};
|
||||
const unchanged =
|
||||
upgraded.combos === base.combos &&
|
||||
upgraded.autoCombos === base.autoCombos &&
|
||||
upgraded.providers === base.providers &&
|
||||
upgraded.enrichment === base.enrichment;
|
||||
if (unchanged) return;
|
||||
state.entries.set(cacheKey, upgraded);
|
||||
if (upgraded.models.length > 0) {
|
||||
await writeDiskSnapshot(X, upgraded, identityFingerprint);
|
||||
}
|
||||
// Reload only when the optional tier actually moved: the catalog
|
||||
// fingerprint covers ids alone, so without this the host would rebuild
|
||||
// its catalog once per TTL window for an identical result.
|
||||
const optionalFingerprint = optionalTierFingerprint(
|
||||
upgraded.autoCombos ?? [],
|
||||
upgraded.providers ?? [],
|
||||
upgraded.enrichment,
|
||||
upgraded.combos
|
||||
);
|
||||
const optionalChanged = state.optionalFingerprint !== optionalFingerprint;
|
||||
state.optionalFingerprint = optionalFingerprint;
|
||||
if (optionalChanged && typeof ctx.catalog.reload === "function") {
|
||||
try {
|
||||
await ctx.catalog.reload();
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSnapshot(): Promise<CatalogSnapshot> {
|
||||
const now = Date.now();
|
||||
const hit = state.entries.get(cacheKey);
|
||||
if (hit && hit.fetchedAt + resolved.modelCacheTtlMs > now) return Promise.resolve(hit);
|
||||
// Cooldown after a total models failure: skip the network until it
|
||||
// lapses. Serves last-known-good when one exists; otherwise the refresh
|
||||
// below still runs (nothing to serve, no point pretending).
|
||||
if (now < state.unreachableUntil && hit) return Promise.resolve(hit);
|
||||
if (now >= state.unreachableUntil) state.unreachableUntil = 0;
|
||||
const inflight = state.inFlight.get(cacheKey);
|
||||
if (inflight) return inflight;
|
||||
const snapshot = refreshSnapshot();
|
||||
state.inFlight.set(cacheKey, snapshot);
|
||||
const clear = () => {
|
||||
if (state.inFlight.get(cacheKey) === snapshot) state.inFlight.delete(cacheKey);
|
||||
};
|
||||
snapshot.then(clear, clear);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Warm-startup: the disk snapshot is read at boot (without blocking
|
||||
// the synchronous transform registration) to publish the last-known
|
||||
// catalog before the first successful fetch.
|
||||
/**
|
||||
* Warm start: publish the last known catalog from disk before the first
|
||||
* fetch returns. Deliberately read *after* the credential is resolved —
|
||||
* the snapshot is keyed by the credential tuple, and resolving the host
|
||||
* credential changes that key, so reading at setup time would look up the
|
||||
* wrong identity and reject a perfectly good snapshot.
|
||||
*/
|
||||
let warmLoadedFor: string | undefined;
|
||||
const ensureWarmSnapshot = async (): Promise<void> => {
|
||||
if (warmLoadedFor === identityFingerprint) return;
|
||||
warmLoadedFor = identityFingerprint;
|
||||
const warm = await readDiskSnapshot(X, identityFingerprint, log);
|
||||
if (warm && !state.entries.has(cacheKey)) state.entries.set(cacheKey, warm);
|
||||
};
|
||||
|
||||
// Fail-closed models (keep-last-good, validated): an empty models fetch
|
||||
// (transient 500/timeout) must not wipe a known catalog. The latest
|
||||
// non-empty entry (fresh fetch or warm disk snapshot) is replayed
|
||||
// instead of publishing the empty set. `refreshSnapshot` never overwrites
|
||||
// the memory entry on failure, so `entries` stays the last-known-good
|
||||
// source — including cross-setup via the disk snapshot.
|
||||
// Fail-open one level down, in the wrappers (never reject) and the
|
||||
// `publishCatalog` catches — so no try/catch here.
|
||||
const catalogRegistration = ctx.catalog.transform(async (draft) => {
|
||||
await ensureCredential();
|
||||
await ensureWarmSnapshot();
|
||||
const snapshot = await loadSnapshot();
|
||||
let effective = snapshot;
|
||||
if (snapshot.models.length === 0) {
|
||||
const stale = state.entries.get(cacheKey);
|
||||
if (stale !== undefined && stale.models.length > 0) {
|
||||
log.warn(
|
||||
`[omniroute-v2] models fetch returned empty, keeping last-known catalog (${stale.models.length} models, ${stale.combos.length} combos)`
|
||||
);
|
||||
effective = stale;
|
||||
}
|
||||
}
|
||||
const counts = await (async (): Promise<{
|
||||
models: number;
|
||||
combos: number;
|
||||
autoCombos: number;
|
||||
}> => {
|
||||
// fetcher-level fail-open covers fetches; this guard covers mapper/draft throws.
|
||||
try {
|
||||
return await publishCatalog(draft, resolved, {
|
||||
onSourceError: reportSourceError,
|
||||
models: async () => effective.models,
|
||||
combos: async () => effective.combos,
|
||||
autoCombos: async () => effective.autoCombos,
|
||||
providers: async () => effective.providers ?? [],
|
||||
enrichment: async () => effective.enrichment ?? new Map(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { models: 0, combos: 0, autoCombos: 0 };
|
||||
}
|
||||
})();
|
||||
void counts;
|
||||
const fingerprint = catalogContentFingerprint(
|
||||
effective.models,
|
||||
effective.combos,
|
||||
effective.autoCombos
|
||||
);
|
||||
const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint;
|
||||
state.fingerprint = fingerprint;
|
||||
if (changed && typeof ctx.catalog.reload === "function") {
|
||||
await Promise.resolve();
|
||||
try {
|
||||
await ctx.catalog.reload();
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
const integrationHook = (ctx.integration as Partial<PluginContext["integration"]> | undefined)
|
||||
?.transform;
|
||||
// A host that exposes the hook but throws while registering it must cost
|
||||
// the plugin nothing but the connect action: the throw happens OUTSIDE
|
||||
// any await, so only a call-site guard catches it (an await-guard alone
|
||||
// would let a synchronous throw escape setup and kill the catalog).
|
||||
let integrationRegistration: unknown;
|
||||
if (typeof integrationHook === "function") {
|
||||
try {
|
||||
integrationRegistration = integrationHook((draft) => {
|
||||
draft.update(X, (integration) => {
|
||||
integration.name = parsed.displayName ?? "OmniRoute";
|
||||
});
|
||||
draft.method.update({ integrationID: X, method: { type: "key", label: "API key" } });
|
||||
draft.method.update({
|
||||
integrationID: X,
|
||||
method: { type: "env", names: ["OMNIROUTE_API_KEY"] },
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
integrationRegistration = undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* `aisdk.language` is newer than the catalog domain, so a host may not
|
||||
* expose it; the plugin must stay loadable there, minus the sanitising.
|
||||
*/
|
||||
const languageHook = (ctx.aisdk as Partial<PluginContext["aisdk"]> | undefined)?.language;
|
||||
// A host that rejects this registration must cost the catalog nothing: the
|
||||
// plugin is a catalog first, and tool-schema cleaning is an extra.
|
||||
let languageRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
|
||||
if (parsed.geminiSanitization !== false && typeof languageHook === "function") {
|
||||
try {
|
||||
languageRegistration = languageHook((input) => {
|
||||
if (input.model.providerID !== X) return;
|
||||
input.language = sanitizeToolSchemasFor(input.language, input.model.id, log);
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] host refused the language-model hook, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await catalogRegistration;
|
||||
if (integrationRegistration !== undefined) {
|
||||
try {
|
||||
await integrationRegistration;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (languageRegistration !== undefined) {
|
||||
try {
|
||||
await languageRegistration;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] language-model hook registration failed, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
117
@omniroute/opencode-plugin-v2/src/options.ts
Normal file
117
@omniroute/opencode-plugin-v2/src/options.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const apiFormatSchema = z
|
||||
.object({
|
||||
allowAnthropic: z.boolean().optional(),
|
||||
anthropicModels: z.array(z.string()).optional(),
|
||||
// Deprecated v1 prefix list. Accepted (warn at resolve time) so copied
|
||||
// v1 configs keep routing; prefer anthropicModels (full IDs).
|
||||
anthropicPrefixes: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const timeoutsSchema = z
|
||||
.object({
|
||||
models: z.number().positive().optional(),
|
||||
combos: z.number().positive().optional(),
|
||||
autoCombos: z.number().positive().optional(),
|
||||
enrichment: z.number().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const pluginOptionsSchema = z
|
||||
.object({
|
||||
// Reaches a filesystem path (the on-disk catalog snapshot) and the
|
||||
// catalog keys, so it is bounded here rather than escaped at each use.
|
||||
providerId: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'")
|
||||
.refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment")
|
||||
.default("omniroute"),
|
||||
baseURL: z.string().url(),
|
||||
apiKey: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
managementReadToken: z.string().optional(),
|
||||
timeoutMs: z.number().positive().default(10000),
|
||||
timeouts: timeoutsSchema.optional(),
|
||||
logLevel: z.enum(["error", "warn", "info", "debug"]).optional(),
|
||||
startupDebug: z.boolean().optional(),
|
||||
modelCacheTtlMs: z.number().positive().optional(),
|
||||
visibleModels: z.array(z.string()).optional(),
|
||||
hiddenModels: z.array(z.string()).optional(),
|
||||
usableOnly: z.boolean().default(false),
|
||||
// v1 parity: enrichment overlay on by default (names + pricing).
|
||||
enrichment: z.boolean().default(true),
|
||||
// v1 parity: strip the JSON-Schema keywords Gemini rejects from tool
|
||||
// declarations bound for a Gemini model. On by default — leaving them in
|
||||
// fails the whole request with 400 INVALID_ARGUMENT.
|
||||
geminiSanitization: z.boolean().default(true),
|
||||
// v1 parity: prefix a model's display name with the upstream provider it
|
||||
// routes to, so the same model sold through two connections is
|
||||
// distinguishable in the picker.
|
||||
providerTag: z.boolean().default(true),
|
||||
apiFormat: apiFormatSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PluginOptions = z.infer<typeof pluginOptionsSchema>;
|
||||
|
||||
/** 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. */
|
||||
export const DEFAULT_AUTO_COMBOS_TIMEOUT_MS = 5_000 as const;
|
||||
|
||||
export interface EndpointTimeouts {
|
||||
models: number;
|
||||
combos: number;
|
||||
autoCombos: number;
|
||||
enrichment: number;
|
||||
}
|
||||
|
||||
export function resolveTimeouts(
|
||||
opts: Pick<PluginOptions, "timeoutMs" | "timeouts">
|
||||
): EndpointTimeouts {
|
||||
const fallback =
|
||||
typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
||||
return {
|
||||
models: opts.timeouts?.models ?? fallback,
|
||||
combos: opts.timeouts?.combos ?? fallback,
|
||||
autoCombos: opts.timeouts?.autoCombos ?? DEFAULT_AUTO_COMBOS_TIMEOUT_MS,
|
||||
enrichment: opts.timeouts?.enrichment ?? fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the plugin block of `opencode.json`.
|
||||
*
|
||||
* A rejected option aborts the whole plugin, and the host reports that as a
|
||||
* bare load failure with the validator's raw dump attached — which is how a
|
||||
* single mistyped key turns into a wall of JSON and an empty model picker. The
|
||||
* schema is strict on purpose (a silently ignored option is worse), so the
|
||||
* least we owe the user is a first line naming what to fix.
|
||||
*/
|
||||
export function parsePluginOptions(raw: unknown): PluginOptions {
|
||||
const result = pluginOptionsSchema.safeParse(raw);
|
||||
if (result.success) return result.data;
|
||||
const problems = result.error.issues.map((issue) => {
|
||||
const at = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
||||
const unknown = issue.code === "unrecognized_keys" ? issue.keys.join(", ") : undefined;
|
||||
return unknown !== undefined ? `unknown option "${unknown}"` : `${at}: ${issue.message}`;
|
||||
});
|
||||
throw new Error(`[omniroute-v2] invalid plugin options — ${problems.join("; ")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host reads the plugin id from the module, before any option is known, so
|
||||
* it cannot carry the configured provider id. Publishing two gateways from one
|
||||
* install is a `providerId` matter — that one does reach the catalog.
|
||||
*/
|
||||
export const PLUGIN_ID = "omniroute-v2";
|
||||
|
||||
export function providerIdFor(providerId: string): string {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
export function integrationIdFor(providerId: string): string {
|
||||
return providerId;
|
||||
}
|
||||
219
@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts
Normal file
219
@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { ApiFormatV2 } from "./models-map.js";
|
||||
import { resolveApiBlockV2 } from "./models-map.js";
|
||||
import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js";
|
||||
|
||||
export type { AutoVariant };
|
||||
|
||||
/**
|
||||
* Raw shape of an auto combo entry as returned by OmniRoute's
|
||||
* `/api/combos/auto` endpoint. Auto combos are virtual -- they self-manage
|
||||
* provider selection via scoring/bandit exploration at runtime.
|
||||
*
|
||||
* Ported from the v1 plugin (`index.ts:1672-1698`); the shape is unchanged
|
||||
* so old and new gateways stay wire-compatible.
|
||||
*/
|
||||
export interface OmniRouteRawAutoCombo {
|
||||
/** Stable id (e.g. "auto", "auto/coding"). */
|
||||
id: string;
|
||||
/** Human-readable name (e.g. "Auto", "Auto Coding"). */
|
||||
name?: string;
|
||||
/** Variant key or undefined for the default auto. */
|
||||
variant?: AutoVariant;
|
||||
/** Provider names eligible for this auto combo. */
|
||||
candidatePool?: string[];
|
||||
/** Number of candidates resolved at fetch time. */
|
||||
candidateCount?: number;
|
||||
/** MAX of candidates' context windows, served by newer gateway builds.
|
||||
* Absent on older servers -- the mapper falls back to a safe default. */
|
||||
context_length?: number;
|
||||
/** MAX of candidates' max output tokens (same provenance as context_length). */
|
||||
max_output_tokens?: number;
|
||||
/** Whether this auto combo should be hidden from the picker. */
|
||||
isHidden?: boolean;
|
||||
/** Auto-combo configuration. */
|
||||
config?: {
|
||||
auto?: {
|
||||
candidatePool?: string[];
|
||||
explorationRate?: number;
|
||||
routerStrategy?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal warn sink so the fetcher never depends on the plugin logger. */
|
||||
export interface AutoCombosWarnSink {
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher contract for `/api/combos/auto`. Returns the list of virtual
|
||||
* auto combos the server can create. Same DI shape as the other fetchers
|
||||
* so unit tests can inject a stub instead of monkey-patching `fetch`.
|
||||
*
|
||||
* HTTP refusals (non-2xx other than 404) and network errors THROW: the caller
|
||||
* distinguishes "the gateway failed" (keep last-known) from "the gateway
|
||||
* answered empty" (publish empty). Only 404 stays soft — the endpoint does
|
||||
* not exist yet on older gateways, and that is an answer, not a failure.
|
||||
*/
|
||||
export type OmniRouteAutoCombosFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number,
|
||||
logger?: AutoCombosWarnSink,
|
||||
onSourceError?: (endpoint: string, reason: string) => void
|
||||
) => Promise<OmniRouteRawAutoCombo[]>;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let i = value.length;
|
||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
|
||||
return i === value.length ? value : value.slice(0, i);
|
||||
}
|
||||
|
||||
function fallbackWarn(message: string, ...args: unknown[]): void {
|
||||
console.warn(`[omniroute-plugin] [WARN] ${message}`, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default auto combos fetcher: `GET <baseURL>/api/combos/auto`.
|
||||
*
|
||||
* 404 stays soft (endpoint not deployed yet on older gateways — an answer,
|
||||
* not a failure). Any other non-2xx or network error THROWS so the caller
|
||||
* keeps last-known instead of publishing an empty tier: a 403 behind a
|
||||
* management-token gate must not wipe the auto combos the picker had.
|
||||
* v1 parity keeps the 5s timeout budget.
|
||||
*/
|
||||
export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 5_000,
|
||||
logger?: AutoCombosWarnSink,
|
||||
onSourceError?: (endpoint: string, reason: string) => void
|
||||
) => {
|
||||
if (!apiKey || !baseURL) return [];
|
||||
const warn = logger?.warn ?? fallbackWarn;
|
||||
const report = (reason: string): void => {
|
||||
warn(reason);
|
||||
onSourceError?.("/api/combos/auto", reason);
|
||||
};
|
||||
|
||||
const trimmed = trimTrailingSlashes(baseURL);
|
||||
const root = trimmed.replace(/\/v\d+$/, "");
|
||||
const url = `${root}/api/combos/auto`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
// 404 = endpoint not deployed yet -- expected during rollout
|
||||
if (res.status === 404) {
|
||||
warn(`/api/combos/auto not available (404) -- auto combos disabled`);
|
||||
return [];
|
||||
}
|
||||
if (!res.ok) {
|
||||
const reason = `HTTP ${res.status} ${res.statusText}`;
|
||||
report(`/api/combos/auto refused (${reason}) -- keeping last-known auto combos`);
|
||||
throw new Error(reason);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const rawList: unknown[] = Array.isArray(body)
|
||||
? body
|
||||
: body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos)
|
||||
? ((body as { combos: unknown[] }).combos as unknown[])
|
||||
: [];
|
||||
const out: OmniRouteRawAutoCombo[] = [];
|
||||
for (const r of rawList) {
|
||||
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
|
||||
out.push(r as OmniRouteRawAutoCombo);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Network error, timeout, abort -- keep last-known, never publish empty.
|
||||
// (The 404-soft path above returns directly and never reaches this throw.)
|
||||
const reason = `/api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} -- keeping last-known auto combos`;
|
||||
report(reason);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/** Fallbacks when the server does not advertise auto-combo limits (older
|
||||
* gateway builds). MUST be positive: OpenCode's overflow guard treats
|
||||
* `limit.context === 0` as "never overflow" and silently DISABLES smart
|
||||
* auto-compaction, letting the session grow until the gateway's destructive
|
||||
* history purge kicks in. */
|
||||
export const AUTO_COMBO_FALLBACK_CONTEXT = 128_000;
|
||||
export const AUTO_COMBO_FALLBACK_OUTPUT = 8_192;
|
||||
|
||||
/**
|
||||
* Convert a raw auto combo into a `ModelV2` entry for the picker.
|
||||
* Auto combos route to capable models, so tool_call and reasoning default
|
||||
* to true. Context/output limits come from the server (MAX of the
|
||||
* candidate pool's windows); a safe positive fallback applies when the
|
||||
* server omits them. Never 0.
|
||||
*/
|
||||
export function mapAutoComboToModelV2(
|
||||
autoCombo: OmniRouteRawAutoCombo,
|
||||
providerId: string,
|
||||
baseURL: string,
|
||||
apiFormat?: ApiFormatV2
|
||||
): ModelV2 {
|
||||
const name = formatAutoComboName(autoCombo.variant, autoCombo.candidateCount);
|
||||
const context =
|
||||
typeof autoCombo.context_length === "number" && autoCombo.context_length > 0
|
||||
? autoCombo.context_length
|
||||
: AUTO_COMBO_FALLBACK_CONTEXT;
|
||||
const output =
|
||||
typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0
|
||||
? autoCombo.max_output_tokens
|
||||
: AUTO_COMBO_FALLBACK_OUTPUT;
|
||||
return {
|
||||
id: autoComboModelId(autoCombo.variant),
|
||||
providerID: providerId,
|
||||
api: resolveApiBlockV2(autoComboModelId(autoCombo.variant), baseURL, apiFormat),
|
||||
name,
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context,
|
||||
output,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "",
|
||||
};
|
||||
}
|
||||
254
@omniroute/opencode-plugin-v2/src/shared/combos-map.ts
Normal file
254
@omniroute/opencode-plugin-v2/src/shared/combos-map.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js";
|
||||
|
||||
export interface OmniRouteRawComboMemberRef {
|
||||
/** Step kind: "model" references a raw model id; "combo-ref" nests another combo. */
|
||||
kind?: "model" | "combo-ref";
|
||||
/** Full model id referenced by this step (when kind === "model"). */
|
||||
model?: string;
|
||||
/** Nested combo name (when kind === "combo-ref"). */
|
||||
comboName?: string;
|
||||
/** Routing weight inside the combo (0–100, advisory at LCD time). */
|
||||
weight?: number;
|
||||
/** Step-local label, distinct from the parent combo's display name. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface OmniRouteRawCombo {
|
||||
id: string;
|
||||
name?: string;
|
||||
/** Routing strategy. Surfaced for forward-compat but not consumed by LCD. */
|
||||
strategy?: string;
|
||||
/** Member step list. Only `kind: "model"` steps participate in LCD. */
|
||||
models?: OmniRouteRawComboMemberRef[];
|
||||
/** Hidden combos are excluded from the OC model picker. */
|
||||
isHidden?: boolean;
|
||||
/** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */
|
||||
release_date?: string;
|
||||
/**
|
||||
* Server-computed context window for this combo (aggregated from member
|
||||
* models using the same logic as /v1/models). When present, the client
|
||||
* uses this value directly instead of re-aggregating from member models.
|
||||
*
|
||||
* Added in 3.9.x — old servers do not send it.
|
||||
*/
|
||||
computed_context_length?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher contract for `/api/combos`. Same DI shape as
|
||||
* `OmniRouteModelsFetcher` so unit tests can inject a stub instead of
|
||||
* monkey-patching global `fetch`.
|
||||
*/
|
||||
export type OmniRouteCombosFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number
|
||||
) => Promise<OmniRouteRawCombo[]>;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let i = value.length;
|
||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
|
||||
return i === value.length ? value : value.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default fetcher: `GET <baseURL>/api/combos` with bearer auth +
|
||||
* AbortController timeout. Accepts both the `{combos: [...]}` envelope the
|
||||
* gateway emits today and a bare-array envelope (defensive — keeps the
|
||||
* plugin working if a future OmniRoute build trims the wrapper).
|
||||
*
|
||||
* Differences from `defaultOmniRouteModelsFetcher`:
|
||||
* - URL is `/api/combos`, NOT `/v1/combos`. The `/v1/...` namespace is the
|
||||
* OpenAI-compatible surface (chat completions, models); combo discovery
|
||||
* lives on the management plane under `/api/...`. We tolerate both
|
||||
* `https://host` and `https://host/v1` baseURL forms by stripping the
|
||||
* trailing `/v1` segment before appending `/api/combos`.
|
||||
* - Combos endpoint requires a management-scoped API key when
|
||||
* `REQUIRE_API_KEY` is enabled. We don't enforce that here; the
|
||||
* gateway returns 401/403 with an actionable error which we propagate.
|
||||
*
|
||||
* Anything that isn't an object with a string `id` is filtered out silently.
|
||||
*/
|
||||
export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000
|
||||
) => {
|
||||
if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /api/combos");
|
||||
if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /api/combos");
|
||||
|
||||
// Strip trailing slashes, then strip a trailing `/v1` so we land on the
|
||||
// management plane. Models live under `/v1/models`; combos live under
|
||||
// `/api/combos` from the same gateway root.
|
||||
const trimmed = trimTrailingSlashes(baseURL);
|
||||
const root = trimmed.replace(/\/v\d+$/, "");
|
||||
const url = `${root}/api/combos`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const rawList: unknown[] = Array.isArray(body)
|
||||
? body
|
||||
: body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos)
|
||||
? ((body as { combos: unknown[] }).combos as unknown[])
|
||||
: [];
|
||||
const out: OmniRouteRawCombo[] = [];
|
||||
for (const r of rawList) {
|
||||
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
|
||||
out.push(r as OmniRouteRawCombo);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a raw combo entry → `ModelV2` by computing the lowest-common-denominator
|
||||
* (LCD) of its underlying member models. The LCD policy is the only way to
|
||||
* surface a single capability vector to OpenCode without lying: if any member
|
||||
* lacks a capability, the combo as a whole cannot guarantee it.
|
||||
*
|
||||
* LCD rules:
|
||||
* - `limit.context` = `min(...members.context_length)`.
|
||||
* - `limit.output` = `min(...members.max_output_tokens)`.
|
||||
* - `limit.input` = `min(...members.max_input_tokens)` ONLY when every
|
||||
* member declares one (ModelV2.limit.input is optional — better to
|
||||
* omit than to fabricate a min over partial data).
|
||||
* - `capabilities.toolcall` / `reasoning` / `attachment` / `temperature`:
|
||||
* `every(member ⇒ supports?)`. The `reasoning` axis ORs across
|
||||
* `reasoning` and `thinking` per member before AND-ing across the
|
||||
* combo (mirrors `mapRawModelToModelV2`). The `attachment` axis ORs
|
||||
* across `attachment` and `vision` per member. The `temperature` axis
|
||||
* uses default-true semantics: a member supports temperature unless
|
||||
* it explicitly declares `temperature: false`.
|
||||
* - `capabilities.input.*` / `output.*`: flattened AND across members'
|
||||
* modality flags. Missing arrays default to `["text"]` (same default
|
||||
* as `mapRawModelToModelV2`).
|
||||
*
|
||||
* Defensive: empty members array → ALL capabilities `false`, limits zero.
|
||||
* That's an intentional safety posture (you can't route through an empty
|
||||
* combo, so OC should grey it out in the picker).
|
||||
*
|
||||
* Spec mapping: `cost` zeroed; `status = "active"`;
|
||||
* `release_date = combo.release_date ?? ""`;
|
||||
* `api = LCD (all-anthropic else openai-compatible)`;
|
||||
* `name = combo.name ?? combo.id`.
|
||||
*
|
||||
* @param combo Raw `/api/combos` entry.
|
||||
* @param members Raw `/v1/models` entries for THIS combo's member ids.
|
||||
* Caller resolves `combo.models[].model` ids; unknown ids
|
||||
* are silently dropped before this call.
|
||||
* @param providerId OpenCode provider id (multi-instance aware).
|
||||
* @param baseURL Resolved gateway base URL for ModelV2.api.url.
|
||||
*/
|
||||
export function mapComboToModelV2(
|
||||
combo: OmniRouteRawCombo,
|
||||
members: OmniRouteRawModelEntry[],
|
||||
providerId: string,
|
||||
baseURL: string,
|
||||
apiFormat?: ApiFormatV2
|
||||
): ModelV2 {
|
||||
// `every` over an empty array returns true (would lie about an empty
|
||||
// combo's capabilities) — short-circuit to all-false when no members.
|
||||
const hasMembers = members.length > 0;
|
||||
|
||||
const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"]));
|
||||
const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"]));
|
||||
|
||||
const modalityAllHave = (sets: Array<Set<string>>, key: string): boolean =>
|
||||
hasMembers && sets.every((s) => s.has(key));
|
||||
|
||||
const contextValues = members
|
||||
.map((m) => m.context_length)
|
||||
.filter((v): v is number => typeof v === "number" && v > 0);
|
||||
const outputValues = members
|
||||
.map((m) => m.max_output_tokens)
|
||||
.filter((v): v is number => typeof v === "number" && v > 0);
|
||||
const inputValues = members
|
||||
.map((m) => m.max_input_tokens)
|
||||
.filter((v): v is number => typeof v === "number" && v > 0);
|
||||
|
||||
const everyDeclaresInput = hasMembers && inputValues.length === members.length;
|
||||
|
||||
const capabilities: ModelV2["capabilities"] = {
|
||||
temperature:
|
||||
hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
|
||||
reasoning:
|
||||
hasMembers &&
|
||||
members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)),
|
||||
attachment:
|
||||
hasMembers &&
|
||||
members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)),
|
||||
toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)),
|
||||
input: {
|
||||
text: modalityAllHave(memberInMods, "text"),
|
||||
audio: modalityAllHave(memberInMods, "audio"),
|
||||
image: modalityAllHave(memberInMods, "image"),
|
||||
video: modalityAllHave(memberInMods, "video"),
|
||||
pdf: modalityAllHave(memberInMods, "pdf"),
|
||||
},
|
||||
output: {
|
||||
text: modalityAllHave(memberOutMods, "text"),
|
||||
audio: modalityAllHave(memberOutMods, "audio"),
|
||||
image: modalityAllHave(memberOutMods, "image"),
|
||||
video: modalityAllHave(memberOutMods, "video"),
|
||||
pdf: modalityAllHave(memberOutMods, "pdf"),
|
||||
},
|
||||
interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)),
|
||||
};
|
||||
|
||||
// Combos span multiple providers. Use Anthropic format only when ALL
|
||||
// members resolve to Anthropic — otherwise fall back to OpenAI-compat
|
||||
// (lowest common denominator that every upstream understands).
|
||||
const comboApiBlock = (() => {
|
||||
if (!hasMembers) return resolveApiBlockV2(combo.id, baseURL, apiFormat);
|
||||
const allAnthropic = members.every(
|
||||
(m) => resolveApiBlockV2(m.id, baseURL, apiFormat).id === "anthropic"
|
||||
);
|
||||
return allAnthropic
|
||||
? resolveApiBlockV2(members[0].id, baseURL, apiFormat)
|
||||
: resolveApiBlockV2(combo.id, baseURL, apiFormat);
|
||||
})();
|
||||
|
||||
return {
|
||||
id: combo.id,
|
||||
providerID: providerId,
|
||||
api: comboApiBlock,
|
||||
name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id,
|
||||
capabilities,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context:
|
||||
typeof combo.computed_context_length === "number" && combo.computed_context_length > 0
|
||||
? combo.computed_context_length
|
||||
: contextValues.length > 0
|
||||
? Math.min(...contextValues)
|
||||
: 0,
|
||||
...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}),
|
||||
output: outputValues.length > 0 ? Math.min(...outputValues) : 0,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: combo.release_date ?? "",
|
||||
};
|
||||
}
|
||||
606
@omniroute/opencode-plugin-v2/src/shared/enrich.ts
Normal file
606
@omniroute/opencode-plugin-v2/src/shared/enrich.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import { buildModelDisplayName } from "./naming.js";
|
||||
import type { FreeModelFreeType } from "./naming.js";
|
||||
|
||||
export interface OmniRouteEnrichmentEntry {
|
||||
/** Human-readable display name. Replaces ModelV2.name when present. */
|
||||
name?: string;
|
||||
/** Per-million-token cost overlay onto ModelV2.cost. */
|
||||
pricing?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
};
|
||||
/**
|
||||
* Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini`).
|
||||
* Populated by `defaultOmniRouteEnrichmentFetcher` from
|
||||
* `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical
|
||||
* resolution.
|
||||
*/
|
||||
providerAlias?: string;
|
||||
/**
|
||||
* Canonical provider id used by `/api/providers` connections (e.g.
|
||||
* `claude`, `gemini`, `kiro`). Populated from the per-provider
|
||||
* `entry.id` field inside `/api/pricing/models`.
|
||||
*/
|
||||
providerCanonical?: string;
|
||||
/**
|
||||
* Human-readable upstream provider label (e.g. `Claude`, `Kiro`,
|
||||
* `Windsurf`, `GitHub Models`). Populated from the per-provider
|
||||
* `entry.name` field inside `/api/pricing/models`. Used by the
|
||||
* `providerTag` feature to suffix `ModelV2.name` with the routing
|
||||
* destination so the OC TUI picker can differentiate the same
|
||||
* model id sold through different upstream connections.
|
||||
*/
|
||||
providerDisplayName?: string;
|
||||
/** Free-model budget type (from freeModelCatalog). */
|
||||
freeType?: FreeModelFreeType;
|
||||
/** Monthly token budget for recurring free models. */
|
||||
monthlyTokens?: number;
|
||||
/** Credit token budget for credit-based free models. */
|
||||
creditTokens?: number;
|
||||
}
|
||||
|
||||
/** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */
|
||||
export type OmniRouteEnrichmentMap = Map<string, OmniRouteEnrichmentEntry>;
|
||||
|
||||
/**
|
||||
* Reverse-index the enrichment map from `providerCanonical → providerAlias`.
|
||||
*
|
||||
* OmniRoute's `/api/pricing/models` is keyed by short ALIAS (`cc`, `cx`,
|
||||
* `pol`). But `/v1/models` exposes some models a SECOND time under their
|
||||
* CANONICAL name (`claude/claude-opus-4-7`, `codex/gpt-5.5`,
|
||||
* `pollinations/midjourney`). Without a reverse map, those canonical
|
||||
* rows miss enrichment entirely and surface as raw ids in the picker.
|
||||
*
|
||||
* Built once per refresh from the enrichment entries themselves — no
|
||||
* hardcoded registry. Only records `canonical → alias` mappings when
|
||||
* both are present AND distinct (skips slots where alias === canonical
|
||||
* like `kiro`).
|
||||
*/
|
||||
export function buildCanonicalToAliasMap(
|
||||
enrichment: OmniRouteEnrichmentMap | undefined
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
if (!enrichment) return out;
|
||||
for (const entry of enrichment.values()) {
|
||||
const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
|
||||
const canonical =
|
||||
typeof entry.providerCanonical === "string" ? entry.providerCanonical.trim() : "";
|
||||
if (alias.length === 0 || canonical.length === 0) continue;
|
||||
if (alias === canonical) continue;
|
||||
if (!out.has(canonical)) out.set(canonical, alias);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichment lookup with alias-fallback chain.
|
||||
*
|
||||
* Resolution order (first hit wins):
|
||||
*
|
||||
* 1. `enrichment.get(rawId)` — direct hit on `<prefix>/<modelId>` or
|
||||
* bare id (the fetcher writes under both forms).
|
||||
* 2. If `rawId` is `<canonical>/<modelId>` and `canonicalToAlias` has
|
||||
* a mapping for `canonical`, try `<alias>/<modelId>`. This rescues
|
||||
* duplicate rows like `claude/claude-opus-4-7` (canonical) when
|
||||
* enrichment only indexed under `cc/claude-opus-4-7` (alias).
|
||||
* 3. Bare `<modelId>` as a last resort. Already covered by step 1 in
|
||||
* practice (fetcher writes bare keys), but kept defensive.
|
||||
*
|
||||
* Returns `undefined` when no lookup hits.
|
||||
*/
|
||||
export function lookupEnrichment(
|
||||
rawId: string,
|
||||
enrichment: OmniRouteEnrichmentMap | undefined,
|
||||
canonicalToAlias: Map<string, string>
|
||||
): OmniRouteEnrichmentEntry | undefined {
|
||||
if (!enrichment) return undefined;
|
||||
const direct = enrichment.get(rawId);
|
||||
if (direct) return direct;
|
||||
const slash = rawId.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const prefix = rawId.slice(0, slash);
|
||||
const modelId = rawId.slice(slash + 1);
|
||||
const alias = canonicalToAlias.get(prefix);
|
||||
if (alias && alias !== prefix) {
|
||||
const viaAlias = enrichment.get(`${alias}/${modelId}`);
|
||||
if (viaAlias) return viaAlias;
|
||||
}
|
||||
const bare = enrichment.get(modelId);
|
||||
if (bare) return bare;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-pass: detect raw rows that are the CANONICAL twin of an ALIAS row
|
||||
* already in the catalog. Returns the set of canonical-keyed ids to skip
|
||||
* during the raw-model loop so each model surfaces exactly once under
|
||||
* its enriched alias key.
|
||||
*
|
||||
* Example: `/v1/models` returns BOTH `cc/claude-opus-4-7` and
|
||||
* `claude/claude-opus-4-7`. The former is enriched (alias `cc` exists
|
||||
* in `/api/pricing/models`); the latter is raw. We keep `cc/...` and
|
||||
* drop `claude/...`.
|
||||
*
|
||||
* Built once per refresh. Cheap — O(M) where M = raw model count.
|
||||
*/
|
||||
export function canonicalDedupSet(
|
||||
rawModels: ReadonlyArray<{ id: string }>,
|
||||
canonicalToAlias: Map<string, string>
|
||||
): Set<string> {
|
||||
const drop = new Set<string>();
|
||||
if (canonicalToAlias.size === 0) return drop;
|
||||
// Index every alias key present in the raw catalog.
|
||||
const aliasKeys = new Set<string>();
|
||||
for (const m of rawModels) {
|
||||
if (typeof m.id === "string" && m.id.length > 0) aliasKeys.add(m.id);
|
||||
}
|
||||
for (const m of rawModels) {
|
||||
if (typeof m.id !== "string" || m.id.length === 0) continue;
|
||||
const slash = m.id.indexOf("/");
|
||||
if (slash <= 0) continue;
|
||||
const prefix = m.id.slice(0, slash);
|
||||
const modelId = m.id.slice(slash + 1);
|
||||
const alias = canonicalToAlias.get(prefix);
|
||||
if (!alias || alias === prefix) continue;
|
||||
// Canonical row only gets suppressed if the alias row actually
|
||||
// exists — otherwise we'd hide the model entirely.
|
||||
if (aliasKeys.has(`${alias}/${modelId}`)) drop.add(m.id);
|
||||
}
|
||||
return drop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-alias index of enrichment metadata so we can render the
|
||||
* provider prefix even for raw models that don't have their own
|
||||
* curated `/api/pricing/models` entry.
|
||||
*
|
||||
* Real example: OmniRoute's `pricing['cohere']` slot lists 10 curated
|
||||
* models but `/v1/models` also returns `cohere/rerank-multilingual-v3.0`
|
||||
* and `cohere/rerank-v4.0-fast` (not in the curated 10). Without this
|
||||
* index, those rows surface in the picker as `cohere/...` with no
|
||||
* `Cohere - ` prefix because the per-model enrichment lookup misses.
|
||||
*
|
||||
* This index records the first non-empty `providerDisplayName` seen
|
||||
* for each alias, plus the alias itself. Callers use it to synthesize
|
||||
* a minimal `OmniRouteEnrichmentEntry` whenever the direct lookup
|
||||
* misses but the raw id's prefix matches a known alias.
|
||||
*
|
||||
* Built once per refresh; first-wins on duplicate alias (matches
|
||||
* `buildCanonicalToAliasMap` semantics).
|
||||
*/
|
||||
export function buildAliasIndex(
|
||||
enrichment: OmniRouteEnrichmentMap | undefined
|
||||
): Map<string, OmniRouteEnrichmentEntry> {
|
||||
const out = new Map<string, OmniRouteEnrichmentEntry>();
|
||||
if (!enrichment) return out;
|
||||
for (const entry of enrichment.values()) {
|
||||
const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
|
||||
if (alias.length === 0) continue;
|
||||
if (out.has(alias)) {
|
||||
// First-wins, but upgrade to the first entry that carries a
|
||||
// non-empty providerDisplayName so the prefix renders nicely.
|
||||
const existing = out.get(alias);
|
||||
if (
|
||||
existing &&
|
||||
(!existing.providerDisplayName || existing.providerDisplayName.trim().length === 0) &&
|
||||
typeof entry.providerDisplayName === "string" &&
|
||||
entry.providerDisplayName.trim().length > 0
|
||||
) {
|
||||
out.set(alias, entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.set(alias, entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a synthesised enrichment entry for `applyProviderTag` /
|
||||
* `shortProviderLabel` consumption, combining two sources:
|
||||
*
|
||||
* 1. The direct per-model enrichment match (if present).
|
||||
* 2. A per-alias fallback derived from `buildAliasIndex` — covers raw
|
||||
* ids whose prefix matches a known alias but the specific model
|
||||
* id wasn't curated in `/api/pricing/models`. Example:
|
||||
* `cohere/rerank-multilingual-v3.0` falls back to the cohere slot's
|
||||
* `providerDisplayName='Cohere'` even though that specific id
|
||||
* isn't in the curated 10-model list.
|
||||
*
|
||||
* Returns `undefined` when neither source surfaces an alias.
|
||||
*
|
||||
* NOTE: this function is read-only over its inputs; it never mutates
|
||||
* the underlying `direct` entry. When it falls back to the alias
|
||||
* index, it constructs a fresh minimal entry exposing only the
|
||||
* provider-prefix fields (`providerAlias`, `providerCanonical`,
|
||||
* `providerDisplayName`). Other fields (name, pricing) are explicitly
|
||||
* left undefined so `applyEnrichment` won't accidentally overwrite a
|
||||
* model name with the alias-slot label.
|
||||
*/
|
||||
export function resolveProviderTagEntry(
|
||||
rawId: string,
|
||||
direct: OmniRouteEnrichmentEntry | undefined,
|
||||
aliasIndex: Map<string, OmniRouteEnrichmentEntry>,
|
||||
canonicalToAlias?: Map<string, string>
|
||||
): OmniRouteEnrichmentEntry | undefined {
|
||||
if (direct) {
|
||||
const alias = typeof direct.providerAlias === "string" ? direct.providerAlias.trim() : "";
|
||||
const display =
|
||||
typeof direct.providerDisplayName === "string" ? direct.providerDisplayName.trim() : "";
|
||||
if (alias.length > 0 || display.length > 0) return direct;
|
||||
}
|
||||
const slash = rawId.indexOf("/");
|
||||
if (slash <= 0) return direct;
|
||||
const prefix = rawId.slice(0, slash);
|
||||
// 1. Direct alias lookup (`cohere/...` → cohere slot keyed by alias=cohere).
|
||||
let fromAlias = aliasIndex.get(prefix);
|
||||
// 2. Canonical fallback (`pollinations/...` → look up via alias `pol`).
|
||||
if (!fromAlias && canonicalToAlias) {
|
||||
const alias = canonicalToAlias.get(prefix);
|
||||
if (alias) fromAlias = aliasIndex.get(alias);
|
||||
}
|
||||
if (!fromAlias) return direct;
|
||||
// Synthesize: borrow only the provider-prefix metadata.
|
||||
return {
|
||||
providerAlias: fromAlias.providerAlias,
|
||||
providerCanonical: fromAlias.providerCanonical,
|
||||
providerDisplayName: fromAlias.providerDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher contract: resolves the enrichment overlay (display names +
|
||||
* pricing + free-tier budgets) from a running OmniRoute instance.
|
||||
*/
|
||||
/**
|
||||
* Reports a source that could not be read. Enrichment stays best-effort, but
|
||||
* a caller that swallows this loses display names, provider tags, canonical
|
||||
* dedupe and pricing with no way to tell why.
|
||||
*/
|
||||
export type OmniRouteEnrichmentSourceError = (endpoint: string, reason: string) => void;
|
||||
|
||||
export type OmniRouteEnrichmentFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number,
|
||||
onSourceError?: OmniRouteEnrichmentSourceError
|
||||
) => Promise<OmniRouteEnrichmentMap>;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let i = value.length;
|
||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
|
||||
return i === value.length ? value : value.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default enrichment fetcher — pulls nice display names from
|
||||
* `GET /api/pricing/models` and merges per-million-token pricing from
|
||||
* `GET /api/pricing` (the actual pricing source — `/api/pricing/models` is
|
||||
* a catalog endpoint whose entries are `{id, name, custom}` only).
|
||||
*
|
||||
* `/api/pricing/models` shape (catalog):
|
||||
* - `{ [providerAlias]: { id, alias, name, models: [{ id, name, custom }] } }`
|
||||
*
|
||||
* `/api/pricing` shape (pricing only):
|
||||
* - `{ [providerAlias]: { [modelId]: { input, output, cached, reasoning, cache_creation } } }`
|
||||
* where values are USD per million tokens.
|
||||
*
|
||||
* The two responses are joined on `(providerAlias, modelId)` and the merged
|
||||
* entries are stored under both `${providerAlias}/${modelId}` and bare
|
||||
* `${modelId}` keys so downstream lookups against either form succeed.
|
||||
*
|
||||
* Soft-fails (returns whatever was collected) on non-2xx or parse errors;
|
||||
* the two fetches are independent so one missing source still surfaces the
|
||||
* other. A third best-effort fetch attaches free-tier budgets from
|
||||
* `/api/free-tier/summary`.
|
||||
*
|
||||
* Ported from the v1 plugin (`index.ts:1906-2106`); the shared logger is
|
||||
* the only intentional difference (no plugin-contract dependency here).
|
||||
*/
|
||||
export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000,
|
||||
onSourceError
|
||||
) => {
|
||||
const report = (endpoint: string, reason: unknown): void => {
|
||||
onSourceError?.(endpoint, reason instanceof Error ? reason.message : String(reason));
|
||||
};
|
||||
const out: OmniRouteEnrichmentMap = new Map();
|
||||
if (!baseURL || !apiKey) return out;
|
||||
const root = trimTrailingSlashes(baseURL.replace(/\/v1\/?$/, ""));
|
||||
const headers = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
// 1. Catalog with nice display names.
|
||||
const catalogAc = new AbortController();
|
||||
const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs);
|
||||
let catalogStatus = 0;
|
||||
try {
|
||||
const res = await fetch(`${root}/api/pricing/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: catalogAc.signal,
|
||||
});
|
||||
catalogStatus = res.status;
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as unknown;
|
||||
const providers =
|
||||
(body as { providers?: Record<string, { models?: unknown[] }> })?.providers ??
|
||||
(body as Record<string, { models?: unknown[] }>);
|
||||
if (providers && typeof providers === "object") {
|
||||
for (const [providerAlias, slot] of Object.entries(providers)) {
|
||||
if (!slot || typeof slot !== "object") continue;
|
||||
const models = (slot as { models?: unknown[] }).models;
|
||||
if (!Array.isArray(models)) continue;
|
||||
const canonicalRaw = (slot as { id?: unknown }).id;
|
||||
const providerCanonical =
|
||||
typeof canonicalRaw === "string" && canonicalRaw.length > 0
|
||||
? canonicalRaw
|
||||
: providerAlias;
|
||||
const slotNameRaw = (slot as { name?: unknown }).name;
|
||||
const providerDisplayName =
|
||||
typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0
|
||||
? slotNameRaw.trim()
|
||||
: undefined;
|
||||
for (const m of models) {
|
||||
if (!m || typeof m !== "object") continue;
|
||||
const id = (m as { id?: unknown }).id;
|
||||
if (typeof id !== "string" || id.length === 0) continue;
|
||||
const name = (m as { name?: unknown }).name;
|
||||
const entry: OmniRouteEnrichmentEntry = {
|
||||
providerAlias,
|
||||
providerCanonical,
|
||||
};
|
||||
if (providerDisplayName) entry.providerDisplayName = providerDisplayName;
|
||||
if (typeof name === "string" && name.trim().length > 0) entry.name = name;
|
||||
const namespaced = `${providerAlias}/${id}`;
|
||||
if (!out.has(namespaced)) out.set(namespaced, entry);
|
||||
// The bare id is a fallback for ids that arrive unnamespaced. It
|
||||
// gets its OWN copy: sharing the object would let a later write
|
||||
// for one provider — a price, typically — land on another
|
||||
// provider's entry that happens to sell the same model id.
|
||||
if (!out.has(id)) out.set(id, { ...entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Network error, timeout, abort: nothing collected from THIS source, but
|
||||
// the pricing fetch below may still succeed — let it try, then decide at
|
||||
// the end whether the whole overlay failed (see the throw below).
|
||||
report("/api/pricing/models", err);
|
||||
catalogStatus = -1;
|
||||
} finally {
|
||||
clearTimeout(catalogTimer);
|
||||
}
|
||||
if (
|
||||
catalogStatus !== 0 &&
|
||||
catalogStatus !== -1 &&
|
||||
(catalogStatus < 200 || catalogStatus >= 300)
|
||||
) {
|
||||
report("/api/pricing/models", `HTTP ${catalogStatus}`);
|
||||
}
|
||||
|
||||
// 2. Pricing values from /api/pricing.
|
||||
const priceAc = new AbortController();
|
||||
const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs);
|
||||
let priceStatus = 0;
|
||||
try {
|
||||
const res = await fetch(`${root}/api/pricing`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: priceAc.signal,
|
||||
});
|
||||
priceStatus = res.status;
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as unknown;
|
||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||
for (const [providerAlias, slot] of Object.entries(body as Record<string, unknown>)) {
|
||||
if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue;
|
||||
for (const [modelId, raw] of Object.entries(slot as Record<string, unknown>)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const p = raw as Record<string, unknown>;
|
||||
const parsed: NonNullable<OmniRouteEnrichmentEntry["pricing"]> = {};
|
||||
if (typeof p.input === "number") parsed.input = p.input;
|
||||
if (typeof p.output === "number") parsed.output = p.output;
|
||||
const cacheRead =
|
||||
typeof p.cached === "number"
|
||||
? p.cached
|
||||
: typeof p.cacheRead === "number"
|
||||
? p.cacheRead
|
||||
: undefined;
|
||||
if (typeof cacheRead === "number") parsed.cacheRead = cacheRead;
|
||||
const cacheWrite =
|
||||
typeof p.cache_creation === "number"
|
||||
? p.cache_creation
|
||||
: typeof p.cacheWrite === "number"
|
||||
? p.cacheWrite
|
||||
: undefined;
|
||||
if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite;
|
||||
if (Object.keys(parsed).length === 0) continue;
|
||||
const namespaced = `${providerAlias}/${modelId}`;
|
||||
const existingNs = out.get(namespaced);
|
||||
if (existingNs) {
|
||||
existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed };
|
||||
} else {
|
||||
out.set(namespaced, { pricing: parsed });
|
||||
}
|
||||
const existingBare = out.get(modelId);
|
||||
// Only the provider that owns the bare entry may price it.
|
||||
// Otherwise the second provider selling the same model id
|
||||
// overwrites the first one's price, and the picker shows a cost
|
||||
// that belongs to a different connection.
|
||||
const bareBelongsHere =
|
||||
existingBare === undefined || existingBare.providerAlias === undefined
|
||||
? true
|
||||
: existingBare.providerAlias === providerAlias;
|
||||
if (bareBelongsHere) {
|
||||
if (existingBare) {
|
||||
existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed };
|
||||
} else {
|
||||
out.set(modelId, { pricing: parsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Same as above: report, mark this source failed, let the remaining
|
||||
// sources try before deciding.
|
||||
report("/api/pricing", err);
|
||||
priceStatus = -1;
|
||||
} finally {
|
||||
clearTimeout(priceTimer);
|
||||
}
|
||||
if (priceStatus !== 0 && priceStatus !== -1 && (priceStatus < 200 || priceStatus >= 300)) {
|
||||
report("/api/pricing", `HTTP ${priceStatus}`);
|
||||
}
|
||||
|
||||
// 3. Free model budgets from /api/free-tier/summary (best-effort).
|
||||
const freeAc = new AbortController();
|
||||
const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs);
|
||||
let freeStatus = 0;
|
||||
try {
|
||||
const res = await fetch(`${root}/api/free-tier/summary`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: freeAc.signal,
|
||||
});
|
||||
freeStatus = res.status;
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as unknown;
|
||||
const perModel: unknown[] =
|
||||
body && typeof body === "object" && Array.isArray((body as { perModel?: unknown }).perModel)
|
||||
? ((body as { perModel: unknown[] }).perModel as unknown[])
|
||||
: Array.isArray(body)
|
||||
? (body as unknown[])
|
||||
: [];
|
||||
for (const fm of perModel) {
|
||||
if (!fm || typeof fm !== "object") continue;
|
||||
const fmObj = fm as Record<string, unknown>;
|
||||
const provider = typeof fmObj.provider === "string" ? fmObj.provider : "";
|
||||
const modelId = typeof fmObj.modelId === "string" ? fmObj.modelId : "";
|
||||
const freeType = typeof fmObj.freeType === "string" ? fmObj.freeType : "";
|
||||
if (!modelId || !freeType) continue;
|
||||
const monthlyTokens =
|
||||
typeof fmObj.monthlyTokens === "number" ? fmObj.monthlyTokens : undefined;
|
||||
const creditTokens =
|
||||
typeof fmObj.creditTokens === "number" ? fmObj.creditTokens : undefined;
|
||||
const displayName = typeof fmObj.displayName === "string" ? fmObj.displayName : "";
|
||||
const candidates = [
|
||||
`${provider}/${modelId}`,
|
||||
modelId,
|
||||
...(displayName ? [displayName] : []),
|
||||
];
|
||||
for (const key of candidates) {
|
||||
const entry = out.get(key);
|
||||
if (entry) {
|
||||
entry.freeType = freeType as FreeModelFreeType;
|
||||
if (monthlyTokens !== undefined) entry.monthlyTokens = monthlyTokens;
|
||||
if (creditTokens !== undefined) entry.creditTokens = creditTokens;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
report("/api/free-tier/summary", err);
|
||||
// Soft-fail; free metadata is optional.
|
||||
} finally {
|
||||
clearTimeout(freeTimer);
|
||||
}
|
||||
if (freeStatus !== 0 && (freeStatus < 200 || freeStatus >= 300)) {
|
||||
report("/api/free-tier/summary", `HTTP ${freeStatus}`);
|
||||
}
|
||||
|
||||
// A source that failed contributes nothing — but the overlay keeps its own
|
||||
// memory per source: names collected while the catalog endpoint answered
|
||||
// survive a later pricing outage, and prices collected while pricing
|
||||
// answered survive a later catalog outage. Without this a single flapping
|
||||
// source wipes the other source's good data on every refresh. So a failed
|
||||
// catalog source throws (the caller keeps last-known) UNLESS the pricing
|
||||
// source brought something on THIS call — then whatever was collected,
|
||||
// names or prices, is the gateway's answer and ships as-is. (Status alone
|
||||
// cannot decide: a 2xx pricing answer with zero priced models is still an
|
||||
// answer, but it carries nothing to save the overlay with.)
|
||||
const sourceFailed = (status: number): boolean =>
|
||||
status === -1 || (status !== 0 && (status < 200 || status >= 300));
|
||||
const catalogFailed = sourceFailed(catalogStatus);
|
||||
const pricingBroughtSomething = !sourceFailed(priceStatus) && out.size > 0;
|
||||
if (catalogFailed && !pricingBroughtSomething) {
|
||||
throw new Error(
|
||||
`enrichment catalog source failed (pricing/models: ${catalogStatus}, pricing: ${priceStatus})`
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply enrichment overlay onto a ModelV2 entry. Mutates and returns the
|
||||
* passed entry for convenience.
|
||||
*/
|
||||
/** What the caller knows about the entry that the overlay itself cannot tell. */
|
||||
export interface EnrichmentDisplayContext {
|
||||
/** Combos never carry a provider tag: they route across providers. */
|
||||
isCombo?: boolean;
|
||||
isAutoCombo?: boolean;
|
||||
/** Set false to publish the bare display name, without the provider tag. */
|
||||
providerTag?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the overlay into a mapped model: display name, provider tag, free-tier
|
||||
* marker and budget, and pricing.
|
||||
*
|
||||
* The name is built rather than copied, because the gateway ships the parts
|
||||
* separately — the pricing catalog gives a display name and an upstream
|
||||
* provider label, the free-tier summary gives the budget. A picker showing
|
||||
* `Claude - [Free] Sonnet 4.6 · 1M/mo` tells the user which connection serves
|
||||
* the model and what it costs them; `claude-sonnet-4-6` tells them nothing.
|
||||
*/
|
||||
export function applyEnrichment(
|
||||
model: ModelV2,
|
||||
enrichment: OmniRouteEnrichmentEntry | undefined,
|
||||
context: EnrichmentDisplayContext = {}
|
||||
): ModelV2 {
|
||||
if (!enrichment) return model;
|
||||
const built = buildModelDisplayName({
|
||||
rawId: model.name && model.name.length > 0 ? model.name : model.id,
|
||||
enrichmentName: enrichment.name,
|
||||
providerAlias: context.providerTag === false ? undefined : enrichment.providerAlias,
|
||||
providerDisplayName: context.providerTag === false ? undefined : enrichment.providerDisplayName,
|
||||
isFree: enrichment.freeType !== undefined,
|
||||
freeType: enrichment.freeType,
|
||||
monthlyTokens: enrichment.monthlyTokens,
|
||||
creditTokens: enrichment.creditTokens,
|
||||
isCombo: context.isCombo,
|
||||
isAutoCombo: context.isAutoCombo,
|
||||
});
|
||||
if (built.trim().length > 0) {
|
||||
model.name = built;
|
||||
}
|
||||
if (enrichment.pricing) {
|
||||
if (typeof enrichment.pricing.input === "number") {
|
||||
model.cost.input = enrichment.pricing.input;
|
||||
}
|
||||
if (typeof enrichment.pricing.output === "number") {
|
||||
model.cost.output = enrichment.pricing.output;
|
||||
}
|
||||
if (typeof enrichment.pricing.cacheRead === "number") {
|
||||
model.cost.cache.read = enrichment.pricing.cacheRead;
|
||||
}
|
||||
if (typeof enrichment.pricing.cacheWrite === "number") {
|
||||
model.cost.cache.write = enrichment.pricing.cacheWrite;
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}
|
||||
127
@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts
Normal file
127
@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Fingerprint the CONTENT of a catalog snapshot (not endpoint/credential
|
||||
* identity) so lazy refresh can reload-after-publish only when something
|
||||
* actually changed.
|
||||
*
|
||||
* sha256 over sorted `id + "|" + (release_date ?? "")` lines for models
|
||||
* plus sorted combo ids, joined with `\n`. Order-insensitive: two
|
||||
* snapshots with the same entries in different order hash identically.
|
||||
*/
|
||||
export function catalogContentFingerprint(
|
||||
models: { id: string; release_date?: string }[],
|
||||
combos: { id: string }[],
|
||||
autoCombos: { id: string }[] = []
|
||||
): string {
|
||||
const modelLines = models
|
||||
.map((m) => `${m.id}|${m.release_date ?? ""}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const comboLines = combos
|
||||
.map((c) => c.id)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const autoLines = autoCombos
|
||||
.map((c) => c.id)
|
||||
.sort()
|
||||
.join("\n");
|
||||
return createHash("sha256").update(`${modelLines}\n${comboLines}\n${autoLines}`).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Digest of the optional tier (auto-combos, provider connections, enrichment).
|
||||
* The catalog fingerprint covers model and combo ids only, so an overlay that
|
||||
* moves — a renamed model, a provider going unusable — leaves it unchanged.
|
||||
* Reloading on every refresh instead would ask the host to rebuild its catalog
|
||||
* once per TTL window for nothing.
|
||||
*/
|
||||
export function optionalTierFingerprint(
|
||||
autoCombos: { id: string }[],
|
||||
providers: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
testStatus?: string;
|
||||
isActive?: boolean;
|
||||
providerDisplayName?: string;
|
||||
}[],
|
||||
enrichment:
|
||||
| Map<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
freeType?: string;
|
||||
providerDisplayName?: string;
|
||||
monthlyTokens?: number;
|
||||
creditTokens?: number;
|
||||
pricing?: Record<string, number | undefined>;
|
||||
}
|
||||
>
|
||||
| undefined,
|
||||
combos: { id: string; name?: string; models?: unknown[] }[] = []
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
// Membership matters: a combo keeping its id while losing a member is a
|
||||
// different combo to anyone picking it.
|
||||
parts.push(
|
||||
combos
|
||||
.map((c) => c.id + "|" + (c.name ?? "") + "|" + String(c.models?.length ?? 0))
|
||||
.sort()
|
||||
.join(",")
|
||||
);
|
||||
parts.push(
|
||||
autoCombos
|
||||
.map((c) => c.id)
|
||||
.sort()
|
||||
.join(",")
|
||||
);
|
||||
// A provider going quiet or getting renamed is as visible to the user as a
|
||||
// price move: its activity flag and display name belong in the digest.
|
||||
parts.push(
|
||||
providers
|
||||
.map(
|
||||
(p) =>
|
||||
(p.id ?? p.name ?? "") +
|
||||
":" +
|
||||
(p.testStatus ?? "") +
|
||||
":" +
|
||||
String(p.isActive ?? "") +
|
||||
":" +
|
||||
(p.providerDisplayName ?? "")
|
||||
)
|
||||
.sort()
|
||||
.join(",")
|
||||
);
|
||||
if (enrichment !== undefined) {
|
||||
const rows: string[] = [];
|
||||
for (const [key, entry] of enrichment) {
|
||||
// Pricing is part of what the user sees, so a price move must reach
|
||||
// the picker without waiting for an id to change.
|
||||
const price = entry.pricing
|
||||
? Object.entries(entry.pricing)
|
||||
.map(([k, v]) => k + "=" + String(v ?? ""))
|
||||
.sort()
|
||||
.join(";")
|
||||
: "";
|
||||
rows.push(
|
||||
key +
|
||||
"|" +
|
||||
(entry.name ?? "") +
|
||||
"|" +
|
||||
(entry.freeType ?? "") +
|
||||
"|" +
|
||||
(entry.providerDisplayName ?? "") +
|
||||
"|" +
|
||||
String(entry.monthlyTokens ?? "") +
|
||||
";" +
|
||||
String(entry.creditTokens ?? "") +
|
||||
"|" +
|
||||
price
|
||||
);
|
||||
}
|
||||
rows.sort();
|
||||
parts.push(String(enrichment.size));
|
||||
parts.push(rows.join("\n"));
|
||||
}
|
||||
return createHash("sha256").update(parts.join(" ")).digest("hex");
|
||||
}
|
||||
166
@omniroute/opencode-plugin-v2/src/shared/gemini.ts
Normal file
166
@omniroute/opencode-plugin-v2/src/shared/gemini.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Gemini rejects several standard JSON-Schema keywords in tool declarations
|
||||
* and answers `400 INVALID_ARGUMENT` for the whole request when it meets one.
|
||||
* The keywords carry no meaning Gemini would honour anyway, so stripping them
|
||||
* costs nothing and is what keeps a tool-calling chain alive.
|
||||
*/
|
||||
/**
|
||||
* Keywords Gemini rejects outright. `$ref` is deliberately NOT here: it
|
||||
* cannot be stripped without turning the schema into "accept anything", so
|
||||
* tools carrying one are forwarded untouched (see below). `ref` is not a
|
||||
* JSON Schema keyword at all, and stripping it by name destroys a legitimate
|
||||
* tool parameter called `ref` — a walker that cannot tell a keyword from a
|
||||
* property name mangles the schema it was meant to repair.
|
||||
*/
|
||||
const REJECTED_KEYWORDS = new Set(["$schema", "additionalProperties"]);
|
||||
|
||||
/** Keys whose value is itself a schema. */
|
||||
const SCHEMA_VALUE_KEYS = [
|
||||
"items",
|
||||
"additionalItems",
|
||||
"contains",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"propertyNames",
|
||||
"contentSchema",
|
||||
"unevaluatedItems",
|
||||
"unevaluatedProperties",
|
||||
];
|
||||
/** Keys whose value maps arbitrary NAMES to schemas — never keyword space. */
|
||||
const SCHEMA_MAP_KEYS = [
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
];
|
||||
/** Keys whose value is a list of schemas. */
|
||||
const SCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** True when any schema in the tree carries a `$ref` we cannot resolve. */
|
||||
function hasUnresolvableRef(node: unknown): boolean {
|
||||
if (Array.isArray(node)) return node.some(hasUnresolvableRef);
|
||||
if (!isRecord(node)) return false;
|
||||
if ("$ref" in node) return true;
|
||||
for (const key of SCHEMA_VALUE_KEYS) if (hasUnresolvableRef(node[key])) return true;
|
||||
// (arrays are handled by the Array branch at the top of this function)
|
||||
for (const key of SCHEMA_LIST_KEYS) if (hasUnresolvableRef(node[key])) return true;
|
||||
for (const key of SCHEMA_MAP_KEYS) {
|
||||
const map = node[key];
|
||||
if (isRecord(map) && Object.values(map).some(hasUnresolvableRef)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the rejected keywords in place, walking only the positions where a
|
||||
* schema can appear. Property names are never treated as keywords, so a tool
|
||||
* whose parameter happens to be called `additionalProperties` keeps it.
|
||||
* Returns whether anything was removed.
|
||||
*/
|
||||
function stripAtSchemaPositions(node: Record<string, unknown>): boolean {
|
||||
let changed = false;
|
||||
for (const keyword of REJECTED_KEYWORDS) {
|
||||
if (keyword in node) {
|
||||
delete node[keyword];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const key of SCHEMA_VALUE_KEYS) {
|
||||
const child = node[key];
|
||||
if (isRecord(child)) {
|
||||
changed = stripAtSchemaPositions(child) || changed;
|
||||
continue;
|
||||
}
|
||||
// `items` also takes the tuple form: an array of schemas, one per position.
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (isRecord(item)) changed = stripAtSchemaPositions(item) || changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of SCHEMA_LIST_KEYS) {
|
||||
const list = node[key];
|
||||
if (Array.isArray(list)) {
|
||||
for (const child of list) {
|
||||
if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of SCHEMA_MAP_KEYS) {
|
||||
const map = node[key];
|
||||
if (!isRecord(map)) continue;
|
||||
for (const child of Object.values(map)) {
|
||||
if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Families Google actually ships, anchored on the last path segment. A plain
|
||||
* substring test also claims `gemini-compatible-proxy` and `my-gemini-wrapper`
|
||||
* — and since the sanitiser removes keywords, a false positive is not free.
|
||||
*/
|
||||
const GEMINI_MODEL_ID =
|
||||
/^gemini(?:[-_.](?:\d|pro|flash|ultra|nano|exp|thinking|embedding|live|imagen)|$)/i;
|
||||
|
||||
/**
|
||||
* True for the routing forms a Gemini model reaches a gateway under — bare
|
||||
* (`gemini-2.5-flash`), canonical (`models/gemini-1.5-pro`) and prefixed
|
||||
* (`google-vertex/gemini-2.0`).
|
||||
*/
|
||||
export function isGeminiModelId(modelId: unknown): boolean {
|
||||
if (typeof modelId !== "string") return false;
|
||||
const segment = modelId.split("/").pop() ?? "";
|
||||
return GEMINI_MODEL_ID.test(segment);
|
||||
}
|
||||
|
||||
/** The subset of an AI SDK tool declaration this module reads. */
|
||||
export interface ToolWithInputSchema {
|
||||
readonly type?: string;
|
||||
readonly inputSchema?: unknown;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of `tools` whose input schemas are free of the keywords Gemini
|
||||
* rejects, or `undefined` when there was nothing to strip — which lets the
|
||||
* caller forward the original array and skip the clone entirely.
|
||||
*
|
||||
* Tools this module cannot read (provider-defined tools, entries without an
|
||||
* object schema) are carried through unchanged rather than dropped: a tool the
|
||||
* sanitiser does not understand is still a tool the model needs.
|
||||
*/
|
||||
export function sanitizeToolInputSchemas<T extends ToolWithInputSchema>(
|
||||
tools: readonly T[] | undefined
|
||||
): T[] | undefined {
|
||||
if (tools === undefined || tools.length === 0) return undefined;
|
||||
let changed = false;
|
||||
const out = tools.map((tool) => {
|
||||
if (!isRecord(tool.inputSchema)) return tool;
|
||||
// A schema carrying something uncloneable is not worth failing a request
|
||||
// over: forward the tool untouched and let the model answer.
|
||||
// A `$ref` cannot be stripped without turning the schema into "anything
|
||||
// goes", and cannot be resolved here. Forward the tool untouched and let
|
||||
// the gateway answer rather than silently widen what the model may send.
|
||||
if (hasUnresolvableRef(tool.inputSchema)) return tool;
|
||||
let schema: Record<string, unknown>;
|
||||
try {
|
||||
schema = structuredClone(tool.inputSchema) as Record<string, unknown>;
|
||||
} catch {
|
||||
return tool;
|
||||
}
|
||||
if (!stripAtSchemaPositions(schema)) return tool;
|
||||
changed = true;
|
||||
return { ...tool, inputSchema: schema };
|
||||
});
|
||||
return changed ? out : undefined;
|
||||
}
|
||||
9
@omniroute/opencode-plugin-v2/src/shared/index.ts
Normal file
9
@omniroute/opencode-plugin-v2/src/shared/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./models-map.js";
|
||||
export * from "./combos-map.js";
|
||||
export * from "./auto-combos.js";
|
||||
export * from "./naming.js";
|
||||
export * from "./enrich.js";
|
||||
export * from "./fingerprint.js";
|
||||
export * from "./logger.js";
|
||||
export * from "./usable.js";
|
||||
export * from "./gemini.js";
|
||||
81
@omniroute/opencode-plugin-v2/src/shared/logger.ts
Normal file
81
@omniroute/opencode-plugin-v2/src/shared/logger.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Namespaced leveled logger shared by the OmniRoute OpenCode packages.
|
||||
*
|
||||
* Levels: error < warn < info < debug. Default: warn.
|
||||
* Ported from the v1 plugin (`logger.ts`) so both new packages share one
|
||||
* sink instead of raw `console.warn` / `console.log` calls.
|
||||
*/
|
||||
|
||||
export type LogLevel = "error" | "warn" | "info" | "debug";
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
|
||||
const TAG = "[omniroute-plugin]";
|
||||
|
||||
function shouldLog(current: LogLevel, target: LogLevel): boolean {
|
||||
return LEVEL_ORDER[current] >= LEVEL_ORDER[target];
|
||||
}
|
||||
|
||||
let _level: LogLevel = "warn";
|
||||
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
_level = level;
|
||||
}
|
||||
|
||||
export function getLogLevel(): LogLevel {
|
||||
return _level;
|
||||
}
|
||||
|
||||
function fmt(level: LogLevel, msg: string, tag?: string): string {
|
||||
const prefix = tag ? `${TAG}${tag}` : TAG;
|
||||
return `${prefix} [${level.toUpperCase()}] ${msg}`;
|
||||
}
|
||||
|
||||
function buildLogger(getLevel: () => LogLevel) {
|
||||
return {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof buildLogger>;
|
||||
|
||||
/** Create an instance-scoped logger whose level cannot be changed by other instances. */
|
||||
export function createLogger(level: LogLevel): Logger {
|
||||
return buildLogger(() => level);
|
||||
}
|
||||
|
||||
/** Backward-compatible module-global logger controlled by setLogLevel(). */
|
||||
export const logger: Logger = buildLogger(() => _level);
|
||||
323
@omniroute/opencode-plugin-v2/src/shared/models-map.ts
Normal file
323
@omniroute/opencode-plugin-v2/src/shared/models-map.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import { normaliseFreeLabel } from "./naming.js";
|
||||
|
||||
export interface OmniRouteRawModelEntry {
|
||||
id: string;
|
||||
object?: string;
|
||||
owned_by?: string;
|
||||
root?: string | null;
|
||||
parent?: string | null;
|
||||
context_length?: number;
|
||||
max_input_tokens?: number;
|
||||
max_output_tokens?: number;
|
||||
input_modalities?: string[];
|
||||
output_modalities?: string[];
|
||||
capabilities?: {
|
||||
tool_calling?: boolean;
|
||||
reasoning?: boolean;
|
||||
vision?: boolean;
|
||||
thinking?: boolean;
|
||||
attachment?: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature?: boolean;
|
||||
/** Runtime-learned or synced reasoning tiers (server-gated, blind-mapped). */
|
||||
effort_tiers?: string[];
|
||||
};
|
||||
release_date?: string;
|
||||
last_updated?: string;
|
||||
api_format?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher contract: returns the raw `/v1/models` entry list from a running
|
||||
* OmniRoute instance. Surfaced as a dependency so unit tests can inject a
|
||||
* stub without monkey-patching global `fetch`.
|
||||
*
|
||||
* Why we inline this instead of using `@omniroute/opencode-provider`'s
|
||||
* `fetchLiveModels`: the sibling helper returns a stripped `{id, name,
|
||||
* contextLength?}` shape that drops the `capabilities` / `*_modalities` /
|
||||
* `max_*_tokens` blocks the mapping needs for ModelV2 pass-through.
|
||||
*/
|
||||
export type OmniRouteModelsFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number
|
||||
) => Promise<OmniRouteRawModelEntry[]>;
|
||||
|
||||
/**
|
||||
* Default fetcher: `GET <baseURL>/v1/models` with bearer auth + AbortController
|
||||
* timeout. Accepts both the `{object:"list", data:[…]}` envelope OmniRoute
|
||||
* emits today and a bare-array envelope (defensive — keeps the plugin
|
||||
* working if a future OmniRoute build trims the wrapper). Anything that
|
||||
* isn't an object with a string `id` is filtered out silently.
|
||||
*/
|
||||
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000
|
||||
) => {
|
||||
if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /v1/models");
|
||||
if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /v1/models");
|
||||
|
||||
const trimmed = trimTrailingSlashes(baseURL);
|
||||
// Tolerate both `https://host` and `https://host/v1` forms — the gateway
|
||||
// exposes /v1/models either way; we just don't want a double `/v1/v1`.
|
||||
const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const rawList: unknown[] = Array.isArray(body)
|
||||
? body
|
||||
: body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data)
|
||||
? ((body as { data: unknown[] }).data as unknown[])
|
||||
: [];
|
||||
const out: OmniRouteRawModelEntry[] = [];
|
||||
for (const r of rawList) {
|
||||
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
|
||||
out.push(r as OmniRouteRawModelEntry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
// Manual trim helpers avoid polynomial-regex CodeQL warnings on
|
||||
// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same
|
||||
// behaviour, no backtracking.
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let i = value.length;
|
||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
|
||||
return i === value.length ? value : value.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
|
||||
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
|
||||
* (it appends `/v1/messages` automatically), so callers should branch on
|
||||
* format first.
|
||||
*/
|
||||
export function ensureV1Suffix(url: string): string {
|
||||
const trimmed = trimTrailingSlashes(url);
|
||||
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
||||
}
|
||||
|
||||
export interface ApiFormatV2 {
|
||||
allowAnthropic?: boolean;
|
||||
anthropicModels?: string[];
|
||||
/**
|
||||
* Deprecated v1 prefix list (default v1:
|
||||
* `cc,claude,anthropic,kiro,kr`). Accepted for backward compatibility:
|
||||
* prefix OR allowlist routes to anthropic, with a one-time deprecation
|
||||
* warning pointing at `anthropicModels`. Prefer full IDs.
|
||||
*/
|
||||
anthropicPrefixes?: string[];
|
||||
}
|
||||
|
||||
/** Default v1 prefix list, kept so copied v1 configs keep routing. */
|
||||
export const DEFAULT_ANTHROPIC_PREFIXES_V1 = ["cc", "claude", "anthropic", "kiro", "kr"];
|
||||
|
||||
const warnedPrefixLists = new Set<string>();
|
||||
|
||||
function warnDeprecatedPrefixesOnce(prefixes: string[]): void {
|
||||
const key = [...prefixes].sort().join(",");
|
||||
if (warnedPrefixLists.has(key)) return;
|
||||
warnedPrefixLists.add(key);
|
||||
console.warn(
|
||||
"[omniroute-plugin] [WARN] apiFormat.anthropicPrefixes is deprecated; convert to anthropicModels (full IDs)"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Anthropic SDK block appends `/v1/messages` itself, so it needs the
|
||||
* gateway root. A config carrying the `/v1` the OpenAI-compatible block wants
|
||||
* would otherwise produce `/v1/v1/messages`.
|
||||
*/
|
||||
function stripV1Suffix(baseURL: string): string {
|
||||
return baseURL.replace(/\/v1\/?$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the API block (id + url + npm package) for a given model id.
|
||||
*
|
||||
* v2 rule: a model routes to the Anthropic SDK block when
|
||||
* `apiFormat.allowAnthropic === true` AND (its FULL id is allowlisted in
|
||||
* `apiFormat.anthropicModels` OR its prefix is listed in the deprecated
|
||||
* `apiFormat.anthropicPrefixes`, defaulting to the v1 list when prefixes
|
||||
* are absent). The deprecated path warns once per prefix list. With
|
||||
* neither allowlist nor prefix match, the model stays openai-compatible.
|
||||
*/
|
||||
export function resolveApiBlockV2(
|
||||
modelId: string,
|
||||
baseURL: string,
|
||||
apiFormat?: ApiFormatV2
|
||||
): { id: string; url: string; npm: string } {
|
||||
if (apiFormat?.allowAnthropic === true) {
|
||||
if ((apiFormat.anthropicModels ?? []).includes(modelId)) {
|
||||
return {
|
||||
id: "anthropic",
|
||||
url: stripV1Suffix(trimTrailingSlashes(baseURL)),
|
||||
npm: "@ai-sdk/anthropic",
|
||||
};
|
||||
}
|
||||
const prefixes = apiFormat.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES_V1;
|
||||
if (apiFormat.anthropicPrefixes !== undefined) warnDeprecatedPrefixesOnce(prefixes);
|
||||
const slash = modelId.indexOf("/");
|
||||
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
|
||||
if (prefixes.includes(prefix)) {
|
||||
return {
|
||||
id: "anthropic",
|
||||
url: stripV1Suffix(trimTrailingSlashes(baseURL)),
|
||||
npm: "@ai-sdk/anthropic",
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: "openai-compatible",
|
||||
url: ensureV1Suffix(baseURL),
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw `/v1/models` entry → `ModelV2` (the type @opencode-ai/sdk/v2
|
||||
* exports as `Model`, re-exported by @opencode-ai/plugin as `ModelV2`).
|
||||
*
|
||||
* ModelV2 requires a much richer shape than a flat record. Concretely it
|
||||
* expects:
|
||||
* - flat `id`, `name`, `providerID`, `api: {id,url,npm}`
|
||||
* - nested `capabilities: { temperature, reasoning, attachment, toolcall,
|
||||
* input:{text,audio,image,video,pdf}, output:{…}, interleaved }`
|
||||
* - `cost: { input, output, cache:{read,write} }` (NOT optional)
|
||||
* - `limit: { context, input?, output }`
|
||||
* - `status: "alpha"|"beta"|"deprecated"|"active"`, `options:{}`, `headers:{}`
|
||||
* - `release_date: string`
|
||||
*
|
||||
* Field adaptations:
|
||||
* 1. Flat `tool_call` / `reasoning` / `attachment` / `modalities`
|
||||
* top-level fields don't exist in ModelV2 — folded into
|
||||
* `capabilities.{toolcall, reasoning, attachment, input.*, output.*}`.
|
||||
* 2. `cost: undefined` is illegal (cost is required). OmniRoute doesn't
|
||||
* surface pricing on /v1/models, so we emit a zeroed cost block.
|
||||
* Downstream opencode reads this for display only — the live pricing
|
||||
* is OmniRoute's responsibility at routing time.
|
||||
* 3. `tool_call` → `toolcall` (ModelV2 field name; one word).
|
||||
* 4. `attachment` maps from `capabilities.vision` per OmniRoute
|
||||
* convention: vision = ability to receive image attachments. If the
|
||||
* raw entry happens to expose an explicit `capabilities.attachment`,
|
||||
* that wins.
|
||||
* 5. `thinking` from OmniRoute has no 1:1 ModelV2 slot. We OR it into
|
||||
* `reasoning` so thinking-only models still surface a non-false
|
||||
* reasoning flag.
|
||||
* 6. `last_updated` from OmniRoute has no ModelV2 slot — dropped.
|
||||
* `release_date` lands in ModelV2.release_date with `""` fallback
|
||||
* (the field is required as `string`).
|
||||
* 7. `temperature: true` per OmniRoute convention (OpenAI-compat mode
|
||||
* always supports the temperature knob). If a raw entry sets
|
||||
* `capabilities.temperature` explicitly, that wins.
|
||||
* 8. Input/output modality arrays: each known modality flips its boolean.
|
||||
* Unknown strings (future OmniRoute additions) are ignored — when the
|
||||
* server adds new modalities we can map them here without breaking
|
||||
* existing entries.
|
||||
* 9. `status: "active"` — OmniRoute doesn't tier models alpha/beta on
|
||||
* /v1/models, and opencode needs a non-deprecated status to expose
|
||||
* the model in the picker. If a future entry surfaces an explicit
|
||||
* lifecycle hint we can map it then.
|
||||
* 10. `options: {}` and `headers: {}` left empty — they're escape hatches
|
||||
* for opencode users to attach per-model overrides; the provider
|
||||
* plugin must not preempt them.
|
||||
* 11. `limit.input` is OPTIONAL on ModelV2 (the `?` modifier). We only
|
||||
* emit it when OmniRoute supplies `max_input_tokens` — keeps the
|
||||
* shape clean for combo entries that only carry context_length.
|
||||
*/
|
||||
export function mapRawModelToModelV2(
|
||||
raw: OmniRouteRawModelEntry,
|
||||
ctx: { providerId: string; baseURL: string; apiFormat?: ApiFormatV2 }
|
||||
): ModelV2 {
|
||||
const caps = raw.capabilities ?? {};
|
||||
// effort_tiers loop: server-declared tiers become ModelV2 variants so the
|
||||
// UI offers exactly the tiers OmniRoute vouches for (instead of opencode's
|
||||
// invented [low, medium, high] fallback). Blind: filtering/exclusion rules
|
||||
// live server-side. Absent/empty/malformed => key omitted ENTIRELY (an
|
||||
// empty variants object would suppress opencode's fallback for this model).
|
||||
const declaredTiers = Array.isArray(caps.effort_tiers)
|
||||
? caps.effort_tiers.filter((t): t is string => typeof t === "string" && t.length > 0)
|
||||
: [];
|
||||
const variants =
|
||||
declaredTiers.length > 0
|
||||
? Object.fromEntries(declaredTiers.map((tier) => [tier, { reasoningEffort: tier }]))
|
||||
: undefined;
|
||||
const inMods = new Set(raw.input_modalities ?? ["text"]);
|
||||
const outMods = new Set(raw.output_modalities ?? ["text"]);
|
||||
|
||||
return {
|
||||
// OC's static-catalog reader parses the key on `/` to recover
|
||||
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
||||
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
|
||||
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
|
||||
// it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids
|
||||
// (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed:
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
|
||||
// the plugin provider. Other bare ids still prefix with
|
||||
// `providerId` so credentials resolve as `(omniroute, model)`.
|
||||
id: raw.id.includes("/") || raw.owned_by === "combo" ? raw.id : `${ctx.providerId}/${raw.id}`,
|
||||
/**
|
||||
* Display name. Falls back to raw.id when no enrichment is available;
|
||||
* the caller overlays `/api/pricing/models` data via enrichment when
|
||||
* the enrichment feature is enabled.
|
||||
*/
|
||||
name: normaliseFreeLabel(raw.id),
|
||||
capabilities: {
|
||||
temperature: caps.temperature ?? true,
|
||||
reasoning: Boolean(caps.reasoning || caps.thinking),
|
||||
attachment: Boolean(caps.attachment ?? caps.vision ?? false),
|
||||
toolcall: Boolean(caps.tool_calling ?? false),
|
||||
input: {
|
||||
text: inMods.has("text"),
|
||||
audio: inMods.has("audio"),
|
||||
image: inMods.has("image"),
|
||||
video: inMods.has("video"),
|
||||
pdf: inMods.has("pdf"),
|
||||
},
|
||||
output: {
|
||||
text: outMods.has("text"),
|
||||
audio: outMods.has("audio"),
|
||||
image: outMods.has("image"),
|
||||
video: outMods.has("video"),
|
||||
pdf: outMods.has("pdf"),
|
||||
},
|
||||
interleaved: Boolean(caps.thinking),
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context: typeof raw.context_length === "number" ? raw.context_length : 0,
|
||||
...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}),
|
||||
output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0,
|
||||
},
|
||||
...(variants ? { variants } : {}),
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: raw.release_date ?? "",
|
||||
providerID: ctx.providerId,
|
||||
api: resolveApiBlockV2(raw.id, ctx.baseURL, ctx.apiFormat),
|
||||
};
|
||||
}
|
||||
295
@omniroute/opencode-plugin-v2/src/shared/naming.ts
Normal file
295
@omniroute/opencode-plugin-v2/src/shared/naming.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Universal model naming template for the OmniRoute plugin.
|
||||
*
|
||||
* Naming pipeline:
|
||||
* [tag] <provider-label><separator><display-name><suffix>
|
||||
*
|
||||
* [Free] <provider> - <name> · <budget> ← free model
|
||||
* Auto: <variant> (<N>p) ← auto combo
|
||||
* Combo: <name> ← DB combo
|
||||
* <provider> - <name> ← regular model
|
||||
*/
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Separator between provider label and model display name. */
|
||||
export const PROVIDER_TAG_SEPARATOR = " - ";
|
||||
|
||||
/** Threshold beyond which providerDisplayName is abbreviated. */
|
||||
const PROVIDER_LABEL_MAX_CHARS = 12;
|
||||
|
||||
/** Aliases longer than this get title-case instead of UPPER. */
|
||||
const ALIAS_UPPER_MAX_CHARS = 5;
|
||||
|
||||
// ── Auto Combo Types ─────────────────────────────────────────────────────
|
||||
|
||||
export type AutoVariant = "coding" | "fast" | "cheap" | "offline" | "smart" | "lkgp";
|
||||
|
||||
export const AUTO_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"];
|
||||
|
||||
export const AUTO_VARIANT_DESCRIPTIONS: Record<AutoVariant | "default", string> = {
|
||||
default: "Best provider via scoring",
|
||||
coding: "Quality-first for code tasks",
|
||||
fast: "Latency-optimized routing",
|
||||
cheap: "Cost-optimized routing",
|
||||
offline: "Offline-friendly providers",
|
||||
smart: "Quality-first with exploration",
|
||||
lkgp: "Last-Known-Good-Provider routing",
|
||||
};
|
||||
|
||||
// ── Free Model Types ─────────────────────────────────────────────────────
|
||||
|
||||
export type FreeModelFreeType =
|
||||
| "recurring-daily"
|
||||
| "recurring-monthly"
|
||||
| "recurring-credit"
|
||||
| "one-time-initial"
|
||||
| "keyless"
|
||||
| "discontinued";
|
||||
|
||||
// ── Provider Label ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Title-case a long, lowercase-looking alias.
|
||||
* `antigravity` → `Antigravity`
|
||||
*/
|
||||
function titleCaseAlias(alias: string): string {
|
||||
if (alias.length === 0) return alias;
|
||||
return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the short label for an upstream provider.
|
||||
*
|
||||
* Rules:
|
||||
* 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim.
|
||||
* 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase.
|
||||
* 3. Neither → undefined.
|
||||
*/
|
||||
export function shortProviderLabel(
|
||||
enrichment: { providerDisplayName?: string; providerAlias?: string } | undefined
|
||||
): string | undefined {
|
||||
if (!enrichment) return undefined;
|
||||
const raw =
|
||||
typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : "";
|
||||
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
|
||||
const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : "";
|
||||
if (alias.length > 0) {
|
||||
return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias);
|
||||
}
|
||||
// Long displayName with no alias to fall back on: keep the long label
|
||||
// rather than dropping the provider prefix entirely.
|
||||
return raw.length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
// ── Free Label ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalise display name so free-tier models get a consistent `[Free] ` prefix.
|
||||
*
|
||||
* "GPT-4.1 (Free)" → "[Free] GPT-4.1"
|
||||
* "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash"
|
||||
* "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged)
|
||||
*/
|
||||
export function normaliseFreeLabel(name: string): string {
|
||||
// Bounded whitespace quantifiers ({0,8}/{1,8}) avoid the polynomial-ReDoS
|
||||
// backtracking that unbounded \s* before an anchored \s*$ would allow on
|
||||
// attacker-influenced display names. 8 covers any realistic label spacing.
|
||||
const cleaned = name
|
||||
.replace(/\s{0,8}\(free\)\s{0,8}$/i, "")
|
||||
.replace(/[\s-]{1,8}free\s{0,8}$/i, "")
|
||||
.trim();
|
||||
const wasFree = cleaned.length < name.trim().length;
|
||||
if (!wasFree) return name;
|
||||
return `[Free] ${cleaned}`;
|
||||
}
|
||||
|
||||
// ── Free Budget Formatting ────────────────────────────────────────────────
|
||||
|
||||
/** Scales, largest first, so the unit is chosen by descending magnitude. */
|
||||
const TOKEN_UNITS = [
|
||||
[1e9, "B"],
|
||||
[1e6, "M"],
|
||||
[1e3, "K"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Format a token count as a short magnitude string: `25M`, `1.5K`, `999`.
|
||||
*
|
||||
* The unit has to be picked from the value that will actually be *printed*,
|
||||
* not from the raw input. `toFixed(1)` rounds to the nearest tenth, so at the
|
||||
* K scale 999_950 and above render as `1000.0` — and by then the M branch has
|
||||
* already been skipped, producing `1000K` for a number that is `1M`. The same
|
||||
* carry turns just under a billion into `1000M`. When the rounded value reaches
|
||||
* the next scale, re-render at that scale instead.
|
||||
*/
|
||||
function fmtTokens(n: number): string {
|
||||
for (let i = 0; i < TOKEN_UNITS.length; i++) {
|
||||
const [scale, suffix] = TOKEN_UNITS[i]!;
|
||||
if (n < scale) continue;
|
||||
const value = Number((n / scale).toFixed(1));
|
||||
// `Number()` also drops a trailing `.0`, which the previous regex did.
|
||||
if (value < 1000 || i === 0) return `${value}${suffix}`;
|
||||
const [nextScale, nextSuffix] = TOKEN_UNITS[i - 1]!;
|
||||
return `${Number((n / nextScale).toFixed(1))}${nextSuffix}`;
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a free model budget into a short human-readable suffix.
|
||||
*
|
||||
* recurring-daily → "25M tokens/day"
|
||||
* recurring-monthly → "25M tokens/month"
|
||||
* recurring-credit → "10M credits"
|
||||
* one-time-initial → "1M credits (one-time)"
|
||||
* keyless → "(keyless)"
|
||||
* discontinued → "(discontinued)"
|
||||
*/
|
||||
export function formatFreeBudget(params: {
|
||||
freeType: FreeModelFreeType;
|
||||
monthlyTokens?: number;
|
||||
creditTokens?: number;
|
||||
}): string {
|
||||
const { freeType, monthlyTokens = 0, creditTokens = 0 } = params;
|
||||
|
||||
switch (freeType) {
|
||||
case "recurring-daily":
|
||||
return `${fmtTokens(monthlyTokens)} tokens/day`;
|
||||
case "recurring-monthly":
|
||||
return `${fmtTokens(monthlyTokens)} tokens/month`;
|
||||
case "recurring-credit":
|
||||
return `${fmtTokens(creditTokens)} credits`;
|
||||
case "one-time-initial":
|
||||
return `${fmtTokens(creditTokens)} credits (one-time)`;
|
||||
case "keyless":
|
||||
return "(keyless)";
|
||||
case "discontinued":
|
||||
return "(discontinued)";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto Combo Naming ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format auto combo display name.
|
||||
*
|
||||
* "Auto: Coding (4p)"
|
||||
* "Auto: Default (6p)"
|
||||
* "Auto" (no candidate count when unknown)
|
||||
*/
|
||||
export function formatAutoComboName(
|
||||
variant: AutoVariant | undefined,
|
||||
candidateCount?: number
|
||||
): string {
|
||||
const label = variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Default";
|
||||
const count =
|
||||
typeof candidateCount === "number" && candidateCount > 0 ? ` (${candidateCount}p)` : "";
|
||||
return `Auto: ${label}${count}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the model ID for an auto combo entry.
|
||||
* "auto/coding", "auto/fast", "auto" (default).
|
||||
*/
|
||||
export function autoComboModelId(variant: AutoVariant | undefined): string {
|
||||
return variant ? `auto/${variant}` : "auto";
|
||||
}
|
||||
|
||||
// ── Universal Display Name Builder ────────────────────────────────────────
|
||||
|
||||
export interface ModelDisplayNameParams {
|
||||
/** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */
|
||||
rawId: string;
|
||||
/** Enrichment display name (e.g. "Claude Sonnet 4.6"). */
|
||||
enrichmentName?: string;
|
||||
/** Provider tag enrichment. */
|
||||
providerAlias?: string;
|
||||
/** Human-readable upstream provider label. */
|
||||
providerDisplayName?: string;
|
||||
/** Whether model is free tier. */
|
||||
isFree?: boolean;
|
||||
/** Free model budget info. */
|
||||
freeType?: FreeModelFreeType;
|
||||
/** Monthly token budget (for recurring free models). */
|
||||
monthlyTokens?: number;
|
||||
/** Credit token budget (for credit-based free models). */
|
||||
creditTokens?: number;
|
||||
/** Whether this is a combo entry (skip provider tag). */
|
||||
isCombo?: boolean;
|
||||
/** Whether this is an auto combo entry. */
|
||||
isAutoCombo?: boolean;
|
||||
/** Auto combo variant. */
|
||||
autoVariant?: AutoVariant;
|
||||
/** Auto combo candidate count. */
|
||||
autoCandidateCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final display name following the universal template.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Auto combo → "Auto: <variant> (<N>p)"
|
||||
* 2. DB combo → "Combo: <name>"
|
||||
* 3. Free + enrichment + provider tag → "[Free] <label> - <name> · <budget>"
|
||||
* 4. Free + enrichment → "[Free] <name> · <budget>"
|
||||
* 5. Free + raw → "[Free] <rawId> · <budget>"
|
||||
* 6. Enrichment + provider tag → "<label> - <name>"
|
||||
* 7. Enrichment only → "<name>"
|
||||
* 8. Raw fallback → normaliseFreeLabel(rawId)
|
||||
*/
|
||||
export function buildModelDisplayName(params: ModelDisplayNameParams): string {
|
||||
// Auto combos
|
||||
if (params.isAutoCombo) {
|
||||
return formatAutoComboName(params.autoVariant, params.autoCandidateCount);
|
||||
}
|
||||
|
||||
// Determine base name — strip any existing free suffix first
|
||||
const rawBase =
|
||||
params.enrichmentName && params.enrichmentName.trim().length > 0
|
||||
? params.enrichmentName
|
||||
: params.rawId;
|
||||
const cleanedBase = rawBase
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/[\s-]+free\s*$/i, "")
|
||||
.trim();
|
||||
const wasFree = cleanedBase.length < rawBase.trim().length;
|
||||
const isFree = !!params.isFree || wasFree;
|
||||
|
||||
let baseName = cleanedBase;
|
||||
|
||||
// Provider tag (skip for combos)
|
||||
if (!params.isCombo) {
|
||||
const label = shortProviderLabel({
|
||||
providerDisplayName: params.providerDisplayName,
|
||||
providerAlias: params.providerAlias,
|
||||
});
|
||||
if (label) {
|
||||
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
|
||||
if (!baseName.startsWith(prefix)) {
|
||||
baseName = `${prefix}${baseName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend [Free] if applicable (AFTER provider tag for correct ordering)
|
||||
if (isFree) {
|
||||
baseName = `[Free] ${baseName}`;
|
||||
}
|
||||
|
||||
// Free budget suffix
|
||||
if (isFree && params.freeType) {
|
||||
const budget = formatFreeBudget({
|
||||
freeType: params.freeType,
|
||||
monthlyTokens: params.monthlyTokens,
|
||||
creditTokens: params.creditTokens,
|
||||
});
|
||||
if (budget) {
|
||||
baseName = `${baseName} · ${budget}`;
|
||||
}
|
||||
}
|
||||
|
||||
return baseName;
|
||||
}
|
||||
171
@omniroute/opencode-plugin-v2/src/shared/usable.ts
Normal file
171
@omniroute/opencode-plugin-v2/src/shared/usable.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { OmniRouteEnrichmentMap } from "./enrich.js";
|
||||
import type { OmniRouteRawCombo } from "./combos-map.js";
|
||||
|
||||
/** Subset of `/api/providers` connections read by the usableOnly filter. */
|
||||
export interface OmniRouteProviderConnection {
|
||||
/** Connection UUID. */
|
||||
id: string;
|
||||
/** Canonical provider id, e.g. `claude`, `gemini`, `kiro`. */
|
||||
provider: string;
|
||||
/** Operator toggle — when false, the connection is provisioned but disabled. */
|
||||
isActive?: boolean;
|
||||
/** Health-check verdict — `active` means routable. */
|
||||
testStatus?: string;
|
||||
/** Permissive bag — additional fields pass through untouched. */
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export type OmniRouteProvidersFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number,
|
||||
onSourceError?: (endpoint: string, reason: string) => void
|
||||
) => Promise<OmniRouteProviderConnection[]>;
|
||||
|
||||
export interface UsableProviderSet {
|
||||
aliases: Set<string>;
|
||||
canonicals: Set<string>;
|
||||
knownAliases: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default providers fetcher: `GET <baseURL>/api/providers` with bearer auth
|
||||
* + AbortController timeout. Accepts the `{ connections: [...] }` envelope
|
||||
* the gateway emits today, a bare-array envelope, and a `{ data: [...] }`
|
||||
* envelope (defensive). Refusals and network errors THROW so the caller keeps
|
||||
* last-known instead of silently disabling the filter; a 2xx with an empty
|
||||
* list is the only empty answer.
|
||||
*/
|
||||
export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000,
|
||||
onSourceError
|
||||
) => {
|
||||
const empty: OmniRouteProviderConnection[] = [];
|
||||
if (!baseURL || !apiKey) return empty;
|
||||
const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
|
||||
const url = `${root}/api/providers`;
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// A refusal (403 behind a management-token gate, 503 mid-outage) is a
|
||||
// failure, not an empty allowlist: the caller keeps last-known instead
|
||||
// of silently disabling the filter.
|
||||
onSourceError?.("/api/providers", `HTTP ${res.status}`);
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const list = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray((body as { connections?: unknown[] })?.connections)
|
||||
? (body as { connections: unknown[] }).connections
|
||||
: Array.isArray((body as { data?: unknown[] })?.data)
|
||||
? (body as { data: unknown[] }).data
|
||||
: [];
|
||||
const out: OmniRouteProviderConnection[] = [];
|
||||
for (const raw of list) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const provider = (raw as { provider?: unknown }).provider;
|
||||
if (typeof provider !== "string" || provider.length === 0) continue;
|
||||
const id = (raw as { id?: unknown }).id;
|
||||
const idStr = typeof id === "string" && id.length > 0 ? id : provider;
|
||||
out.push({ ...(raw as Record<string, unknown>), id: idStr, provider });
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Network error, timeout, abort: keep last-known, never silently unfilter.
|
||||
onSourceError?.("/api/providers", err instanceof Error ? err.message : String(err));
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the provider prefixes safe to keep. A canonical provider is usable
|
||||
* when at least one connection is not explicitly disabled (`isActive: false`)
|
||||
* and has no failing health verdict (`testStatus` present and not "active"). Aliases (e.g. `cc` -> `claude`) resolve through the enrichment
|
||||
* map, which records every alias in `knownAliases` so the downstream filter
|
||||
* decides "this prefix was in /api/pricing/models" in O(1).
|
||||
*
|
||||
* Subtract-filter semantics: callers keep prefixes unknown to BOTH tables.
|
||||
*/
|
||||
export function usableProviderAliasSet(
|
||||
connections: OmniRouteProviderConnection[],
|
||||
enrichment: OmniRouteEnrichmentMap | undefined
|
||||
): UsableProviderSet {
|
||||
const usableCanonicals = new Set<string>();
|
||||
for (const c of connections) {
|
||||
// A missing toggle means "no opinion", not "disabled": the field only
|
||||
// exists on newer gateways, and treating its absence as a veto would hide
|
||||
// every model behind a filter the operator never asked to tighten. Only an
|
||||
// explicit `false` disables.
|
||||
if (!c || c.isActive === false) continue;
|
||||
if (typeof c.testStatus === "string" && c.testStatus !== "active") continue;
|
||||
if (typeof c.provider === "string" && c.provider.length > 0) {
|
||||
usableCanonicals.add(c.provider);
|
||||
}
|
||||
}
|
||||
const aliases = new Set<string>();
|
||||
const knownAliases = new Set<string>();
|
||||
if (enrichment) {
|
||||
for (const entry of enrichment.values()) {
|
||||
const alias = entry.providerAlias;
|
||||
const canonical = entry.providerCanonical;
|
||||
if (typeof alias !== "string" || alias.length === 0) continue;
|
||||
knownAliases.add(alias);
|
||||
if (typeof canonical !== "string" || canonical.length === 0) continue;
|
||||
if (usableCanonicals.has(canonical)) aliases.add(alias);
|
||||
}
|
||||
}
|
||||
for (const canonical of usableCanonicals) aliases.add(canonical);
|
||||
return { aliases, canonicals: usableCanonicals, knownAliases };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a raw `/v1/models` id passes the usableOnly filter.
|
||||
* Rules (subtract-filter, bias toward keep): no `/` -> keep; usable alias
|
||||
* or canonical -> keep; unknown to both tables -> keep; known alias but not
|
||||
* usable -> drop.
|
||||
*/
|
||||
export function isUsableRawModelId(id: string, usable: UsableProviderSet): boolean {
|
||||
const slash = id.indexOf("/");
|
||||
if (slash <= 0) return true;
|
||||
const prefix = id.slice(0, slash);
|
||||
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
|
||||
if (usable.knownAliases.has(prefix)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a combo passes the usableOnly filter. A combo keeps when AT
|
||||
* LEAST ONE member maps to a usable provider; unknown prefixes keep (cannot
|
||||
* prove unroutable); combos with zero resolvable members keep.
|
||||
*/
|
||||
export function isUsableCombo(combo: OmniRouteRawCombo, usable: UsableProviderSet): boolean {
|
||||
const steps = Array.isArray(combo.models) ? combo.models : [];
|
||||
if (steps.length === 0) return true;
|
||||
let sawResolvableMember = false;
|
||||
for (const step of steps) {
|
||||
if (step?.kind === "combo-ref") continue;
|
||||
const modelId = typeof step?.model === "string" ? step.model : "";
|
||||
const slash = modelId.indexOf("/");
|
||||
if (slash <= 0) continue;
|
||||
sawResolvableMember = true;
|
||||
const prefix = modelId.slice(0, slash);
|
||||
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
|
||||
if (!usable.knownAliases.has(prefix)) return true;
|
||||
}
|
||||
if (!sawResolvableMember) return true;
|
||||
return false;
|
||||
}
|
||||
93
@omniroute/opencode-plugin-v2/tests/api-package.test.ts
Normal file
93
@omniroute/opencode-plugin-v2/tests/api-package.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
const SUPPORTED_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/anthropic"]);
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
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 CatalogDraft;
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
function apiPackageOf(m: ModelV2Info | undefined): string {
|
||||
assert.ok(m, "model must be published");
|
||||
assert.equal(m?.api.type, "aisdk");
|
||||
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
return m.api.package;
|
||||
}
|
||||
|
||||
describe("catalog api package (models + combos + auto-combos)", () => {
|
||||
it("every published entry carries a non-empty supported api.package", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "gpt-x", context_length: 128000, max_output_tokens: 4096 }],
|
||||
combosFetcher: async () => [
|
||||
{ id: "combo-a", name: "Combo A", models: [{ kind: "model", model: "gpt-x" }] },
|
||||
],
|
||||
autoCombosFetcher: async () => [{ id: "auto", candidateCount: 6 }],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 1 });
|
||||
for (const key of ["omniroute/gpt-x", "omniroute/combo-a", "omniroute/auto"]) {
|
||||
const pkg = apiPackageOf(models.get(key));
|
||||
assert.ok(pkg.length > 0, `${key} api.package must be non-empty`);
|
||||
assert.ok(SUPPORTED_PACKAGES.has(pkg), `${key} api.package must be supported, got ${pkg}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-combos follow the same anthropic apiFormat rule as models", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
...baseOpts,
|
||||
apiFormat: { allowAnthropic: true, anthropicModels: ["anthropic/claude-x", "auto/coding"] },
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "anthropic/claude-x" }],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => [
|
||||
{ id: "auto/coding", variant: "coding", candidateCount: 4 },
|
||||
{ id: "auto/fast", variant: "fast", candidateCount: 2 },
|
||||
],
|
||||
}
|
||||
);
|
||||
assert.equal(apiPackageOf(models.get("omniroute/anthropic/claude-x")), "@ai-sdk/anthropic");
|
||||
assert.equal(apiPackageOf(models.get("omniroute/auto/coding")), "@ai-sdk/anthropic");
|
||||
assert.equal(apiPackageOf(models.get("omniroute/auto/fast")), "@ai-sdk/openai-compatible");
|
||||
});
|
||||
});
|
||||
196
@omniroute/opencode-plugin-v2/tests/auto-combos.test.ts
Normal file
196
@omniroute/opencode-plugin-v2/tests/auto-combos.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
function fakeDraft(): {
|
||||
models: Map<string, ModelV2Info>;
|
||||
draft: CatalogDraft;
|
||||
warns: string[];
|
||||
restore: () => void;
|
||||
} {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
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 CatalogDraft;
|
||||
return {
|
||||
models,
|
||||
draft,
|
||||
warns,
|
||||
restore: () => {
|
||||
console.warn = origWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
timeouts: { autoCombos: 5000 },
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
describe("catalog auto combos (v1 parity)", () => {
|
||||
it("publishes auto/* ids from the auto-combos fetcher", async () => {
|
||||
const { models, draft, warns, restore } = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => [
|
||||
{ id: "auto", variant: undefined, candidateCount: 6 },
|
||||
{ id: "auto/coding", variant: "coding", candidateCount: 4 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 2 });
|
||||
assert.ok(models.has("omniroute/auto"), "auto entry must be published");
|
||||
assert.ok(models.has("omniroute/auto/coding"), "auto/coding entry must be published");
|
||||
const coding = models.get("omniroute/auto/coding");
|
||||
assert.equal(coding?.limit.context, 128_000);
|
||||
assert.equal(coding?.limit.output, 8_192);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.ok(!warns.some((w) => w.includes("auto combo") && w.includes("failed")));
|
||||
});
|
||||
|
||||
it("auto-combos fetch throw stays fail-open (warn, models kept)", async () => {
|
||||
const { models, draft, warns, restore } = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => {
|
||||
throw new Error("GET https://gw.example.com/api/combos/auto failed: 500 boom");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.ok(models.has("omniroute/m1"));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("auto combos") || w.includes("auto-combos")));
|
||||
});
|
||||
|
||||
it("forwards the 5s auto-combos timeout to the fetcher", async () => {
|
||||
const seen: number[] = [];
|
||||
const { draft, restore } = fakeDraft();
|
||||
try {
|
||||
await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, timeoutMs: 9999, timeouts: { autoCombos: 5000 } },
|
||||
{
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async (_base, _key, timeoutMs) => {
|
||||
seen.push(timeoutMs ?? -1);
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.deepEqual(seen, [5000]);
|
||||
});
|
||||
|
||||
it("hidden auto combos are skipped", async () => {
|
||||
const { models, draft, restore } = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => [
|
||||
{ id: "auto/offline", variant: "offline", isHidden: true },
|
||||
{ id: "auto/fast", variant: "fast" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 1 });
|
||||
assert.ok(!models.has("omniroute/auto/offline"));
|
||||
assert.ok(models.has("omniroute/auto/fast"));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto combos obey the same allowlists as everything else", () => {
|
||||
it("hides an auto combo the user hid, and keeps the rest", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const e: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(e);
|
||||
published.set(`${pid}/${mid}`, e);
|
||||
},
|
||||
},
|
||||
};
|
||||
await publishCatalog(
|
||||
draft as never,
|
||||
{
|
||||
providerId: "omni",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 1000,
|
||||
usableOnly: false,
|
||||
enrichment: new Map(),
|
||||
hiddenModels: ["auto/best-chaos"],
|
||||
} as never,
|
||||
{
|
||||
models: async () => [{ id: "m1" }],
|
||||
combos: async () => [],
|
||||
autoCombos: async () =>
|
||||
[
|
||||
{ id: "auto/best-coding", variant: "best-coding", candidateCount: 3 },
|
||||
{ id: "auto/best-chaos", variant: "best-chaos", candidateCount: 2 },
|
||||
] as never,
|
||||
providers: async () => [],
|
||||
enrichment: async () => new Map(),
|
||||
}
|
||||
);
|
||||
const keys = [...published.keys()];
|
||||
assert.ok(
|
||||
keys.some((k) => k.includes("best-coding")),
|
||||
`the other auto combo stays, got ${JSON.stringify(keys)}`
|
||||
);
|
||||
assert.equal(
|
||||
keys.some((k) => k.includes("best-chaos")),
|
||||
false,
|
||||
`a hidden auto combo must not reach the picker, got ${JSON.stringify(keys)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
378
@omniroute/opencode-plugin-v2/tests/cache-ttl-snapshot.test.ts
Normal file
378
@omniroute/opencode-plugin-v2/tests/cache-ttl-snapshot.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import plugin from "../src/index.js";
|
||||
import { DEFAULT_MODEL_CACHE_TTL_MS, diskSnapshotPath } from "../src/cache.js";
|
||||
|
||||
interface CtxOpts {
|
||||
providerId: string;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
modelCacheTtlMs?: number;
|
||||
}
|
||||
|
||||
function stubFetch(
|
||||
counter: { models: number; combos: number; autoCombos?: number; enrichment?: number },
|
||||
modelIds: string[]
|
||||
) {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
if (counter.enrichment !== undefined) counter.enrichment += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
if (counter.autoCombos !== undefined) counter.autoCombos += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
counter.combos += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
counter.models += 1;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: modelIds.map((id) => ({ id })) }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function setupPlugin(opts: CtxOpts): Promise<{
|
||||
callbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
reloads: { count: number };
|
||||
}> {
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const reloads = { count: 0 };
|
||||
const ctx = {
|
||||
options: { ...opts },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads.count += 1;
|
||||
},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
const logs: string[] = [];
|
||||
const warns: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(String(args[0]));
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
}
|
||||
return { callbacks, reloads };
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
return {
|
||||
published,
|
||||
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 entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isolateDisk(): { dir: string; restore: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-p1-"));
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
it("TTL default is 300s (v1 parity)", () => {
|
||||
assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000);
|
||||
});
|
||||
|
||||
it("2nd transform within TTL -> 0 network fetches (stub counter)", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0, autoCombos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(counter, ["m1"]);
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "ttl-hit",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-ttl",
|
||||
});
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
assert.equal(counter.combos, 1);
|
||||
assert.equal(counter.autoCombos, 1);
|
||||
assert.ok(published.has("ttl-hit/m1"));
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
assert.equal(counter.combos, 1);
|
||||
assert.equal(counter.autoCombos, 1);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("expired TTL -> network refetch", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(counter, ["m1"]);
|
||||
const origNow = Date.now;
|
||||
let now = 1_000_000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "ttl-expire",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-expire",
|
||||
modelCacheTtlMs: 1000,
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
now += 500;
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
now += 1000;
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 2);
|
||||
} finally {
|
||||
Date.now = origNow;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("concurrent calls share a single refresh promise", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/v1/models")) {
|
||||
counter.models += 1;
|
||||
await gate;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/providers")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
counter.combos += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}) as typeof fetch;
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "singleflight",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-sf",
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
const a = callbacks[0](draft);
|
||||
const b = callbacks[0](draft);
|
||||
release();
|
||||
await Promise.all([a, b]);
|
||||
assert.equal(counter.models, 1);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshot: fetch down at boot + snapshot present -> warm catalog published", async () => {
|
||||
const disk = isolateDisk();
|
||||
const warmCounter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(warmCounter, ["mw"]);
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "warm",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-warm",
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(statSync(diskSnapshotPath("warm")).isFile());
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
|
||||
const warmBody = (await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(join(disk.dir, "plugins", "omniroute-warm.json"), "utf8")
|
||||
)) as string;
|
||||
void warmBody;
|
||||
|
||||
const disk2prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = disk.dir;
|
||||
const downFetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
|
||||
}) as typeof fetch;
|
||||
globalThis.fetch = downFetch;
|
||||
const warns: string[] = [];
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "warm",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-warm",
|
||||
});
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(
|
||||
published.has("warm/mw"),
|
||||
`warm snapshot must publish mw, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
if (disk2prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = disk2prev;
|
||||
}
|
||||
void warns;
|
||||
});
|
||||
|
||||
it("invalidation: fresh setup does not reuse the previous in-memory cache", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(counter, ["m1"]);
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const first = await setupPlugin({
|
||||
providerId: "inval",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-inval",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await first.callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
// Fresh setup = empty memory (setup closure): the stale disk warm entry
|
||||
// expires + the refetch starts, no reuse of the previous cache.
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
await sleep(5);
|
||||
const second = await setupPlugin({
|
||||
providerId: "inval",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-inval",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
await second.callbacks[0](draft);
|
||||
assert.equal(counter.models, 2);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the snapshot carries the display overlay across a restart", () => {
|
||||
it("round-trips enrichment so a cold start is not a wall of raw ids", async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { writeDiskSnapshot, readDiskSnapshot } = await import("../src/cache.js");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-enrich-snap-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
try {
|
||||
const enrichment = new Map([
|
||||
["cc/sonnet", { name: "Claude Sonnet 4.6", providerDisplayName: "Claude" }],
|
||||
]);
|
||||
await writeDiskSnapshot(
|
||||
"omni",
|
||||
{
|
||||
models: [{ id: "cc/sonnet" }],
|
||||
combos: [],
|
||||
autoCombos: [],
|
||||
providers: [],
|
||||
enrichment,
|
||||
fetchedAt: Date.now(),
|
||||
} as never,
|
||||
"fp-1"
|
||||
);
|
||||
const back = await readDiskSnapshot("omni", "fp-1");
|
||||
assert.ok(back, "snapshot readable");
|
||||
assert.equal(back.enrichment?.get("cc/sonnet")?.name, "Claude Sonnet 4.6");
|
||||
assert.equal(back.enrichment?.get("cc/sonnet")?.providerDisplayName, "Claude");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
330
@omniroute/opencode-plugin-v2/tests/catalog.test.ts
Normal file
330
@omniroute/opencode-plugin-v2/tests/catalog.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
interface FakeDraft {
|
||||
providers: Map<string, ProviderV2Info>;
|
||||
models: Map<string, ModelV2Info>;
|
||||
warns: string[];
|
||||
provider: CatalogDraft["provider"];
|
||||
model: CatalogDraft["model"];
|
||||
}
|
||||
|
||||
function fakeDraft(): FakeDraft {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
return {
|
||||
providers,
|
||||
models,
|
||||
warns: [],
|
||||
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: () => {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
describe("catalog provider template", () => {
|
||||
it("writes provider api package/url plus integrationID", async () => {
|
||||
const draft = fakeDraft();
|
||||
await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
const p = draft.providers.get("omniroute");
|
||||
assert.ok(p);
|
||||
assert.equal(p?.name, "OmniRoute");
|
||||
assert.deepEqual(p?.api, {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
});
|
||||
assert.equal(p?.integrationID, "omniroute");
|
||||
});
|
||||
|
||||
it("publishes stub models with mapped fields, counts them", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [
|
||||
{
|
||||
id: "gpt-x",
|
||||
context_length: 128000,
|
||||
max_output_tokens: 4096,
|
||||
input_modalities: ["text", "image"],
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
{ id: "plain" },
|
||||
],
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
assert.deepEqual(res, { models: 2, combos: 0, autoCombos: 0 });
|
||||
const m = draft.models.get("omniroute/gpt-x");
|
||||
assert.ok(m);
|
||||
assert.equal(m?.providerID, "omniroute");
|
||||
assert.equal(m?.limit.context, 128000);
|
||||
assert.equal(m?.capabilities.tools, true);
|
||||
assert.equal(m?.status, "active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog allowlist", () => {
|
||||
const stubModels = async () => [{ id: "cc/keep-me" }, { id: "cc/drop-me" }, { id: "bare-keep" }];
|
||||
|
||||
it("visible exact allowlist keeps matches, drops the rest", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, visibleModels: ["cc/keep-me"] },
|
||||
{ fetcher: stubModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(res.models, 1);
|
||||
assert.ok(draft.models.has("omniroute/cc/keep-me"));
|
||||
assert.ok(!draft.models.has("omniroute/cc/drop-me"));
|
||||
});
|
||||
|
||||
it("hidden deny-wins over visible allow", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, visibleModels: ["cc/keep-me"], hiddenModels: ["cc/keep-me"] },
|
||||
{ fetcher: stubModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(res.models, 0);
|
||||
});
|
||||
|
||||
it("bare hidden id matches any prefix via suffix rule", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, hiddenModels: ["drop-me"] },
|
||||
{ fetcher: stubModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(res.models, 2);
|
||||
assert.ok(!draft.models.has("omniroute/cc/drop-me"));
|
||||
assert.ok(draft.models.has("omniroute/bare-keep"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog fail-open", () => {
|
||||
it("models fetch throw warns and returns zeros without throwing", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => {
|
||||
throw new Error("boom 500");
|
||||
},
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /models fetch failed/);
|
||||
assert.ok(draft.providers.has("omniroute"));
|
||||
});
|
||||
|
||||
it("combos fetch throw keeps models, warns, returns models-only counts", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
throw Object.assign(new Error("Not Found"), { status: 404 });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
|
||||
it("combos fetch 403 (PROD) keeps models, warns, never rejects", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
throw new Error(
|
||||
"[omniroute-v2] GET https://gw.example.com/api/combos failed: 403 Forbidden"
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.match(warns[0], /403/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
|
||||
it("combos fetch 500 keeps models, warns, never rejects", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
throw new Error(
|
||||
"[omniroute-v2] GET https://gw.example.com/api/combos failed: 500 Internal Server Error"
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.match(warns[0], /500/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
|
||||
it("combos fetch timeout (AbortError) keeps models, warns, never rejects", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
const err = new Error("This operation was aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog combo vs combo", () => {
|
||||
it("second combo with same id warns only once", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "dupe" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "dupe",
|
||||
name: "Dupe Combo",
|
||||
models: [{ kind: "model", model: "dupe" }],
|
||||
},
|
||||
{
|
||||
id: "dupe",
|
||||
name: "Dupe Combo Again",
|
||||
models: [{ kind: "model", model: "dupe" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 2, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /collides with a model id; combo wins/);
|
||||
const m = draft.models.get("omniroute/dupe");
|
||||
assert.ok(m);
|
||||
assert.equal(m?.name, "Dupe Combo Again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog model bare vs combo", () => {
|
||||
it("bare model id colliding with combo id warns exactly once", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "dupe" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "dupe",
|
||||
name: "Dupe Combo",
|
||||
models: [{ kind: "model", model: "dupe" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /collides with a model id; combo wins/);
|
||||
assert.match(warns[0], /"omniroute\/dupe"/);
|
||||
});
|
||||
});
|
||||
34
@omniroute/opencode-plugin-v2/tests/compat.test.ts
Normal file
34
@omniroute/opencode-plugin-v2/tests/compat.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { assertContext } from "../src/compat.js";
|
||||
|
||||
function validContext() {
|
||||
return {
|
||||
options: { baseURL: "https://gw.example.com" },
|
||||
catalog: { transform: async () => {} },
|
||||
integration: { transform: async () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe("assertContext", () => {
|
||||
it("throws on non-object ctx", () => {
|
||||
assert.throws(() => assertContext(null), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("throws when catalog.transform is missing", () => {
|
||||
const ctx = { ...validContext(), catalog: {} };
|
||||
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("serves a catalog on a host that has no integration domain", () => {
|
||||
// The integration domain carries the credential flow, not the catalog.
|
||||
// Refusing to load without it would deny the whole plugin to a host that
|
||||
// simply does not implement that surface yet.
|
||||
assert.doesNotThrow(() => assertContext({ catalog: { transform: () => {} }, options: {} }));
|
||||
});
|
||||
it("throws when options is not an object", () => {
|
||||
const ctx = { ...validContext(), options: undefined };
|
||||
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("passes for a valid context", () => {
|
||||
assert.doesNotThrow(() => assertContext(validContext()));
|
||||
});
|
||||
});
|
||||
128
@omniroute/opencode-plugin-v2/tests/credentials.test.ts
Normal file
128
@omniroute/opencode-plugin-v2/tests/credentials.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { Logger } from "../src/shared/index.js";
|
||||
import { resolveApiKey, warnIfMissing } from "../src/credentials.js";
|
||||
|
||||
function collectingLogger(): { log: Logger; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const log = {
|
||||
error: () => {},
|
||||
warn: (m: string) => warnings.push(m),
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
} as unknown as Logger;
|
||||
return { log, warnings };
|
||||
}
|
||||
|
||||
/** A host exposing `integration.connection`, optionally with a stored value. */
|
||||
function ctxWith(stored: unknown, opts: { withConnectionApi?: boolean } = {}): PluginContext {
|
||||
const integration =
|
||||
opts.withConnectionApi === false
|
||||
? {}
|
||||
: {
|
||||
connection: {
|
||||
active: async () =>
|
||||
stored === undefined ? undefined : { type: "credential", id: "c", label: "l" },
|
||||
resolve: async () => stored,
|
||||
},
|
||||
};
|
||||
return { integration } as unknown as PluginContext;
|
||||
}
|
||||
|
||||
const ENV = "OMNIROUTE_API_KEY";
|
||||
|
||||
async function withEnv<T>(value: string | undefined, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env[ENV];
|
||||
if (value === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = value;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway credential resolution", () => {
|
||||
it("prefers the credential the host stored over one written in config", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const got = await withEnv("from-env", () =>
|
||||
resolveApiKey(ctxWith({ type: "key", key: "from-host" }), "omniroute", "from-option", log)
|
||||
);
|
||||
assert.deepEqual(got, { key: "from-host", origin: "connection" });
|
||||
});
|
||||
|
||||
it("falls back to the configured option, then to the environment", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const option = await withEnv("from-env", () =>
|
||||
resolveApiKey(ctxWith(undefined), "omniroute", "from-option", log)
|
||||
);
|
||||
assert.deepEqual(option, { key: "from-option", origin: "option" });
|
||||
const env = await withEnv("from-env", () =>
|
||||
resolveApiKey(ctxWith(undefined), "omniroute", undefined, log)
|
||||
);
|
||||
assert.deepEqual(env, { key: "from-env", origin: "env" });
|
||||
});
|
||||
|
||||
it("reports no key rather than pretending an empty one works", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const got = await withEnv(undefined, () =>
|
||||
resolveApiKey(ctxWith(undefined), "omniroute", undefined, log)
|
||||
);
|
||||
assert.deepEqual(got, { key: "", origin: "missing" });
|
||||
warnIfMissing(got, "omniroute", log);
|
||||
assert.equal(warnings.length, 1);
|
||||
// The message must name every way out, or it sends the user hunting.
|
||||
assert.match(warnings[0] ?? "", /Connect the integration/);
|
||||
assert.match(warnings[0] ?? "", /"apiKey"/);
|
||||
assert.match(warnings[0] ?? "", new RegExp(ENV));
|
||||
});
|
||||
|
||||
it("declines an oauth credential instead of reading a token as a key", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const got = await withEnv(undefined, () =>
|
||||
resolveApiKey(
|
||||
ctxWith({ type: "oauth", methodID: "m", refresh: "r", access: "a", expires: 0 }),
|
||||
"omniroute",
|
||||
undefined,
|
||||
log
|
||||
)
|
||||
);
|
||||
assert.equal(got.origin, "missing");
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? "", /oauth/);
|
||||
});
|
||||
|
||||
it("stays loadable on a host that has no connection api", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const got = await withEnv(undefined, () =>
|
||||
resolveApiKey(
|
||||
ctxWith(undefined, { withConnectionApi: false }),
|
||||
"omniroute",
|
||||
"from-option",
|
||||
log
|
||||
)
|
||||
);
|
||||
assert.deepEqual(got, { key: "from-option", origin: "option" });
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
it("treats a lookup that throws as no credential, not as a failure to load", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const ctx = {
|
||||
integration: {
|
||||
connection: {
|
||||
active: async () => {
|
||||
throw new Error("store unavailable");
|
||||
},
|
||||
resolve: async () => undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginContext;
|
||||
const got = await withEnv(undefined, () => resolveApiKey(ctx, "omniroute", "from-option", log));
|
||||
assert.deepEqual(got, { key: "from-option", origin: "option" });
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? "", /store unavailable/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
/**
|
||||
* A gateway routes the same model id through several upstream providers. The
|
||||
* overlay must keep them apart: one connection's price shown on another's
|
||||
* model is worse than no price at all, because it looks authoritative.
|
||||
*/
|
||||
describe("two providers selling the same model id keep their own overlay", () => {
|
||||
it("does not let the second provider's price land on the first one's model", async () => {
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/pricing/models"))
|
||||
return ok({
|
||||
providers: {
|
||||
cc: {
|
||||
id: "claude",
|
||||
alias: "cc",
|
||||
name: "Claude",
|
||||
models: [{ id: "shared", name: "From Claude" }],
|
||||
},
|
||||
kir: {
|
||||
id: "kiro",
|
||||
alias: "kir",
|
||||
name: "Kiro",
|
||||
models: [{ id: "shared", name: "From Kiro" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (href.includes("/api/pricing"))
|
||||
return ok({
|
||||
cc: { shared: { input: 3, output: 15 } },
|
||||
kir: { shared: { input: 99, output: 99 } },
|
||||
});
|
||||
return ok({});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.deepEqual(map.get("cc/shared")?.pricing, { input: 3, output: 15 });
|
||||
assert.deepEqual(map.get("kir/shared")?.pricing, { input: 99, output: 99 });
|
||||
// The bare fallback belongs to whoever claimed it first, and keeps that
|
||||
// provider's numbers rather than the last writer's.
|
||||
assert.equal(map.get("shared")?.providerAlias, "cc");
|
||||
assert.deepEqual(map.get("shared")?.pricing, { input: 3, output: 15 });
|
||||
assert.notEqual(map.get("shared"), map.get("cc/shared"), "the bare entry is its own object");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyEnrichment } from "../src/shared/enrich.js";
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
|
||||
function model(id: string, name = id): ModelV2 {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
} as unknown as ModelV2;
|
||||
}
|
||||
|
||||
describe("the enrichment overlay reaches the picker", () => {
|
||||
it("renders the upstream provider a model routes to", () => {
|
||||
const m = applyEnrichment(model("cc/sonnet"), {
|
||||
name: "Claude Sonnet 4.6",
|
||||
providerDisplayName: "Claude",
|
||||
providerAlias: "cc",
|
||||
});
|
||||
assert.equal(m.name, "Claude - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
it("marks a free model and states the budget the user actually gets", () => {
|
||||
const m = applyEnrichment(model("pol/grok"), {
|
||||
name: "Grok 4 Fast",
|
||||
providerDisplayName: "Pollinations",
|
||||
freeType: "recurring-monthly" as const,
|
||||
monthlyTokens: 1_000_000,
|
||||
});
|
||||
assert.match(m.name, /\[Free\]/);
|
||||
assert.match(m.name, /Grok 4 Fast/);
|
||||
assert.match(
|
||||
m.name,
|
||||
/tokens\/month/,
|
||||
"the budget the gateway reports is stated, not just the fact it is free"
|
||||
);
|
||||
assert.equal(m.name, "[Free] Pollinations - Grok 4 Fast · 1M tokens/month");
|
||||
});
|
||||
|
||||
it("drops the tag when the caller turns it off", () => {
|
||||
const m = applyEnrichment(
|
||||
model("cc/sonnet"),
|
||||
{ name: "Claude Sonnet 4.6", providerDisplayName: "Claude" },
|
||||
{ providerTag: false }
|
||||
);
|
||||
assert.equal(m.name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
it("never tags a combo: it routes across providers, not to one", () => {
|
||||
const m = applyEnrichment(
|
||||
model("combo-fast", "Combo Fast"),
|
||||
{ name: "Combo Fast", providerDisplayName: "Claude" },
|
||||
{ isCombo: true }
|
||||
);
|
||||
assert.equal(m.name, "Combo Fast");
|
||||
});
|
||||
|
||||
it("still overlays pricing", () => {
|
||||
const m = applyEnrichment(model("x"), { pricing: { input: 3, output: 15 } });
|
||||
assert.equal(m.cost.input, 3);
|
||||
assert.equal(m.cost.output, 15);
|
||||
});
|
||||
});
|
||||
106
@omniroute/opencode-plugin-v2/tests/enrichment-report.test.ts
Normal file
106
@omniroute/opencode-plugin-v2/tests/enrichment-report.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Logger } from "../src/shared/index.js";
|
||||
import { createSourceErrorReporter } from "../src/enrichment-report.js";
|
||||
|
||||
function collectingLogger(): { log: Logger; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const log = {
|
||||
error: () => {},
|
||||
warn: (m: string) => warnings.push(m),
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
} as unknown as Logger;
|
||||
return { log, warnings };
|
||||
}
|
||||
|
||||
describe("degraded enrichment is reported, not swallowed", () => {
|
||||
it("names the endpoint and what the catalog loses", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, false)("/api/pricing", "HTTP 500");
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? "", /\/api\/pricing/);
|
||||
assert.match(warnings[0] ?? "", /HTTP 500/);
|
||||
assert.match(warnings[0] ?? "", /pricing are degraded/);
|
||||
});
|
||||
|
||||
it("points a 403 at the management token when the inference key stands in", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, true)("/api/pricing/models", "HTTP 403");
|
||||
assert.match(warnings[0] ?? "", /managementReadToken/);
|
||||
assert.match(warnings[0] ?? "", /falls back to "apiKey"/);
|
||||
});
|
||||
|
||||
it("does not blame the fallback when a management token was configured", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, false)("/api/pricing/models", "HTTP 403");
|
||||
assert.match(warnings[0] ?? "", /was rejected/);
|
||||
assert.doesNotMatch(warnings[0] ?? "", /falls back/);
|
||||
});
|
||||
|
||||
it("keeps a transport failure free of auth advice", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, true)("/api/pricing", "connect ECONNREFUSED");
|
||||
assert.doesNotMatch(warnings[0] ?? "", /managementReadToken/);
|
||||
});
|
||||
|
||||
it("warns once per endpoint so a refresh loop cannot spam the log", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const report = createSourceErrorReporter(log, true);
|
||||
report("/api/pricing", "HTTP 403");
|
||||
report("/api/pricing", "HTTP 403");
|
||||
report("/api/free-tier/summary", "HTTP 403");
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the library path reports too, not only the plugin", () => {
|
||||
it("passes a refused /api/providers up to the caller through publishCatalog", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const seen: Array<[string, string]> = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/providers"))
|
||||
return { ok: false, status: 403, statusText: "Forbidden", json: async () => ({}) };
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier"))
|
||||
return { ok: false, status: 403, statusText: "Forbidden", json: async () => ({}) };
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (_p: string, _m: string, fn: (x: Record<string, unknown>) => void) => fn({}),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await publishCatalog(
|
||||
draft as never,
|
||||
{
|
||||
providerId: "omni",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 1000,
|
||||
usableOnly: true,
|
||||
} as never,
|
||||
{ onSourceError: (endpoint, reason) => seen.push([endpoint, reason]) }
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
const endpoints = seen.map(([e]) => e);
|
||||
assert.ok(
|
||||
endpoints.includes("/api/providers"),
|
||||
`the usable filter must say it was refused, got ${JSON.stringify(endpoints)}`
|
||||
);
|
||||
assert.ok(endpoints.some((e) => e.startsWith("/api/pricing")));
|
||||
});
|
||||
});
|
||||
124
@omniroute/opencode-plugin-v2/tests/enrichment.test.ts
Normal file
124
@omniroute/opencode-plugin-v2/tests/enrichment.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import type { OmniRouteEnrichmentMap } from "../src/shared/index.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
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 CatalogDraft;
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
const stubModels = async () => [{ id: "cc/m1", context_length: 1000 }];
|
||||
|
||||
describe("catalog enrichment source", () => {
|
||||
it("applies names and pricing from an injected enrichmentFetcher", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
const enrichment: OmniRouteEnrichmentMap = new Map([
|
||||
["cc/m1", { name: "Model One", pricing: { input: 3, output: 15 } }],
|
||||
["m1", { name: "Model One", pricing: { input: 3, output: 15 } }],
|
||||
]);
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => enrichment,
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
const m = models.get("omniroute/cc/m1");
|
||||
assert.ok(m);
|
||||
assert.equal(m?.name, "Model One");
|
||||
assert.equal(m?.cost[0].input, 3);
|
||||
assert.equal(m?.cost[0].output, 15);
|
||||
});
|
||||
|
||||
it("stays fail-open when the enrichment fetch throws", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => {
|
||||
throw new Error("pricing down");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.ok(models.get("omniroute/cc/m1"));
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("enrichment fetch failed")));
|
||||
});
|
||||
|
||||
it("forwards the enrichment timeout and skips the fetch when enrichment is false", async () => {
|
||||
const { draft } = fakeDraft();
|
||||
let seenTimeout: number | undefined;
|
||||
await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, timeouts: { enrichment: 4321 } },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async (_base, _key, timeout) => {
|
||||
seenTimeout = timeout;
|
||||
return new Map();
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(seenTimeout, 4321);
|
||||
|
||||
const { draft: draft2 } = fakeDraft();
|
||||
let called = 0;
|
||||
await publishCatalog(
|
||||
draft2,
|
||||
{ ...baseOpts, enrichment: false },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => {
|
||||
called += 1;
|
||||
return new Map();
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
});
|
||||
68
@omniroute/opencode-plugin-v2/tests/fixtures/catalog.json
vendored
Normal file
68
@omniroute/opencode-plugin-v2/tests/fixtures/catalog.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "m-alpha",
|
||||
"object": "model",
|
||||
"owned_by": "openai",
|
||||
"context_length": 128000,
|
||||
"max_output_tokens": 4096,
|
||||
"input_modalities": ["text", "image"],
|
||||
"output_modalities": ["text"],
|
||||
"capabilities": { "tool_calling": true, "reasoning": true }
|
||||
},
|
||||
{
|
||||
"id": "m-beta",
|
||||
"object": "model",
|
||||
"owned_by": "openai",
|
||||
"context_length": 64000,
|
||||
"max_output_tokens": 2048,
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"capabilities": { "tool_calling": false, "thinking": true, "temperature": false }
|
||||
},
|
||||
{
|
||||
"id": "cc/m-gamma",
|
||||
"object": "model",
|
||||
"owned_by": "anthropic",
|
||||
"context_length": 200000,
|
||||
"max_output_tokens": 32000,
|
||||
"input_modalities": ["text", "image", "pdf"],
|
||||
"output_modalities": ["text"],
|
||||
"capabilities": { "tool_calling": true, "reasoning": true, "vision": true }
|
||||
},
|
||||
{
|
||||
"id": "local-delta",
|
||||
"object": "model",
|
||||
"context_length": 32768,
|
||||
"capabilities": { "effort_tiers": ["low", "high"] }
|
||||
},
|
||||
{
|
||||
"id": "good-combo",
|
||||
"object": "model",
|
||||
"owned_by": "omniroute",
|
||||
"context_length": 100000,
|
||||
"max_output_tokens": 8000,
|
||||
"capabilities": { "tool_calling": true }
|
||||
}
|
||||
],
|
||||
"combos": [
|
||||
{
|
||||
"id": "combo-fast",
|
||||
"name": "Combo Fast",
|
||||
"strategy": "priority",
|
||||
"models": [
|
||||
{ "kind": "model", "model": "m-alpha" },
|
||||
{ "kind": "model", "model": "cc/m-gamma" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "good-combo",
|
||||
"name": "Good Combo",
|
||||
"strategy": "priority",
|
||||
"models": [
|
||||
{ "kind": "model", "model": "m-beta" },
|
||||
{ "kind": "model", "model": "local-delta" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
301
@omniroute/opencode-plugin-v2/tests/fixtures/v1-parity.json
vendored
Normal file
301
@omniroute/opencode-plugin-v2/tests/fixtures/v1-parity.json
vendored
Normal file
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"_note": "Recorded output of @omniroute/opencode-plugin (v1) against tests/fixtures/catalog.json. This is the contract this package claims parity with; regenerate only when intentionally re-basing on a newer v1.",
|
||||
"v1PluginVersion": "0.2.1",
|
||||
"hookId": "opencode-omniroute",
|
||||
"publishedKeys": ["combo-fast", "good-combo", "local-delta", "m-alpha", "m-beta"],
|
||||
"comboSlugs": ["combo-fast", "good-combo"],
|
||||
"mappedModels": {
|
||||
"m-alpha": {
|
||||
"id": "omniroute/m-alpha",
|
||||
"name": "m-alpha",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": true,
|
||||
"attachment": false,
|
||||
"toolcall": true,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": true,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": false
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 128000,
|
||||
"output": 4096
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
},
|
||||
"m-beta": {
|
||||
"id": "omniroute/m-beta",
|
||||
"name": "m-beta",
|
||||
"capabilities": {
|
||||
"temperature": false,
|
||||
"reasoning": true,
|
||||
"attachment": false,
|
||||
"toolcall": false,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": true
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 64000,
|
||||
"output": 2048
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
},
|
||||
"local-delta": {
|
||||
"id": "omniroute/local-delta",
|
||||
"name": "local-delta",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": false,
|
||||
"attachment": false,
|
||||
"toolcall": false,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": false
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
"output": 0
|
||||
},
|
||||
"variants": {
|
||||
"low": {
|
||||
"reasoningEffort": "low"
|
||||
},
|
||||
"high": {
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
},
|
||||
"good-combo": {
|
||||
"id": "omniroute/good-combo",
|
||||
"name": "good-combo",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": false,
|
||||
"attachment": false,
|
||||
"toolcall": true,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": false
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 100000,
|
||||
"output": 8000
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mappedCombos": {
|
||||
"combo-fast": {
|
||||
"id": "combo-fast",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
},
|
||||
"name": "Combo Fast",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": true,
|
||||
"attachment": false,
|
||||
"toolcall": true,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": true,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": false
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 128000,
|
||||
"output": 4096
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": ""
|
||||
},
|
||||
"good-combo": {
|
||||
"id": "good-combo",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
},
|
||||
"name": "Good Combo",
|
||||
"capabilities": {
|
||||
"temperature": false,
|
||||
"reasoning": false,
|
||||
"attachment": false,
|
||||
"toolcall": false,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": false
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
"output": 2048
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
226
@omniroute/opencode-plugin-v2/tests/gemini-language.test.ts
Normal file
226
@omniroute/opencode-plugin-v2/tests/gemini-language.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Logger } from "../src/shared/index.js";
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider";
|
||||
import plugin from "../src/index.js";
|
||||
import { sanitizeToolSchemasFor } from "../src/gemini-language.js";
|
||||
|
||||
function collectingLogger(): { log: Logger; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const log = {
|
||||
error: () => {},
|
||||
warn: (m: string) => warnings.push(m),
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
} as unknown as Logger;
|
||||
return { log, warnings };
|
||||
}
|
||||
|
||||
/** Records what the underlying model was actually asked to do. */
|
||||
function recordingModel(): { model: Record<string, unknown>; seen: unknown[] } {
|
||||
const seen: unknown[] = [];
|
||||
const model = {
|
||||
specificationVersion: "v3",
|
||||
modelId: "gemini-2.5-flash",
|
||||
provider: "omniroute",
|
||||
doGenerate: async (options: unknown) => {
|
||||
seen.push(options);
|
||||
return { content: [], finishReason: "stop", usage: {}, warnings: [] };
|
||||
},
|
||||
doStream: async (options: unknown) => {
|
||||
seen.push(options);
|
||||
return { stream: new ReadableStream() };
|
||||
},
|
||||
};
|
||||
return { model, seen };
|
||||
}
|
||||
|
||||
const dirtyTools = [
|
||||
{
|
||||
type: "function",
|
||||
name: "edit",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: { p: { type: "string" } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("Gemini sanitising on the language model", () => {
|
||||
it("cleans the tool schemas a Gemini model would reject, on both call paths", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"gemini-2.5-flash",
|
||||
log
|
||||
);
|
||||
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
await wrapped.doStream({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
assert.equal(seen.length, 2);
|
||||
for (const options of seen) {
|
||||
const schema = (options as { tools: Array<{ inputSchema: Record<string, unknown> }> })
|
||||
.tools[0]!.inputSchema;
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.equal(
|
||||
((schema["properties"] as Record<string, Record<string, unknown>>)["p"] ?? {})["type"],
|
||||
"string"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a non-Gemini model's request exactly as it was", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"claude-opus-5",
|
||||
log
|
||||
);
|
||||
assert.equal(wrapped, model, "a model that needs no cleaning must not even be wrapped");
|
||||
await (wrapped as unknown as { doGenerate: (o: unknown) => Promise<unknown> }).doGenerate({
|
||||
prompt: [],
|
||||
tools: structuredClone(dirtyTools),
|
||||
});
|
||||
const schema = (seen[0] as { tools: Array<{ inputSchema: Record<string, unknown> }> }).tools[0]!
|
||||
.inputSchema;
|
||||
assert.equal(schema["additionalProperties"], false);
|
||||
});
|
||||
|
||||
it("forwards a request with no tools untouched", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"gemini-2.5-flash",
|
||||
log
|
||||
);
|
||||
await wrapped.doGenerate({ prompt: [] } as never);
|
||||
assert.deepEqual(seen[0], { prompt: [] });
|
||||
});
|
||||
|
||||
it("keeps the properties the model still needs, and says once that it cleaned", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"gemini-2.5-flash",
|
||||
log
|
||||
);
|
||||
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
const schema = (seen[0] as { tools: Array<{ inputSchema: Record<string, any> }> }).tools[0]!
|
||||
.inputSchema;
|
||||
assert.equal(schema["properties"]["p"]["type"], "string");
|
||||
assert.equal(warnings.length, 0, "a routine cleaning is not a warning");
|
||||
});
|
||||
|
||||
it("passes the untouched model through when the host hands over nothing to wrap", () => {
|
||||
const { log } = collectingLogger();
|
||||
assert.equal(
|
||||
sanitizeToolSchemasFor(undefined as LanguageModelV3 | undefined, "gemini-2.5-flash", log),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini sanitising is wired into the host, and only where it belongs", () => {
|
||||
interface LanguageInput {
|
||||
model: { id: string; providerID: string };
|
||||
sdk: unknown;
|
||||
options: Record<string, unknown>;
|
||||
language?: LanguageModelV3;
|
||||
}
|
||||
|
||||
function hostCtx(opts: { geminiSanitization?: boolean; withAisdk?: boolean }): {
|
||||
ctx: Record<string, unknown>;
|
||||
languageCallbacks: Array<(input: LanguageInput) => void | Promise<void>>;
|
||||
} {
|
||||
const languageCallbacks: Array<(input: LanguageInput) => void | Promise<void>> = [];
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const options: Record<string, unknown> = {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omni",
|
||||
apiKey: "k",
|
||||
};
|
||||
if (opts.geminiSanitization !== undefined)
|
||||
options["geminiSanitization"] = opts.geminiSanitization;
|
||||
const ctx: Record<string, unknown> = {
|
||||
options,
|
||||
catalog: { transform: () => registration, reload: async () => {} },
|
||||
integration: { transform: () => registration },
|
||||
};
|
||||
if (opts.withAisdk !== false) {
|
||||
ctx["aisdk"] = {
|
||||
language: (cb: (input: LanguageInput) => void | Promise<void>) => {
|
||||
languageCallbacks.push(cb);
|
||||
return registration;
|
||||
},
|
||||
sdk: () => registration,
|
||||
};
|
||||
}
|
||||
return { ctx, languageCallbacks };
|
||||
}
|
||||
|
||||
async function setupPlugin(ctx: Record<string, unknown>): Promise<void> {
|
||||
const warn = console.warn;
|
||||
const log = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
console.log = log;
|
||||
}
|
||||
}
|
||||
|
||||
const bareModel = { specificationVersion: "v3", modelId: "x" } as unknown as LanguageModelV3;
|
||||
|
||||
it("wraps a Gemini model of this provider and leaves every other one alone", async () => {
|
||||
const { ctx, languageCallbacks } = hostCtx({});
|
||||
await setupPlugin(ctx);
|
||||
assert.equal(languageCallbacks.length, 1);
|
||||
const wrapOf = async (model: { id: string; providerID: string }) => {
|
||||
const input: LanguageInput = { model, sdk: {}, options: {}, language: bareModel };
|
||||
await languageCallbacks[0]!(input);
|
||||
return input.language !== bareModel;
|
||||
};
|
||||
assert.equal(await wrapOf({ id: "gemini-2.5-flash", providerID: "omni" }), true);
|
||||
assert.equal(await wrapOf({ id: "claude-opus-5", providerID: "omni" }), false);
|
||||
assert.equal(
|
||||
await wrapOf({ id: "gemini-2.5-flash", providerID: "some-other-provider" }),
|
||||
false,
|
||||
"another provider's models are none of this plugin's business"
|
||||
);
|
||||
});
|
||||
|
||||
it("registers nothing when the option is turned off", async () => {
|
||||
const { ctx, languageCallbacks } = hostCtx({ geminiSanitization: false });
|
||||
await setupPlugin(ctx);
|
||||
assert.deepEqual(languageCallbacks, []);
|
||||
});
|
||||
|
||||
it("still loads on a host that exposes no aisdk domain", async () => {
|
||||
const { ctx } = hostCtx({ withAisdk: false });
|
||||
await setupPlugin(ctx);
|
||||
});
|
||||
|
||||
it("keeps the catalog when the host refuses the language hook", async () => {
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const ctx: Record<string, unknown> = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omni", apiKey: "k" },
|
||||
catalog: { transform: () => registration, reload: async () => {} },
|
||||
integration: { transform: () => registration },
|
||||
aisdk: {
|
||||
language: () => {
|
||||
throw new Error("host says no");
|
||||
},
|
||||
sdk: () => registration,
|
||||
},
|
||||
};
|
||||
// Must not reject: tool-schema cleaning is an extra, the catalog is the job.
|
||||
await setupPlugin(ctx);
|
||||
});
|
||||
});
|
||||
164
@omniroute/opencode-plugin-v2/tests/host-contract.test.ts
Normal file
164
@omniroute/opencode-plugin-v2/tests/host-contract.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import {
|
||||
publishCatalog,
|
||||
type BinaryCompatModel,
|
||||
type BinaryCompatProvider,
|
||||
type BinaryCompatVariant,
|
||||
} from "../src/catalog.js";
|
||||
import { detectHostContract, emitsLegacyFields } from "../src/compat.js";
|
||||
|
||||
/**
|
||||
* A host seed shape. `legacy` mirrors `Provider.Info.empty` as observed on
|
||||
* `@opencode-ai/cli` 0.0.0-beta-17823; `sdk` mirrors the pinned SDK contract;
|
||||
* `bare` is a host that discloses neither.
|
||||
*/
|
||||
type SeedKind = "legacy" | "sdk" | "bare";
|
||||
|
||||
function providerSeed(id: string, kind: SeedKind): ProviderV2Info {
|
||||
if (kind === "legacy") {
|
||||
return { id, name: id, activation: "auto", package: "" } as unknown as ProviderV2Info;
|
||||
}
|
||||
if (kind === "sdk") {
|
||||
return { id, name: id, api: { type: "aisdk", package: "", url: "" } } as ProviderV2Info;
|
||||
}
|
||||
return { id } as ProviderV2Info;
|
||||
}
|
||||
|
||||
function fakeDraft(kind: SeedKind): {
|
||||
draft: CatalogDraft;
|
||||
providers: Map<string, ProviderV2Info>;
|
||||
models: Map<string, ModelV2Info>;
|
||||
} {
|
||||
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) ?? providerSeed(id, kind);
|
||||
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 CatalogDraft;
|
||||
return { draft, providers, models };
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
const rawModel = {
|
||||
id: "af/chat-latest",
|
||||
capabilities: { effort_tiers: ["low", "high"] },
|
||||
};
|
||||
|
||||
async function publish(kind: SeedKind) {
|
||||
const { draft, providers, models } = fakeDraft(kind);
|
||||
await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [rawModel],
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
const provider = providers.get("omniroute");
|
||||
const model = models.get("omniroute/af/chat-latest");
|
||||
assert.ok(provider, "provider must be published");
|
||||
assert.ok(model, "model must be published");
|
||||
return { provider: provider as BinaryCompatProvider, model: model as BinaryCompatModel };
|
||||
}
|
||||
|
||||
describe("host contract detection", () => {
|
||||
it("reads the contract off the seeded object, not off a version", () => {
|
||||
assert.equal(detectHostContract({ id: "x", package: "" }), "legacy-package");
|
||||
assert.equal(detectHostContract({ id: "x", api: { type: "aisdk" } }), "sdk-api");
|
||||
assert.equal(detectHostContract({ id: "x" }), "unknown");
|
||||
assert.equal(detectHostContract({ id: "x", api: {}, package: "" }), "unknown");
|
||||
assert.equal(detectHostContract(undefined), "unknown");
|
||||
assert.equal(detectHostContract("nope"), "unknown");
|
||||
});
|
||||
|
||||
it("publishes the legacy fields for every contract but the sdk one", () => {
|
||||
assert.equal(emitsLegacyFields("legacy-package"), true);
|
||||
assert.equal(emitsLegacyFields("unknown"), true);
|
||||
assert.equal(emitsLegacyFields("sdk-api"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy-package host (cli 0.0.0-beta-17823)", () => {
|
||||
it("publishes package and settings.baseURL on the provider", async () => {
|
||||
const { provider } = await publish("legacy");
|
||||
assert.equal(provider.api.type, "aisdk");
|
||||
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(provider.settings.baseURL, "https://gw.example.com/v1");
|
||||
});
|
||||
|
||||
it("publishes package, settings.baseURL and headers on the model", async () => {
|
||||
const { model } = await publish("legacy");
|
||||
if (model.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
assert.equal(model.package, `aisdk:${model.api.package}`);
|
||||
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(model.settings.baseURL, model.api.url);
|
||||
assert.deepEqual(model.headers, model.request.headers);
|
||||
});
|
||||
|
||||
it("publishes each variant in both shapes", async () => {
|
||||
const { model } = await publish("legacy");
|
||||
const variants = model.variants as BinaryCompatVariant[];
|
||||
assert.deepEqual(
|
||||
variants.map((v) => v.id),
|
||||
["low", "high"]
|
||||
);
|
||||
for (const variant of variants) {
|
||||
assert.deepEqual(variant.settings, { reasoningEffort: variant.id });
|
||||
// The pinned-contract shape stays intact next to the legacy one.
|
||||
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
|
||||
assert.deepEqual(variant.headers, {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdk-api host", () => {
|
||||
it("publishes the api block only, with no legacy field", async () => {
|
||||
const { provider, model } = await publish("sdk");
|
||||
assert.equal(provider.api.type, "aisdk");
|
||||
assert.equal("package" in provider, false);
|
||||
assert.equal("settings" in provider, false);
|
||||
assert.equal("package" in model, false);
|
||||
assert.equal("settings" in model, false);
|
||||
assert.equal("headers" in model, false);
|
||||
for (const variant of model.variants) {
|
||||
assert.equal("settings" in variant, false);
|
||||
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("undisclosed host contract", () => {
|
||||
it("falls back to the superset so an unknown host still routes", async () => {
|
||||
const { provider, model } = await publish("bare");
|
||||
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.ok(model.settings.baseURL);
|
||||
assert.ok(model.api);
|
||||
});
|
||||
});
|
||||
244
@omniroute/opencode-plugin-v2/tests/index.test.ts
Normal file
244
@omniroute/opencode-plugin-v2/tests/index.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
interface CapturedCall {
|
||||
kind: "catalog" | "integration";
|
||||
}
|
||||
|
||||
interface FakeCtx {
|
||||
options: Record<string, unknown>;
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
};
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCtx(options: Record<string, unknown>, seen: CapturedCall[]): FakeCtx {
|
||||
return {
|
||||
options,
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "catalog" });
|
||||
assert.equal(typeof cb, "function");
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "integration" });
|
||||
assert.equal(typeof cb, "function");
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin-v2 entrypoint", () => {
|
||||
it("boot line is silent by default, visible with startupDebug", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(args.map(String).join(" "));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(
|
||||
fakeCtx({ baseURL: "https://gw.example.com", providerId: "omniroute" }, seen)
|
||||
);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(
|
||||
!warns.some((line) => line.includes("init providerId=")),
|
||||
`boot line must stay silent by default, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
seen.map((s) => s.kind),
|
||||
["catalog", "integration"]
|
||||
);
|
||||
const seen2: CapturedCall[] = [];
|
||||
const warns2: string[] = [];
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns2.push(args.map(String).join(" "));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(
|
||||
fakeCtx(
|
||||
{ baseURL: "https://gw.example.com", providerId: "omniroute", startupDebug: true },
|
||||
seen2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(
|
||||
warns2.some((line) => line.includes("init providerId=omniroute")),
|
||||
`boot line must show with startupDebug, got: ${JSON.stringify(warns2)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("registers transforms synchronously: captures exist without awaiting fetch", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const ctx = fakeCtx({ baseURL: "https://gw.example.com" }, seen);
|
||||
const pending = (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(
|
||||
seen.map((s) => s.kind),
|
||||
["catalog", "integration"]
|
||||
);
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("declares key plus env methods and no oauth in the integration transform", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const integrationCallbacks: Array<(draft: unknown) => unknown> = [];
|
||||
const catalogCallbacks: Array<(draft: unknown) => unknown> = [];
|
||||
const ctx: FakeCtx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "catalog" });
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "integration" });
|
||||
integrationCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
};
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
assert.equal(integrationCallbacks.length, 1);
|
||||
|
||||
interface MethodUpdate {
|
||||
integrationID: string;
|
||||
method: { type: string; label?: string; names?: string[] };
|
||||
}
|
||||
interface FakeIntegrationDraft {
|
||||
updates: Array<{ id: string; name: string }>;
|
||||
methods: MethodUpdate[];
|
||||
update: (id: string, fn: (i: { name: string }) => void) => void;
|
||||
method: { update: (input: MethodUpdate) => void };
|
||||
}
|
||||
const draft: FakeIntegrationDraft = {
|
||||
updates: [],
|
||||
methods: [],
|
||||
update(id: string, fn: (i: { name: string }) => void) {
|
||||
const entry = { name: "" };
|
||||
fn(entry);
|
||||
this.updates.push({ id, name: entry.name });
|
||||
},
|
||||
method: {
|
||||
update(input: MethodUpdate) {
|
||||
draft.methods.push(input);
|
||||
},
|
||||
},
|
||||
};
|
||||
(integrationCallbacks[0] as (draft: FakeIntegrationDraft) => unknown)(draft);
|
||||
assert.deepEqual(draft.updates, [{ id: "omniroute", name: "OmniRoute" }]);
|
||||
const keyMethod = draft.methods.find((m) => m.method.type === "key");
|
||||
const envMethod = draft.methods.find((m) => m.method.type === "env");
|
||||
assert.ok(keyMethod);
|
||||
assert.equal(keyMethod?.integrationID, "omniroute");
|
||||
assert.ok(envMethod);
|
||||
assert.deepEqual(envMethod?.method.names, ["OMNIROUTE_API_KEY"]);
|
||||
assert.ok(!draft.methods.some((m) => m.method.type === "oauth"));
|
||||
});
|
||||
|
||||
it("lazy refresh: [m1] then [m1,m2] reloads once; identical runs never reload", async () => {
|
||||
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;
|
||||
let modelsCall = 0;
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (!href.includes("/v1/models")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
modelsCall += 1;
|
||||
const ids = modelsCall <= 1 ? [{ id: "m1" }] : [{ id: "m1" }, { id: "m2" }];
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ data: ids }) };
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
let reloads = 0;
|
||||
const ctx = {
|
||||
options: {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "lazy-reload",
|
||||
apiKey: "k-lazy",
|
||||
modelCacheTtlMs: 1,
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads += 1;
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const cb = catalogCallbacks[0] as (draft: unknown) => Promise<void>;
|
||||
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) => fn({}),
|
||||
},
|
||||
};
|
||||
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;
|
||||
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 cb(draft);
|
||||
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
|
||||
assert.equal(modelsCall, 3);
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
233
@omniroute/opencode-plugin-v2/tests/management-token.test.ts
Normal file
233
@omniroute/opencode-plugin-v2/tests/management-token.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
const MODELS_URL = "https://gw.example.com/v1/models";
|
||||
const COMBOS_URL = "https://gw.example.com/api/combos";
|
||||
|
||||
function silence() {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
return {
|
||||
warns,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(options: Record<string, unknown>, reload?: () => Promise<void>) {
|
||||
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 () => {} });
|
||||
},
|
||||
...(reload ? { reload } : {}),
|
||||
},
|
||||
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 entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
describe("plugin-v2 managementReadToken wiring (F1)", () => {
|
||||
it("combos fetch uses managementReadToken while models use apiKey", async () => {
|
||||
const seen = new Map<string, string>();
|
||||
const origFetch = globalThis.fetch;
|
||||
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 { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-key",
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.ok(published.has("omniroute/m1"));
|
||||
assert.equal(seen.get(COMBOS_URL), "Bearer mgmt-key");
|
||||
assert.equal(seen.get(MODELS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("combos fetch falls back to apiKey when managementReadToken is absent", async () => {
|
||||
const seen = new Map<string, string>();
|
||||
const origFetch = globalThis.fetch;
|
||||
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 { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(seen.get(COMBOS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (_p: string, _m: string, fn: (m: Record<string, unknown>) => void) => fn({}),
|
||||
},
|
||||
};
|
||||
const res = await publishCatalog(
|
||||
draft as never,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-key",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{
|
||||
fetcher: async (_baseURL, token) => {
|
||||
calls.push(["models", token]);
|
||||
return [{ id: "m1" }];
|
||||
},
|
||||
combosFetcher: async (_baseURL, token) => {
|
||||
calls.push(["combos", token]);
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.deepEqual(calls, [
|
||||
["models", "chat-key"],
|
||||
["combos", "mgmt-key"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-v2 fail-closed models (F2)", () => {
|
||||
it("empty models fetch on 2nd refresh keeps the last-known catalog", async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-f2-"));
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
let modelsCall = 0;
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
modelsCall += 1;
|
||||
if (modelsCall === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 500, statusText: "Error", json: async () => ({}) };
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "f2-keep",
|
||||
apiKey: "k-f2",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const first = stubDraft();
|
||||
await catalogCallbacks[0](first.draft);
|
||||
assert.ok(first.published.has("f2-keep/m1"), "first refresh must publish m1");
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
await sleep(5);
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0](second.draft);
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
assert.ok(
|
||||
second.published.has("f2-keep/m1"),
|
||||
"empty models fetch must reuse last-known catalog"
|
||||
);
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("keeping last-known catalog")),
|
||||
`expected keep-last-known warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
236
@omniroute/opencode-plugin-v2/tests/nested-combos.test.ts
Normal file
236
@omniroute/opencode-plugin-v2/tests/nested-combos.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
interface Captured {
|
||||
models: Map<string, ModelV2Info>;
|
||||
draft: CatalogDraft;
|
||||
warns: string[];
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
function fakeDraft(): Captured {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
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 CatalogDraft;
|
||||
return {
|
||||
models,
|
||||
draft,
|
||||
warns,
|
||||
restore: () => {
|
||||
console.warn = origWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
describe("catalog nested combo refs", () => {
|
||||
it("resolves a forward combo-ref: parent LCD follows the nested combo, not zero", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [
|
||||
{
|
||||
id: "a",
|
||||
context_length: 100000,
|
||||
max_output_tokens: 4000,
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
context_length: 50000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "parent",
|
||||
name: "Parent",
|
||||
models: [
|
||||
{ kind: "combo-ref", comboName: "Child" } as never,
|
||||
{ kind: "model", model: "a" } as never,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
name: "Child",
|
||||
models: [{ kind: "model", model: "b" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 2, combos: 2, autoCombos: 0 });
|
||||
const parent = c.models.get("omniroute/parent");
|
||||
assert.ok(parent);
|
||||
assert.equal(parent?.limit.context, 50000);
|
||||
assert.equal(parent?.limit.output, 2000);
|
||||
assert.equal(parent?.capabilities.tools, true);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops an unresolvable combo-ref after the fixpoint instead of claiming empty LCD", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "a" }],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "orphan",
|
||||
name: "Orphan",
|
||||
models: [{ kind: "combo-ref", comboName: "Missing" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.ok(!c.models.has("omniroute/orphan"));
|
||||
assert.ok(c.warns.some((w) => w.includes("could not resolve")));
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a two-level chain within the pass budget", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [
|
||||
{
|
||||
id: "a",
|
||||
context_length: 80000,
|
||||
max_output_tokens: 3000,
|
||||
capabilities: { tool_calling: true },
|
||||
},
|
||||
],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "top",
|
||||
name: "Top",
|
||||
models: [{ kind: "combo-ref", comboName: "Mid" } as never],
|
||||
},
|
||||
{
|
||||
id: "mid",
|
||||
name: "Mid",
|
||||
models: [{ kind: "combo-ref", comboName: "Leaf" } as never],
|
||||
},
|
||||
{
|
||||
id: "leaf",
|
||||
name: "Leaf",
|
||||
models: [{ kind: "model", model: "a" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
const top = c.models.get("omniroute/top");
|
||||
assert.ok(top);
|
||||
assert.equal(top?.limit.context, 80000);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog collision dedupe", () => {
|
||||
it("warns once per key even across repeated publishes sharing collisionWarned", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const collisionWarned = new Set<string>();
|
||||
const args = {
|
||||
fetcher: async () => [{ id: "dupe" }],
|
||||
combosFetcher: async () => [
|
||||
{ id: "dupe", name: "Dupe Combo", models: [{ kind: "model", model: "dupe" } as never] },
|
||||
],
|
||||
};
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
const hits = c.warns.filter((w) => w.includes("collides with a model id"));
|
||||
assert.equal(hits.length, 1);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses the warning for the intentional dedup pattern", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
// v1 pattern: /v1/models pre-mirrors the combo under its friendly
|
||||
// NAME and the combo is keyed by that name, so the overwritten model
|
||||
// id equals the combo name exactly and the draft key collides.
|
||||
const res = await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "omniroute/Mirror Combo" }],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "Mirror Combo",
|
||||
name: "Mirror Combo",
|
||||
models: [{ kind: "model", model: "omniroute/Mirror Combo" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
|
||||
assert.ok(!c.warns.some((w) => w.includes("collides")));
|
||||
assert.equal(c.models.get("omniroute/Mirror Combo")?.name, "Mirror Combo");
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shares the dedupe set when the caller passes collisionWarned", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const collisionWarned = new Set<string>();
|
||||
const args = {
|
||||
fetcher: async () => [{ id: "other" }],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "other",
|
||||
name: "Other Combo",
|
||||
models: [{ kind: "model", model: "other" } as never],
|
||||
},
|
||||
],
|
||||
};
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
const hits = c.warns.filter((w) => w.includes("collides with a model id"));
|
||||
assert.equal(hits.length, 1);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
129
@omniroute/opencode-plugin-v2/tests/options.test.ts
Normal file
129
@omniroute/opencode-plugin-v2/tests/options.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
integrationIdFor,
|
||||
parsePluginOptions,
|
||||
PLUGIN_ID,
|
||||
providerIdFor,
|
||||
resolveTimeouts,
|
||||
} from "../src/options.js";
|
||||
|
||||
describe("parsePluginOptions", () => {
|
||||
it("applies defaults for providerId, timeoutMs, usableOnly, enrichment", () => {
|
||||
const opts = parsePluginOptions({ baseURL: "https://gw.example.com" });
|
||||
assert.equal(opts.providerId, "omniroute");
|
||||
assert.equal(opts.timeoutMs, 10000);
|
||||
assert.equal(opts.usableOnly, false);
|
||||
assert.equal(opts.enrichment, true);
|
||||
assert.equal(opts.modelCacheTtlMs, undefined);
|
||||
});
|
||||
it("accepts a positive modelCacheTtlMs (in-memory TTL cache, default 300s)", () => {
|
||||
const opts = parsePluginOptions({ baseURL: "https://gw.example.com", modelCacheTtlMs: 60000 });
|
||||
assert.equal(opts.modelCacheTtlMs, 60000);
|
||||
});
|
||||
it("rejects a non-positive modelCacheTtlMs", () => {
|
||||
assert.throws(() =>
|
||||
parsePluginOptions({ baseURL: "https://gw.example.com", modelCacheTtlMs: 0 })
|
||||
);
|
||||
});
|
||||
it("requires baseURL", () => {
|
||||
assert.throws(() => parsePluginOptions({}), /baseURL/);
|
||||
});
|
||||
it("rejects unknown top-level keys (strict)", () => {
|
||||
assert.throws(() => parsePluginOptions({ baseURL: "https://gw.example.com", bogus: 1 }));
|
||||
});
|
||||
it("rejects unknown apiFormat keys (strict)", () => {
|
||||
assert.throws(() =>
|
||||
parsePluginOptions({
|
||||
baseURL: "https://gw.example.com",
|
||||
apiFormat: { bogus: ["claude"] },
|
||||
})
|
||||
);
|
||||
});
|
||||
it("accepts deprecated anthropicPrefixes (warn at resolve time, not parse time)", () => {
|
||||
const opts = parsePluginOptions({
|
||||
baseURL: "https://gw.example.com",
|
||||
apiFormat: { allowAnthropic: true, anthropicPrefixes: ["cc", "claude"] },
|
||||
});
|
||||
assert.deepEqual(opts.apiFormat, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: ["cc", "claude"],
|
||||
});
|
||||
});
|
||||
it("passes apiFormat allowlist through (shared enforces semantics)", () => {
|
||||
const opts = parsePluginOptions({
|
||||
baseURL: "https://gw.example.com",
|
||||
apiFormat: { allowAnthropic: true, anthropicModels: ["anthropic/claude-x"] },
|
||||
});
|
||||
assert.deepEqual(opts.apiFormat, {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity table", () => {
|
||||
it("maps providerId X to provider X and integration X, under one fixed plugin id", () => {
|
||||
assert.equal(providerIdFor("omniroute"), "omniroute");
|
||||
assert.equal(integrationIdFor("omniroute"), "omniroute");
|
||||
assert.equal(providerIdFor("second-gateway"), "second-gateway");
|
||||
// The host reads the plugin id before any option exists, so it never
|
||||
// varies with providerId.
|
||||
assert.equal(PLUGIN_ID, "omniroute-v2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid options say what to fix", () => {
|
||||
it("names an unknown key instead of dumping the validator output", () => {
|
||||
assert.throws(
|
||||
() => parsePluginOptions({ baseURL: "http://gw.example.com", modelCacheTtl: 300000 }),
|
||||
(err: Error) => {
|
||||
assert.match(err.message, /invalid plugin options/);
|
||||
assert.match(err.message, /unknown option "modelCacheTtl"/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("names the offending field for a wrong type", () => {
|
||||
assert.throws(
|
||||
() => parsePluginOptions({ baseURL: 42 }),
|
||||
(err: Error) => {
|
||||
assert.match(err.message, /baseURL/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the documented option names", () => {
|
||||
const parsed = parsePluginOptions({
|
||||
baseURL: "http://gw.example.com",
|
||||
modelCacheTtlMs: 300000,
|
||||
timeouts: { models: 15000, combos: 8000 },
|
||||
geminiSanitization: false,
|
||||
});
|
||||
assert.equal(parsed.modelCacheTtlMs, 300000);
|
||||
assert.equal(resolveTimeouts(parsed).models, 15000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerId is bounded because it reaches a filesystem path", () => {
|
||||
it("rejects a traversal attempt instead of writing outside the snapshot directory", () => {
|
||||
for (const bad of ["../../etc/cron.d/x", "a/b", "..", "."]) {
|
||||
assert.throws(
|
||||
() => parsePluginOptions({ baseURL: "https://gw.example.com", providerId: bad }),
|
||||
/invalid plugin options/,
|
||||
`providerId ${JSON.stringify(bad)} must be rejected`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the ids a user would actually pick", () => {
|
||||
for (const ok of ["omniroute", "omniroute-2", "gw.staging", "gw_prod"]) {
|
||||
assert.equal(
|
||||
parsePluginOptions({ baseURL: "https://gw.example.com", providerId: ok }).providerId,
|
||||
ok
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
224
@omniroute/opencode-plugin-v2/tests/parity.test.ts
Normal file
224
@omniroute/opencode-plugin-v2/tests/parity.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createReadStream } from "node:fs";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import {
|
||||
mapComboToModelV2 as sharedMapCombo,
|
||||
mapRawModelToModelV2 as sharedMapModel,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/shared/index.js";
|
||||
|
||||
interface Fixture {
|
||||
models: OmniRouteRawModelEntry[];
|
||||
combos: OmniRouteRawCombo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the v1 plugin produces for the same fixture, recorded in
|
||||
* `fixtures/v1-parity.json`. Running v1 here instead would mean importing its
|
||||
* build output from a sibling package: it only exists on a machine that has
|
||||
* built v1, so the check silently passed locally and could not run in CI at
|
||||
* all. Recording it makes the claim reviewable in the diff and reproducible
|
||||
* anywhere.
|
||||
*/
|
||||
interface V1Parity {
|
||||
v1PluginVersion: string;
|
||||
hookId: string;
|
||||
publishedKeys: string[];
|
||||
comboSlugs: string[];
|
||||
mappedModels: Record<string, unknown>;
|
||||
mappedCombos: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function loadFixture(): Promise<Fixture> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of createReadStream(new URL("./fixtures/catalog.json", import.meta.url))) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Fixture;
|
||||
}
|
||||
|
||||
async function loadV1Parity(): Promise<V1Parity> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of createReadStream(
|
||||
new URL("./fixtures/v1-parity.json", import.meta.url)
|
||||
)) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as V1Parity;
|
||||
}
|
||||
|
||||
type ApiAuth = { type: "api"; key: string };
|
||||
|
||||
function fakeDraft() {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
return {
|
||||
providers,
|
||||
models,
|
||||
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: () => {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_OPTS = {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "parity-key",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false as const,
|
||||
};
|
||||
|
||||
describe("v1-vs-v2 catalog parity", () => {
|
||||
it("same fixture models publish the same key set modulo documented exclusions", async () => {
|
||||
const fixture = await loadFixture();
|
||||
assert.equal(fixture.models.length, 5);
|
||||
assert.equal(fixture.combos.length, 2);
|
||||
|
||||
const fetcher: OmniRouteModelsFetcher = async () => fixture.models;
|
||||
const combosFetcher: OmniRouteCombosFetcher = async () => fixture.combos;
|
||||
|
||||
const recorded = await loadV1Parity();
|
||||
assert.equal(recorded.hookId, "opencode-omniroute", "v1 published under its own provider id");
|
||||
|
||||
const stripX = (k: string): string =>
|
||||
k.startsWith("omniroute/") ? k.slice("omniroute/".length) : k;
|
||||
const v1Keys = recorded.publishedKeys;
|
||||
|
||||
const draft = fakeDraft();
|
||||
const counts = await publishCatalog(draft, TEST_OPTS, { fetcher, combosFetcher });
|
||||
assert.equal(counts.models, 5);
|
||||
assert.equal(counts.combos, 2);
|
||||
assert.equal(counts.autoCombos, 0);
|
||||
|
||||
// Final converted ModelV2Info shape (legacy→info boundary in
|
||||
// src/catalog.ts assignModelFields): api resolves to the
|
||||
// openai-compatible AISDK block, capabilities fold tool_calling into
|
||||
// tools, cost is zeroed (pricing lives server-side).
|
||||
const mAlpha = draft.models.get("omniroute/m-alpha");
|
||||
assert.ok(mAlpha, "m-alpha published in v2 draft");
|
||||
assert.equal(mAlpha.api.type, "aisdk");
|
||||
if (mAlpha.api.type !== "aisdk") throw new Error("m-alpha api must be aisdk");
|
||||
assert.equal(mAlpha.api.package, "@ai-sdk/openai-compatible");
|
||||
assert.equal(mAlpha.capabilities.tools, true);
|
||||
assert.equal(mAlpha.cost[0].input, 0);
|
||||
|
||||
// Measured key shapes: v1 namespaces the friendly name
|
||||
// (`Combo Fast` -> `omniroute/combo-fast`) and keys the colliding combo
|
||||
// by its raw id (`good-combo` -> `omniroute/good-combo`, combo wins);
|
||||
// v2 publishes combo ids verbatim (`omniroute/combo-fast`,
|
||||
// `omniroute/good-combo` overwriting the raw model). v1 and v2 therefore
|
||||
// publish the SAME model keys (modulo the slashed-id exclusion above)
|
||||
// and the SAME non-colliding combo key; the colliding `good-combo` combo
|
||||
// overwrites the same-named raw model on BOTH sides (warn asserted below).
|
||||
const v1Combo = v1Keys.filter((k) => k.startsWith("combo-") || k === "good-combo").sort();
|
||||
const v2Combo = [...draft.models.keys()]
|
||||
.map(stripX)
|
||||
.filter((k) => k.startsWith("combo-") || k === "good-combo")
|
||||
.sort();
|
||||
// slug("Combo Fast") = "combo-fast", slug("Good Combo") = "good-combo".
|
||||
assert.deepEqual(v2Combo, ["combo-fast", "good-combo"]);
|
||||
assert.deepEqual(v1Combo, ["combo-fast", "good-combo"]);
|
||||
assert.deepEqual(v2Combo, v1Combo);
|
||||
assert.deepEqual(recorded.comboSlugs, ["combo-fast", "good-combo"]);
|
||||
const v1Models = v1Keys.filter((k) => !k.startsWith("combo-") && k !== "good-combo").sort();
|
||||
// Anchor v1 model keys as literals (modulo the slashed-id exclusion
|
||||
// documented above: the bare `cc/m-gamma` key fails the
|
||||
// `startsWith("omniroute/")` filter and is dropped here).
|
||||
assert.deepEqual(v1Models, ["local-delta", "m-alpha", "m-beta"]);
|
||||
const v2Models = [...draft.models.keys()]
|
||||
.map(stripX)
|
||||
.filter((k) => !k.includes("/") && !k.startsWith("combo-") && k !== "good-combo")
|
||||
.sort();
|
||||
assert.deepEqual(v2Models, v1Models);
|
||||
|
||||
// Collision: the combo whose friendly name collides with a raw model id
|
||||
// wins on both sides (v1 warns via injected logger, v2 via console.warn).
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const draft2 = fakeDraft();
|
||||
await publishCatalog(draft2, TEST_OPTS, { fetcher, combosFetcher });
|
||||
assert.ok(draft2.models.has("omniroute/good-combo"), "colliding combo key wins in v2");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("collides with a model id; combo wins")));
|
||||
|
||||
// Mapper-level parity for the slashed-id exclusion: v1 and shared mappers
|
||||
// must produce identical ModelV2 payloads for every fixture entry.
|
||||
// (No apiFormat on either side — the parity scope is key publication,
|
||||
// not the v1-prefix vs v2-allowlist routing rule covered by A2 tests.
|
||||
// The `cc/m-gamma` fixture entry is therefore EXCLUDED from the mapper
|
||||
// comparison: v1 routes it to anthropic via its default
|
||||
// anthropicPrefixes while shared/v2 leave it openai-compatible without
|
||||
// an explicit anthropicModels allowlist.)
|
||||
// Mapper parity for the slashed-id exclusion: shared must still produce
|
||||
// the payloads v1 produced. (`cc/m-gamma` is excluded: v1 routes it to
|
||||
// anthropic through its default prefix list, while v2 leaves it
|
||||
// openai-compatible without an explicit allowlist — covered by the
|
||||
// apiFormat tests.)
|
||||
for (const entry of fixture.models.filter((m) => m.id !== "cc/m-gamma")) {
|
||||
const expected = recorded.mappedModels[entry.id];
|
||||
assert.ok(expected, `v1 output recorded for ${entry.id}`);
|
||||
const viaShared = sharedMapModel(entry, {
|
||||
providerId: "omniroute",
|
||||
baseURL: TEST_OPTS.baseURL,
|
||||
});
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify({ ...viaShared, providerID: undefined })),
|
||||
JSON.parse(JSON.stringify({ ...(expected as object), providerID: undefined })),
|
||||
`model mapper parity for ${entry.id}`
|
||||
);
|
||||
}
|
||||
const byId = new Map(fixture.models.map((m) => [m.id, m]));
|
||||
for (const combo of fixture.combos) {
|
||||
const members = (combo.models ?? [])
|
||||
.filter((s) => s?.kind !== "combo-ref" && typeof s?.model === "string")
|
||||
.map((s) => byId.get(s.model as string))
|
||||
.filter((m): m is OmniRouteRawModelEntry => m !== undefined);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(sharedMapCombo(combo, members, "omniroute", TEST_OPTS.baseURL))),
|
||||
recorded.mappedCombos[combo.id],
|
||||
`combo mapper parity for ${combo.id}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fixture is rejected when it drifts from the 5+2 shape (guard against silent shrink)", async () => {
|
||||
const fixture = await loadFixture();
|
||||
assert.ok(fixture.models.length >= 5, "fixture must keep at least 5 models");
|
||||
assert.ok(fixture.combos.length >= 2, "fixture must keep at least 2 combos");
|
||||
});
|
||||
});
|
||||
|
||||
void (0 as unknown as ApiAuth);
|
||||
void (0 as unknown as CatalogDraft);
|
||||
137
@omniroute/opencode-plugin-v2/tests/publish-guard.test.ts
Normal file
137
@omniroute/opencode-plugin-v2/tests/publish-guard.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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";
|
||||
|
||||
// Guard around `publishCatalog` in the catalog transform: fetcher-level
|
||||
// fail-open covers fetch rejections, but a mapper throw or a host throw in
|
||||
// `draft.update` would reject the transform callback (unhandled rejection).
|
||||
// The guard must warn + resolve instead.
|
||||
describe("plugin-v2 publish guard (mapper/draft throws)", () => {
|
||||
function isolateDisk(): () => void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-guard-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
};
|
||||
}
|
||||
function setupCtx(): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute", apiKey: "k" },
|
||||
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 stubFetch(): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, warns };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
it("host throw in draft.model.update: callback resolves + warn, never rejects", async () => {
|
||||
const restoreDisk = isolateDisk();
|
||||
const { catalogCallbacks, ctx } = setupCtx();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch();
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: () => {
|
||||
throw new Error("host boom");
|
||||
},
|
||||
},
|
||||
};
|
||||
// MUST resolve — without the guard this rejects with "host boom".
|
||||
await catalogCallbacks[0](draft);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("catalog publish failed") && w.includes("host boom")),
|
||||
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("host throw in draft.provider.update: callback resolves + warn, never rejects", async () => {
|
||||
const restoreDisk = isolateDisk();
|
||||
const { catalogCallbacks, ctx } = setupCtx();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch();
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const draft = {
|
||||
provider: {
|
||||
update: () => {
|
||||
throw new Error("provider host boom");
|
||||
},
|
||||
},
|
||||
model: {
|
||||
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) =>
|
||||
fn({}),
|
||||
},
|
||||
};
|
||||
await catalogCallbacks[0](draft);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("catalog publish failed")),
|
||||
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
});
|
||||
214
@omniroute/opencode-plugin-v2/tests/refresh-failopen.test.ts
Normal file
214
@omniroute/opencode-plugin-v2/tests/refresh-failopen.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
// RED: reproduces the PROD unhandled rejection — combos 403 must not escape
|
||||
// the catalog transform. Today `loadSnapshot()` awaits
|
||||
// `Promise.all([models, combos])` with no catch, so a 403 combos fetch
|
||||
// rejects the snapshot promise and the rejection propagates out of the
|
||||
// `ctx.catalog.transform` callback (fail-open in `publishCatalog` is
|
||||
// bypassed because injected fetchers return the already-rejected data).
|
||||
describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
let diskSeq = 0;
|
||||
async function isolateDisk(): Promise<() => void> {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
diskSeq += 1;
|
||||
const dir = mkdtempSync(join(tmpdir(), `omniroute-fo-${diskSeq}-`));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
};
|
||||
}
|
||||
function setupCtx(opts: {
|
||||
combosStatus: number;
|
||||
modelsStatus?: number;
|
||||
reloads: { count: number };
|
||||
}): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "fo-" + String(opts.combosStatus) + "-" + String(opts.modelsStatus ?? 200),
|
||||
apiKey: "k-fo-" + String(opts.combosStatus),
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
opts.reloads.count += 1;
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
}
|
||||
|
||||
function stubFetch(opts: { combosStatus: number; modelsStatus?: number }): typeof fetch {
|
||||
const modelsStatus = opts.modelsStatus ?? 200;
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return {
|
||||
ok: opts.combosStatus === 200,
|
||||
status: opts.combosStatus,
|
||||
statusText: opts.combosStatus === 403 ? "Forbidden" : "Error",
|
||||
json: async () => ({ combos: [] }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: modelsStatus === 200,
|
||||
status: modelsStatus,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function stubDraft(): {
|
||||
draft: unknown;
|
||||
published: Map<string, Record<string, unknown>>;
|
||||
} {
|
||||
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 entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, warns };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
it("combos 403: catalog callback resolves (models-only + warn), never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 403, reloads });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ combosStatus: 403 });
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const { draft, published } = stubDraft();
|
||||
// MUST resolve — today it rejects with the 403 error.
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("combos") && w.includes("403")),
|
||||
`expected a combos 403 warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("combos 500: catalog callback resolves (models-only + warn), never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 500, reloads });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ combosStatus: 500 });
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("combos")),
|
||||
`expected a combos warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("combos timeout (abort): catalog callback resolves, never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 200, reloads });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
const err = new Error("This operation was aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("combos")),
|
||||
`expected a combos warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
|
||||
import { parsePluginOptions } from "../src/options.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 GW = "https://gw.example.com";
|
||||
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
|
||||
|
||||
describe("deprecated anthropicPrefixes", () => {
|
||||
it("routes a default prefix id to anthropic with a deprecation warning", async () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("cc/claude-x", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
|
||||
});
|
||||
|
||||
it("warns only once per process for the same prefix list", async () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
resolveApiBlockV2("cc/a", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
|
||||
resolveApiBlockV2("cc/b", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.filter((w) => w.includes("deprecated")).length, 1);
|
||||
});
|
||||
|
||||
it("documents the known claude/openai-compatible-model edge: prefix wins", async () => {
|
||||
// v1 parity keeps prefix matching, so a model literally named
|
||||
// `claude/openai-compatible-model` routes to anthropic when its prefix
|
||||
// is listed. Documented (not silently fixed): operators hitting this
|
||||
// rare name should move the id to the allowlist instead.
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("claude/openai-compatible-model", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
const mapped = mapRawModelToModelV2(
|
||||
{ id: "claude/openai-compatible-model" },
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
apiFormat: {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(mapped.api.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("prefix OR allowlist routes to anthropic; neither means openai-compatible", async () => {
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
assert.equal(
|
||||
resolveApiBlockV2("kiro/m", GW, { allowAnthropic: true, anthropicPrefixes: ["kiro"] }).id,
|
||||
"anthropic"
|
||||
);
|
||||
assert.equal(
|
||||
resolveApiBlockV2("anthropic/claude-x", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
}).id,
|
||||
"anthropic"
|
||||
);
|
||||
assert.equal(resolveApiBlockV2("gpt-x", GW).id, "openai-compatible");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("copied v1 config routes anthropic and warns deprecation through publishCatalog", async () => {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const parsed = parsePluginOptions({
|
||||
baseURL: GW,
|
||||
apiFormat: { allowAnthropic: true, anthropicPrefixes: [...PREFIXES, "v1copy"] },
|
||||
});
|
||||
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 CatalogDraft;
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
apiFormat: parsed.apiFormat,
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "cc/claude-x" }],
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
}
|
||||
);
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
const m = models.get("omniroute/cc/claude-x");
|
||||
assert.ok(m);
|
||||
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
assert.equal(m?.api.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
|
||||
});
|
||||
});
|
||||
196
@omniroute/opencode-plugin-v2/tests/shared-auto-combos.test.ts
Normal file
196
@omniroute/opencode-plugin-v2/tests/shared-auto-combos.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
defaultOmniRouteAutoCombosFetcher,
|
||||
mapAutoComboToModelV2,
|
||||
} from "../src/shared/auto-combos.js";
|
||||
|
||||
// A fetch stub that hangs until the caller aborts: proves the AbortController
|
||||
// wiring fires. Rejects with an AbortError like undici does on abort.
|
||||
function hangingFetch(): typeof fetch {
|
||||
return ((url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
});
|
||||
}) as Promise<Response>;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function stubFetch(res: { status: number; statusText?: string; body?: unknown }): typeof fetch {
|
||||
return (async () => ({
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
statusText: res.statusText ?? (res.status === 404 ? "Not Found" : "Error"),
|
||||
json: async () => res.body,
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function silenceWarn(): { warns: string[]; restore: () => void } {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
return {
|
||||
warns,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteAutoCombosFetcher", () => {
|
||||
it("404 returns [] with a warn (old gateway without the endpoint stays fail-open)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ status: 404 });
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
|
||||
assert.deepEqual(res, []);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("404")),
|
||||
`expected a 404 warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("500 throws (with warn + token-hint report) so the caller keeps last-known", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ status: 500, statusText: "Internal Server Error" });
|
||||
const guard = silenceWarn();
|
||||
const reported: string[] = [];
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000, undefined, (e, r) =>
|
||||
reported.push(`${e} ${r}`)
|
||||
),
|
||||
/HTTP 500/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("500")),
|
||||
`expected a 500 warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
assert.ok(
|
||||
reported.some((r) => r.includes("/api/combos/auto")),
|
||||
`expected a per-endpoint report, got: ${JSON.stringify(reported)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("a network throw rejects instead of resolving empty", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 20),
|
||||
/abort|aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto")),
|
||||
`expected an abort warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the {combos:[...]} envelope and filters entries without a string id", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({
|
||||
status: 200,
|
||||
body: { combos: [{ id: "auto/coding", name: "Auto Coding" }, { name: "no-id" }] },
|
||||
});
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
|
||||
assert.deepEqual(res, [{ id: "auto/coding", name: "Auto Coding" }]);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapAutoComboToModelV2", () => {
|
||||
it("falls back to 128k context / 8k output when the server omits limits (never 0)", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", name: "Auto Coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(m.id, "auto/coding");
|
||||
assert.equal(m.limit.context, 128_000);
|
||||
assert.equal(m.limit.output, 8_192);
|
||||
assert.equal(m.capabilities.toolcall, true);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
it("uses server limits when positive, falls back when zero or negative", () => {
|
||||
const served = mapAutoComboToModelV2(
|
||||
{ id: "auto", name: "Auto", context_length: 200000, max_output_tokens: 16000 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(served.limit.context, 200000);
|
||||
assert.equal(served.limit.output, 16000);
|
||||
const zeroed = mapAutoComboToModelV2(
|
||||
{ id: "auto", name: "Auto", context_length: 0, max_output_tokens: -1 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(zeroed.limit.context, 128_000);
|
||||
assert.equal(zeroed.limit.output, 8_192);
|
||||
});
|
||||
|
||||
it("defaults the model id to the auto variant key (auto, auto/coding)", () => {
|
||||
const def = mapAutoComboToModelV2({ id: "whatever" }, "omniroute", "https://gw.example.com");
|
||||
assert.equal(def.id, "auto");
|
||||
const coding = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(coding.id, "auto/coding");
|
||||
});
|
||||
|
||||
it("stamps the openai-compatible api block by default", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto", candidateCount: 3 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes an allowlisted auto id to anthropic (same rule as models)", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com",
|
||||
{ allowAnthropic: true, anthropicModels: ["auto/coding"] }
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapComboToModelV2 } from "../src/shared/combos-map.js";
|
||||
|
||||
const member = (id: string, caps = {}) => ({
|
||||
id,
|
||||
context_length: 100000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: caps,
|
||||
});
|
||||
|
||||
describe("mapComboToModelV2", () => {
|
||||
it("rolls up LCD: min context/output, AND of toolcall", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{
|
||||
id: "combo-a",
|
||||
name: "A",
|
||||
models: [
|
||||
{ kind: "model", model: "a" },
|
||||
{ kind: "model", model: "b" },
|
||||
],
|
||||
},
|
||||
[
|
||||
{
|
||||
...member("a"),
|
||||
context_length: 100000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: { tool_calling: true },
|
||||
},
|
||||
{
|
||||
...member("b"),
|
||||
context_length: 50000,
|
||||
max_output_tokens: 1000,
|
||||
capabilities: { tool_calling: false },
|
||||
},
|
||||
],
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(m.limit.context, 50000);
|
||||
assert.equal(m.limit.output, 1000);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
});
|
||||
it("empty members short-circuit to all-false capabilities", () => {
|
||||
const m = mapComboToModelV2({ id: "combo-empty" }, [], "omniroute", "https://gw.example.com");
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
});
|
||||
it("stamps the api block via resolveApiBlockV2 (openai-compatible by default)", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-a", models: [{ kind: "model", model: "a" }] },
|
||||
[{ ...member("a") }],
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
it("routes an allowlisted combo id to anthropic even with mixed members", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-a", models: [{ kind: "model", model: "gpt-x" }] },
|
||||
[{ ...member("gpt-x") }],
|
||||
"omniroute",
|
||||
"https://gw.example.com",
|
||||
{ allowAnthropic: true, anthropicModels: ["combo-a"] }
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
});
|
||||
47
@omniroute/opencode-plugin-v2/tests/shared-enrich.test.ts
Normal file
47
@omniroute/opencode-plugin-v2/tests/shared-enrich.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyEnrichment,
|
||||
buildCanonicalToAliasMap,
|
||||
canonicalDedupSet,
|
||||
lookupEnrichment,
|
||||
type OmniRouteEnrichmentMap,
|
||||
} from "../src/shared/enrich.js";
|
||||
import { mapRawModelToModelV2 } from "../src/shared/models-map.js";
|
||||
|
||||
const enrichment: OmniRouteEnrichmentMap = new Map([
|
||||
["cc/model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
|
||||
["model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
|
||||
]);
|
||||
|
||||
describe("lookupEnrichment", () => {
|
||||
it("resolves a canonical id via the alias fallback chain", () => {
|
||||
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
|
||||
assert.equal(canonicalToAlias.get("claude"), "cc");
|
||||
const found = lookupEnrichment("claude/model-x", enrichment, canonicalToAlias);
|
||||
assert.equal(found?.name, "Model X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyEnrichment", () => {
|
||||
it("overlays the enrichment display name onto the model", () => {
|
||||
const model = mapRawModelToModelV2(
|
||||
{ id: "cc/model-x" },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
applyEnrichment(model, { name: "Model X" });
|
||||
assert.equal(model.name, "Model X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalDedupSet", () => {
|
||||
it("drops the canonical twin when the alias row exists", () => {
|
||||
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
|
||||
const drop = canonicalDedupSet(
|
||||
[{ id: "cc/model-x" }, { id: "claude/model-x" }],
|
||||
canonicalToAlias
|
||||
);
|
||||
assert.ok(drop.has("claude/model-x"));
|
||||
assert.ok(!drop.has("cc/model-x"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
function stubFetch(handler: (url: string) => unknown): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const body = handler(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteEnrichmentFetcher", () => {
|
||||
it("merges display names and pricing from the two catalog sources", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch((url) =>
|
||||
url.endsWith("/api/pricing/models")
|
||||
? {
|
||||
cc: {
|
||||
id: "claude",
|
||||
alias: "cc",
|
||||
name: "Claude",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
}
|
||||
: url.endsWith("/api/pricing")
|
||||
? { cc: { m1: { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 } } }
|
||||
: { perModel: [] }
|
||||
);
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
const namespaced = map.get("cc/m1");
|
||||
assert.ok(namespaced);
|
||||
assert.equal(namespaced?.name, "Model One");
|
||||
assert.equal(namespaced?.providerAlias, "cc");
|
||||
assert.equal(namespaced?.providerCanonical, "claude");
|
||||
assert.deepEqual(namespaced?.pricing, {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cacheRead: 0.3,
|
||||
cacheWrite: 3.75,
|
||||
});
|
||||
assert.deepEqual(map.get("m1")?.pricing, namespaced?.pricing);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails per source: a dead pricing endpoint still returns catalog names", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.endsWith("/api/pricing")) throw new Error("boom");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] },
|
||||
}),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.equal(map.get("cc/m1")?.name, "Model One");
|
||||
assert.equal(map.get("cc/m1")?.pricing, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails per source: a dead catalog endpoint still returns pricing", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.endsWith("/api/pricing/models")) {
|
||||
return { ok: false, status: 500, statusText: "err", json: async () => ({}) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ cc: { m1: { input: 1, output: 2 } } }),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.deepEqual(map.get("cc/m1")?.pricing, { input: 1, output: 2 });
|
||||
assert.equal(map.get("cc/m1")?.name, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches free-tier budgets from the third source", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch((url) =>
|
||||
url.endsWith("/api/pricing/models")
|
||||
? { cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] } }
|
||||
: url.endsWith("/api/pricing")
|
||||
? {}
|
||||
: {
|
||||
perModel: [
|
||||
{ provider: "cc", modelId: "m1", freeType: "monthly", monthlyTokens: 100 },
|
||||
],
|
||||
}
|
||||
);
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.equal(map.get("cc/m1")?.freeType, "monthly");
|
||||
assert.equal(map.get("cc/m1")?.monthlyTokens, 100);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty map without credentials instead of throwing", async () => {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "", 1000);
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("enrichment source failures are reported", () => {
|
||||
it("names each endpoint a gateway refuses, then throws so the caller keeps last-known", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("forbidden", { status: 403 })) as unknown as typeof globalThis.fetch;
|
||||
const seen: Array<[string, string]> = [];
|
||||
await assert.rejects(
|
||||
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (endpoint, reason) =>
|
||||
seen.push([endpoint, reason])
|
||||
),
|
||||
/enrichment (catalog source|sources) failed/
|
||||
);
|
||||
assert.deepEqual(seen.map(([endpoint]) => endpoint).sort(), [
|
||||
"/api/free-tier/summary",
|
||||
"/api/pricing",
|
||||
"/api/pricing/models",
|
||||
]);
|
||||
for (const [, reason] of seen) assert.match(reason, /403/);
|
||||
});
|
||||
|
||||
it("reports a transport failure with its cause, then throws", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("connect ECONNREFUSED");
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
await assert.rejects(
|
||||
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (_e, reason) =>
|
||||
seen.push(reason)
|
||||
),
|
||||
/enrichment (catalog source|sources) failed/
|
||||
);
|
||||
assert.equal(seen.length, 3);
|
||||
for (const reason of seen) assert.match(reason, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it("stays silent when every source answers", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (e) =>
|
||||
seen.push(e)
|
||||
);
|
||||
assert.deepEqual(seen, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteModelsFetcher } from "../src/shared/models-map.js";
|
||||
import { defaultOmniRouteCombosFetcher } from "../src/shared/combos-map.js";
|
||||
|
||||
// A fetch stub that hangs until the caller aborts: proves the AbortController
|
||||
// wiring fires. Rejects with an AbortError like undici does on abort.
|
||||
function hangingFetch(): typeof fetch {
|
||||
return ((url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
});
|
||||
}) as Promise<Response>;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("shared default fetchers abort on timeout", () => {
|
||||
it("models fetcher aborts a hanging request after timeoutMs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteModelsFetcher("https://gw.example.com", "k", 20),
|
||||
/aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("combos fetcher aborts a hanging request after timeoutMs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteCombosFetcher("https://gw.example.com", "k", 20),
|
||||
/aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { catalogContentFingerprint, optionalTierFingerprint } from "../src/shared/fingerprint.js";
|
||||
|
||||
describe("catalogContentFingerprint", () => {
|
||||
it("returns the same hash for two identical catalogs", () => {
|
||||
const models = [
|
||||
{ id: "b", release_date: "2026-01-02" },
|
||||
{ id: "a", release_date: "2026-01-01" },
|
||||
];
|
||||
const combos = [{ id: "combo-b" }, { id: "combo-a" }];
|
||||
assert.equal(
|
||||
catalogContentFingerprint(models, combos),
|
||||
catalogContentFingerprint([...models].reverse(), [...combos].reverse())
|
||||
);
|
||||
});
|
||||
it("returns a different hash when one model id changes", () => {
|
||||
const combos = [{ id: "combo-a" }];
|
||||
const before = catalogContentFingerprint([{ id: "a" }], combos);
|
||||
const after = catalogContentFingerprint([{ id: "a2" }], combos);
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
it("returns a different hash when the auto-combos set changes", () => {
|
||||
const models = [{ id: "a" }];
|
||||
const combos = [{ id: "combo-a" }];
|
||||
const before = catalogContentFingerprint(models, combos, [{ id: "auto" }]);
|
||||
const after = catalogContentFingerprint(models, combos, [
|
||||
{ id: "auto" },
|
||||
{ id: "auto/coding" },
|
||||
]);
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionalTierFingerprint", () => {
|
||||
it("moves on a pricing-only change, so stale prices reach the picker", () => {
|
||||
const priced = (input: number) =>
|
||||
new Map([["cc/m1", { name: "M1", pricing: { input, output: 1 } }]]);
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], [], priced(3)),
|
||||
optionalTierFingerprint([], [], priced(99))
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a combo loses a member without changing id", () => {
|
||||
const one = [{ id: "combo-a", name: "A", models: [{ model: "m1" }] }];
|
||||
const two = [{ id: "combo-a", name: "A", models: [{ model: "m1" }, { model: "m2" }] }];
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], [], undefined, one),
|
||||
optionalTierFingerprint([], [], undefined, two)
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a provider goes quiet or gets renamed", () => {
|
||||
const active = [{ id: "c1", testStatus: "active", isActive: true }];
|
||||
const quiet = [{ id: "c1", testStatus: "active", isActive: false }];
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], active, undefined),
|
||||
optionalTierFingerprint([], quiet, undefined)
|
||||
);
|
||||
});
|
||||
});
|
||||
144
@omniroute/opencode-plugin-v2/tests/shared-gemini.test.ts
Normal file
144
@omniroute/opencode-plugin-v2/tests/shared-gemini.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isGeminiModelId, sanitizeToolInputSchemas } from "../src/shared/gemini.js";
|
||||
|
||||
describe("Gemini tool-schema sanitising", () => {
|
||||
it("recognises the routing forms a Gemini model arrives under", () => {
|
||||
for (const id of [
|
||||
"gemini-2.5-flash",
|
||||
"models/gemini-1.5-pro",
|
||||
"google-vertex/gemini-2.0",
|
||||
"GEMINI-PRO",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), true, id);
|
||||
}
|
||||
for (const id of ["gpt-5", "claude-opus-5", "", "gemma-2-9b"]) {
|
||||
assert.equal(isGeminiModelId(id), false, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips the keywords Gemini rejects, however deep they sit", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "edit",
|
||||
inputSchema: {
|
||||
$schema: "https://json-schema.org/draft-07/schema",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
nested: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: { deep: { type: "string" } },
|
||||
},
|
||||
list: { type: "array", items: [{ type: "object", additionalProperties: false }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const out = sanitizeToolInputSchemas(tools);
|
||||
assert.notEqual(out, undefined, "a payload that needed cleaning must come back changed");
|
||||
const schema = out![0]!.inputSchema as Record<string, any>;
|
||||
assert.equal("$schema" in schema, false);
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.equal("additionalProperties" in schema["properties"]["nested"], false);
|
||||
assert.equal("additionalProperties" in schema["properties"]["list"]["items"][0], false);
|
||||
// What the schema means must survive the cleaning.
|
||||
assert.equal(schema["properties"]["path"]["type"], "string");
|
||||
assert.equal(schema["properties"]["nested"]["properties"]["deep"]["type"], "string");
|
||||
});
|
||||
|
||||
it("leaves the caller's tools untouched", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "t",
|
||||
inputSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
];
|
||||
sanitizeToolInputSchemas(tools);
|
||||
assert.equal((tools[0]!.inputSchema as Record<string, unknown>)["additionalProperties"], false);
|
||||
});
|
||||
|
||||
it("reports nothing to do rather than cloning a clean payload", () => {
|
||||
assert.equal(
|
||||
sanitizeToolInputSchemas([
|
||||
{ type: "function" as const, name: "t", inputSchema: { type: "object", properties: {} } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
assert.equal(sanitizeToolInputSchemas(undefined), undefined);
|
||||
assert.equal(sanitizeToolInputSchemas([]), undefined);
|
||||
});
|
||||
|
||||
it("walks past a provider tool it cannot read without dropping it", () => {
|
||||
const tools = [
|
||||
{ type: "provider" as const, id: "p.search", name: "search", args: {} },
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "t",
|
||||
inputSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
];
|
||||
const out = sanitizeToolInputSchemas(tools as never);
|
||||
assert.equal(out?.length, 2);
|
||||
assert.deepEqual(out![0], tools[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sanitiser repairs schemas without mangling them", () => {
|
||||
it("keeps a tool parameter that happens to be named like a keyword", () => {
|
||||
const out = sanitizeToolInputSchemas([
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "checkout",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ref"],
|
||||
properties: {
|
||||
ref: { type: "string", description: "a git ref" },
|
||||
additionalProperties: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
const schema = out![0]!.inputSchema as Record<string, any>;
|
||||
// The keyword goes; the parameters keep their names, or `required` would
|
||||
// point at a property the model can no longer send.
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.deepEqual(Object.keys(schema["properties"]).sort(), ["additionalProperties", "ref"]);
|
||||
assert.deepEqual(schema["required"], ["ref"]);
|
||||
});
|
||||
|
||||
it("forwards a $ref schema untouched rather than widening it to anything", () => {
|
||||
assert.equal(
|
||||
sanitizeToolInputSchemas([
|
||||
{ type: "function" as const, name: "t", inputSchema: { $ref: "#/$defs/x" } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises the Gemini families, and only those", () => {
|
||||
for (const id of [
|
||||
"gemini",
|
||||
"gemini-2.5-flash",
|
||||
"models/gemini-1.5-pro",
|
||||
"google-vertex/gemini-2.0",
|
||||
"GEMINI-3-PRO",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), true, id);
|
||||
}
|
||||
for (const id of [
|
||||
"gemini-compatible-proxy",
|
||||
"my-gemini-wrapper",
|
||||
"openai/gpt-5-gemini-eval",
|
||||
"gemma-2-9b",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), false, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
130
@omniroute/opencode-plugin-v2/tests/shared-logger.test.ts
Normal file
130
@omniroute/opencode-plugin-v2/tests/shared-logger.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLogger, getLogLevel } from "../src/shared/logger.js";
|
||||
|
||||
function capture(): {
|
||||
warns: string[];
|
||||
errors: string[];
|
||||
restore: () => void;
|
||||
} {
|
||||
const warns: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(args.map(String).join(" "));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
return {
|
||||
warns,
|
||||
errors,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shared leveled logger (v1 parity)", () => {
|
||||
it("default level is warn", () => {
|
||||
assert.equal(getLogLevel(), "warn");
|
||||
});
|
||||
|
||||
it("warn logger emits error+warn, suppresses info+debug", () => {
|
||||
const log = createLogger("warn");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 1);
|
||||
});
|
||||
|
||||
it("error logger emits only error", () => {
|
||||
const log = createLogger("error");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 0);
|
||||
});
|
||||
|
||||
it("info logger emits error+warn+info, suppresses debug", () => {
|
||||
const log = createLogger("info");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 2);
|
||||
});
|
||||
|
||||
it("debug logger emits everything", () => {
|
||||
const log = createLogger("debug");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 3);
|
||||
});
|
||||
|
||||
it("lines carry the namespace tag and the uppercased level", () => {
|
||||
const log = createLogger("debug");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.warn("hello");
|
||||
log.error("boom");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.match(cap.warns[0], /\[omniroute-plugin\] \[WARN\] hello/);
|
||||
assert.match(cap.errors[0], /\[omniroute-plugin\] \[ERROR\] boom/);
|
||||
});
|
||||
|
||||
it("child loggers append their tag after the namespace", () => {
|
||||
const log = createLogger("debug").child("[v2]");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.info("hello");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.match(cap.warns[0], /\[omniroute-plugin\]\[v2\] \[INFO\] hello/);
|
||||
});
|
||||
|
||||
it("always() emits regardless of level", () => {
|
||||
const log = createLogger("error");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.always("breadcrumb");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.warns.length, 1);
|
||||
assert.match(cap.warns[0], /breadcrumb/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
|
||||
|
||||
describe("resolveApiBlockV2", () => {
|
||||
it("routes unknown ids to openai-compatible with /v1 suffix", () => {
|
||||
assert.deepEqual(resolveApiBlockV2("gpt-x", "https://gw.example.com"), {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
it("does not double the /v1 suffix", () => {
|
||||
assert.equal(
|
||||
resolveApiBlockV2("gpt-x", "https://gw.example.com/v1").url,
|
||||
"https://gw.example.com/v1"
|
||||
);
|
||||
});
|
||||
it("routes allowlisted full id to anthropic even with claude-like prefix elsewhere", () => {
|
||||
const out = resolveApiBlockV2("anthropic/claude-x", "https://gw.example.com/", {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
assert.deepEqual(out, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
it("keeps a non-allowlisted id on openai-compatible when prefixes are emptied", () => {
|
||||
const out = resolveApiBlockV2("claude/openai-compatible-model", "https://gw.example.com", {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
anthropicPrefixes: [],
|
||||
});
|
||||
assert.equal(out.id, "openai-compatible");
|
||||
});
|
||||
it("routes a deprecated-prefix id to anthropic with a deprecation warning", () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("cc/claude-x", "https://gw.example.com", {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: ["cc"],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRawModelToModelV2", () => {
|
||||
it("prefixes bare ids, keeps slashed ids, maps capabilities", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{
|
||||
id: "gpt-x",
|
||||
context_length: 128000,
|
||||
max_output_tokens: 4096,
|
||||
input_modalities: ["text", "image"],
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.equal(m.id, "omniroute/gpt-x");
|
||||
assert.equal(m.providerID, "omniroute");
|
||||
assert.equal(m.capabilities.toolcall, true);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
assert.equal(m.capabilities.input.image, true);
|
||||
assert.equal(m.limit.context, 128000);
|
||||
assert.equal(m.limit.output, 4096);
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
assert.equal(m.status, "active");
|
||||
});
|
||||
it("maps effort_tiers to variants, omits key when absent", () => {
|
||||
const withTiers = mapRawModelToModelV2(
|
||||
{ id: "r1", capabilities: { effort_tiers: ["low", "high"] } },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.deepEqual(withTiers.variants, {
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
});
|
||||
const without = mapRawModelToModelV2(
|
||||
{ id: "r2" },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.ok(!("variants" in without));
|
||||
});
|
||||
});
|
||||
75
@omniroute/opencode-plugin-v2/tests/shared-naming.test.ts
Normal file
75
@omniroute/opencode-plugin-v2/tests/shared-naming.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests for `formatFreeBudget` (@omniroute/opencode-plugin/src/naming.ts):
|
||||
* formats a free-tier model's budget info into a short human-readable
|
||||
* suffix, branching on `freeType`.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatFreeBudget, type FreeModelFreeType } from "../src/shared/naming.js";
|
||||
|
||||
test("formatFreeBudget: recurring-daily formats tokens/day", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 25_000_000 }),
|
||||
"25M tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-monthly formats tokens/month", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 1_000_000 }),
|
||||
"1M tokens/month"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-credit formats credits", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 10_000_000 }),
|
||||
"10M credits"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: one-time-initial formats credits with (one-time) suffix", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 1_000_000 }),
|
||||
"1M credits (one-time)"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: keyless has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "keyless" }), "(keyless)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: discontinued has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "discontinued" }), "(discontinued)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: missing token/credit counts default to 0", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "recurring-daily" }), "0 tokens/day");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: unrecognised freeType falls through to the default branch", () => {
|
||||
// `freeType` is populated from catalog data at runtime, so a value the
|
||||
// build doesn't know about is reachable even though TypeScript treats the
|
||||
// `default:` arm as dead code for a well-typed caller.
|
||||
assert.equal(formatFreeBudget({ freeType: "some-future-type" as FreeModelFreeType }), "");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: sub-1K token count is not abbreviated", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 500 }),
|
||||
"500 tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: the 999_999 rounding wart is fixed — promotes to 1M", () => {
|
||||
// `toFixed(1)` rounds 999999/1e3 up to "1000.0" before the `>= 1e6` threshold
|
||||
// check has a chance to apply. fmtTokens now promotes a rounded-up "1000" in
|
||||
// any unit to the next unit up, so this correctly reads "1M" instead of the
|
||||
// old "1000K" wart.
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 999_999 }),
|
||||
"1M tokens/day"
|
||||
);
|
||||
});
|
||||
248
@omniroute/opencode-plugin-v2/tests/shared-usable.test.ts
Normal file
248
@omniroute/opencode-plugin-v2/tests/shared-usable.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
defaultOmniRouteProvidersFetcher,
|
||||
isUsableCombo,
|
||||
isUsableRawModelId,
|
||||
usableProviderAliasSet,
|
||||
} from "../src/shared/usable.js";
|
||||
import type { OmniRouteEnrichmentMap } from "../src/shared/enrich.js";
|
||||
|
||||
function entry(
|
||||
alias: string,
|
||||
canonical: string
|
||||
): [string, { providerAlias: string; providerCanonical: string }] {
|
||||
return [`${alias}/model-x`, { providerAlias: alias, providerCanonical: canonical }];
|
||||
}
|
||||
|
||||
function enrichmentOf(...pairs: Array<[string, string]>): OmniRouteEnrichmentMap {
|
||||
return new Map(pairs.map(([alias, canonical]) => entry(alias, canonical)));
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: unknown) => unknown): typeof fetch {
|
||||
return (async (url: unknown) => handler(url)) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteProvidersFetcher envelopes", () => {
|
||||
it("reads the { connections: [...] } envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
globalThis.fetch = stubFetch((url) => {
|
||||
seen.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
connections: [{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
}),
|
||||
};
|
||||
});
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com/v1/", "k");
|
||||
assert.equal(seen[0], "https://gw.example.com/api/providers");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["claude"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a bare array envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => [{ id: "c1", provider: "gemini", isActive: true, testStatus: "active" }],
|
||||
}));
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["gemini"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the { data: [...] } envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
data: [{ id: "c1", provider: "kiro", isActive: true, testStatus: "active" }],
|
||||
}),
|
||||
}));
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["kiro"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on non-2xx so the caller keeps last-known instead of unfiltering", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Error",
|
||||
json: async () => ({}),
|
||||
}));
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"),
|
||||
/HTTP 500/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on network failure so the caller keeps last-known", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => {
|
||||
throw new Error("down");
|
||||
});
|
||||
try {
|
||||
await assert.rejects(defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"), /down/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails to [] without baseURL or apiKey", async () => {
|
||||
assert.deepEqual(await defaultOmniRouteProvidersFetcher("", "k"), []);
|
||||
assert.deepEqual(await defaultOmniRouteProvidersFetcher("https://gw.example.com", ""), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usableProviderAliasSet", () => {
|
||||
it("maps usable canonicals to aliases via enrichment", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"])
|
||||
);
|
||||
assert.ok(usable.aliases.has("cc"));
|
||||
assert.ok(usable.aliases.has("claude"));
|
||||
assert.ok(usable.canonicals.has("claude"));
|
||||
assert.ok(usable.knownAliases.has("cc"));
|
||||
});
|
||||
|
||||
it("ignores inactive or unhealthy connections", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[
|
||||
{ id: "c1", provider: "claude", isActive: false, testStatus: "active" },
|
||||
{ id: "c2", provider: "gemini", isActive: true, testStatus: "error" },
|
||||
],
|
||||
enrichmentOf(["cc", "claude"], ["gm", "gemini"])
|
||||
);
|
||||
assert.equal(usable.aliases.size, 0);
|
||||
assert.equal(usable.canonicals.size, 0);
|
||||
assert.ok(usable.knownAliases.has("cc"));
|
||||
assert.ok(usable.knownAliases.has("gm"));
|
||||
});
|
||||
|
||||
it("treats a missing toggle as no opinion, not disabled", () => {
|
||||
// Older gateways predate the `isActive` field: reading its absence as a
|
||||
// veto would hide every model behind a filter the operator never asked
|
||||
// to tighten. Only an explicit `false` disables.
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"])
|
||||
);
|
||||
assert.ok(usable.aliases.has("cc"));
|
||||
assert.ok(usable.canonicals.has("claude"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsableRawModelId subtract-filter", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
|
||||
);
|
||||
|
||||
it("keeps ids with a usable alias prefix", () => {
|
||||
assert.equal(isUsableRawModelId("cc/claude-opus-4-7", usable), true);
|
||||
});
|
||||
|
||||
it("keeps ids with a usable canonical prefix", () => {
|
||||
assert.equal(isUsableRawModelId("claude/sonnet-4", usable), true);
|
||||
});
|
||||
|
||||
it("keeps ids with an unknown prefix", () => {
|
||||
assert.equal(isUsableRawModelId("agentrouter/mystery", usable), true);
|
||||
});
|
||||
|
||||
it("drops ids with a known-but-not-provisioned prefix", () => {
|
||||
assert.equal(isUsableRawModelId("dead/legacy-model", usable), false);
|
||||
});
|
||||
|
||||
it("keeps ids without a prefix", () => {
|
||||
assert.equal(isUsableRawModelId("bare-model", usable), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsableCombo", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
|
||||
);
|
||||
|
||||
it("keeps a combo with one usable member", () => {
|
||||
assert.equal(
|
||||
isUsableCombo(
|
||||
{
|
||||
id: "c",
|
||||
models: [
|
||||
{ kind: "model", model: "dead/legacy" },
|
||||
{ kind: "model", model: "cc/x" },
|
||||
],
|
||||
},
|
||||
usable
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a combo whose members are all known-but-not-provisioned", () => {
|
||||
assert.equal(
|
||||
isUsableCombo(
|
||||
{
|
||||
id: "c",
|
||||
models: [
|
||||
{ kind: "model", model: "dead/a" },
|
||||
{ kind: "model", model: "dead/b" },
|
||||
],
|
||||
},
|
||||
usable
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a combo with an unknown-prefix member", () => {
|
||||
assert.equal(
|
||||
isUsableCombo({ id: "c", models: [{ kind: "model", model: "agentrouter/mystery" }] }, usable),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps combos with no resolvable members", () => {
|
||||
assert.equal(isUsableCombo({ id: "c", models: [] }, usable), true);
|
||||
assert.equal(isUsableCombo({ id: "c" }, usable), true);
|
||||
assert.equal(
|
||||
isUsableCombo({ id: "c", models: [{ kind: "combo-ref", comboName: "nested" }] }, usable),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
93
@omniroute/opencode-plugin-v2/tests/smoke-types.test.ts
Normal file
93
@omniroute/opencode-plugin-v2/tests/smoke-types.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft, PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
describe("v2 contract smoke", () => {
|
||||
it("default export has string id and function setup", () => {
|
||||
assert.equal(typeof (plugin as { id: unknown }).id, "string");
|
||||
assert.equal(typeof (plugin as { setup: unknown }).setup, "function");
|
||||
});
|
||||
it("setup registers transforms against a structurally-real ctx", async () => {
|
||||
const seen: string[] = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
|
||||
catalog: {
|
||||
transform: async () => {
|
||||
seen.push("catalog.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: {
|
||||
transform: async () => {
|
||||
seen.push("integration.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
connection: { active: async () => undefined, resolve: async () => undefined },
|
||||
},
|
||||
agent: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
command: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
reference: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
skill: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
aisdk: {
|
||||
sdk: async () => ({ dispose: async () => {} }),
|
||||
language: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
plugin: { add: async () => {}, remove: async () => {} },
|
||||
} satisfies PluginContext;
|
||||
await (plugin as { setup: (c: PluginContext) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(seen, ["catalog.transform", "integration.transform"]);
|
||||
});
|
||||
it("publishCatalog writes into a real CatalogDraft without proxy breakage", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const written: { provider?: string; models: string[] } = { models: [] };
|
||||
const draft: CatalogDraft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: () => undefined,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
written.provider = id;
|
||||
const p = {
|
||||
id,
|
||||
name: "",
|
||||
api: { type: "aisdk", package: "" },
|
||||
request: { headers: {}, body: {} },
|
||||
} as ProviderV2Info;
|
||||
fn(p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (providerID: string, modelID: string, fn: (d: ModelV2Info) => void) => {
|
||||
written.models.push(providerID + "/" + modelID);
|
||||
const d = { id: modelID, providerID } as ModelV2Info;
|
||||
fn(d);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
};
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{
|
||||
models: async () => [{ id: "m1", context_length: 1000 }],
|
||||
combos: async () => [],
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 1);
|
||||
assert.equal(written.provider, "omniroute");
|
||||
assert.deepEqual(written.models, ["omniroute/m1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import plugin from "../src/index.js";
|
||||
import { diskSnapshotPath, snapshotIdentityFingerprint } from "../src/cache.js";
|
||||
import { legacyApiToInfoApi } from "../src/catalog.js";
|
||||
|
||||
function isolateDisk(): { dir: string; restore: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-snapfix-"));
|
||||
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 setupCtx(providerId: string): {
|
||||
callbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: {
|
||||
providerId,
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-snapfix",
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
return { callbacks, ctx };
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
return {
|
||||
published,
|
||||
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 entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, warns };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
function downFetch(): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const fingerprint = snapshotIdentityFingerprint("https://gw.example.com", "k-snapfix", "k-snapfix");
|
||||
|
||||
describe("plugin-v2 snapshot stale-entry filter", () => {
|
||||
it("snapshot with 2 entries without api block + 1 valid: only the valid one is published + warn emitted", async () => {
|
||||
const disk = isolateDisk();
|
||||
const providerId = "snapfix-mixed";
|
||||
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
|
||||
writeFileSync(
|
||||
diskSnapshotPath(providerId),
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
identityFingerprint: fingerprint,
|
||||
// Two pre-mapped entries with a broken api block (missing npm) plus
|
||||
// one plain raw entry (no api block: synthesized at publish time).
|
||||
models: [
|
||||
{ id: "stale-a", api: {} },
|
||||
{ id: "stale-b", api: { npm: "" } },
|
||||
{ id: "good-1", context_length: 128000 },
|
||||
],
|
||||
combos: [],
|
||||
autoCombos: [],
|
||||
providers: [],
|
||||
writtenAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = downFetch();
|
||||
try {
|
||||
const { callbacks, ctx } = setupCtx(providerId);
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(
|
||||
published.has(`${providerId}/good-1`),
|
||||
`valid entry must be published, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
assert.ok(
|
||||
![...published.keys()].some((k) => k.includes("stale")),
|
||||
`stale entries must be dropped, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("dropping 2 stale snapshot entries without api block")),
|
||||
`expected stale-drop warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshot without version (v1 format): ignored entirely, fail-open to fresh fetch", async () => {
|
||||
const disk = isolateDisk();
|
||||
const providerId = "snapfix-unversioned";
|
||||
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
|
||||
writeFileSync(
|
||||
diskSnapshotPath(providerId),
|
||||
JSON.stringify({
|
||||
identityFingerprint: "whatever",
|
||||
rawModels: [{ id: "ancient" }],
|
||||
rawCombos: [],
|
||||
writtenAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "fresh-1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { callbacks, ctx } = setupCtx(providerId);
|
||||
await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(
|
||||
published.has(`${providerId}/fresh-1`),
|
||||
`fresh fetch must win over unversioned snapshot, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
assert.ok(
|
||||
![...published.keys()].some((k) => k.includes("ancient")),
|
||||
`unversioned snapshot must be ignored, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("legacyApiToInfoApi throws on missing api.npm (fail-fast, publish guard turns it into a warn)", () => {
|
||||
assert.throws(
|
||||
() => legacyApiToInfoApi(undefined as unknown as { id: string; npm: string; url: string }),
|
||||
/without an api block/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
legacyApiToInfoApi({ id: "openai-compatible", url: "https://x/v1" } as unknown as {
|
||||
id: string;
|
||||
npm: string;
|
||||
url: string;
|
||||
}),
|
||||
/without an api block/
|
||||
);
|
||||
// Sanity: sha256 helper used above matches the plugin identity scheme.
|
||||
assert.equal(createHash("sha256").update("x").digest("hex").length, 64);
|
||||
});
|
||||
});
|
||||
452
@omniroute/opencode-plugin-v2/tests/staged-refresh.test.ts
Normal file
452
@omniroute/opencode-plugin-v2/tests/staged-refresh.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
// A catalog that only appears once the slowest optional source has answered is
|
||||
// a catalog that never appears at all on a host that exits first: an
|
||||
// unreachable `/api/combos/auto` kept models, combos and everything else
|
||||
// unpublished until its own timeout fired. Models and combos must reach the
|
||||
// draft as soon as they are known; the optional sources upgrade the snapshot
|
||||
// when they land.
|
||||
describe("plugin-v2 staged refresh: optional sources never gate the publish", () => {
|
||||
let seq = 0;
|
||||
|
||||
async function isolateDisk(): Promise<() => void> {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
seq += 1;
|
||||
const dir = mkdtempSync(join(tmpdir(), `omniroute-staged-${seq}-`));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
};
|
||||
}
|
||||
|
||||
function setupCtx(
|
||||
providerId: string,
|
||||
reloads: { count: number }
|
||||
): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId, apiKey: "k-" + providerId },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads.count += 1;
|
||||
},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
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 entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/combos/auto` never answers and never honours the abort signal — the
|
||||
* shape of a gateway that accepts the connection and then goes quiet.
|
||||
*/
|
||||
function stubFetch(opts: {
|
||||
autoCombosHangs: boolean;
|
||||
combosHangs?: boolean;
|
||||
enrichmentDelayMs?: number;
|
||||
}): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
if (opts.autoCombosHangs) return await new Promise(() => {});
|
||||
return ok({ combos: [] });
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
// A gateway that accepts the connection and then goes quiet on the
|
||||
// combos endpoint: models must still publish without waiting for it.
|
||||
if (opts.combosHangs) return await new Promise(() => {});
|
||||
return ok({ combos: [] });
|
||||
}
|
||||
if (href.includes("/api/pricing/models")) {
|
||||
if (opts.enrichmentDelayMs !== undefined) {
|
||||
await new Promise((r) => setTimeout(r, opts.enrichmentDelayMs));
|
||||
}
|
||||
return ok({
|
||||
omni: {
|
||||
id: "omni",
|
||||
alias: "omni",
|
||||
name: "Omni",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/api/pricing")) return ok({});
|
||||
if (href.includes("/api/free-tier/summary")) return ok({});
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function withSilentConsole<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const warn = console.warn;
|
||||
const log = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
console.log = log;
|
||||
}
|
||||
}
|
||||
|
||||
it("publishes models while an optional source is still hanging", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: true });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-hang", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
const done = catalogCallbacks[0]!(draft);
|
||||
const raced = await Promise.race([
|
||||
done.then(() => "published" as const),
|
||||
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
|
||||
]);
|
||||
assert.equal(
|
||||
raced,
|
||||
"published",
|
||||
"the publish must not wait on a source that never answers"
|
||||
);
|
||||
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies an optional source that lands after the publish, on the next transform", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false, enrichmentDelayMs: 120 });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-late", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const first = stubDraft();
|
||||
await catalogCallbacks[0]!(first.draft);
|
||||
const early = [...first.published.values()].find((m) => m["id"] === "m1");
|
||||
assert.ok(early, "models publish before the slow enrichment");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0]!(second.draft);
|
||||
const late = [...second.published.values()].find((m) => m["id"] === "m1");
|
||||
// The overlay is rendered, not just stored: the provider label the
|
||||
// gateway ships alongside the display name reaches the picker.
|
||||
assert.equal(
|
||||
late?.["name"],
|
||||
"Omni - Model One",
|
||||
"the late enrichment must reach the catalog, provider tag included"
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not ask the host to reload when the overlay came back identical", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-stable", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = stubDraft();
|
||||
await catalogCallbacks[0]!(d.draft);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
});
|
||||
assert.ok(
|
||||
reloads.count <= 1,
|
||||
`an unchanged overlay must not trigger a reload per refresh, got ${reloads.count}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("a refresh after the TTL keeps the overlay instead of downgrading the picker", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
// Enrichment answers once, then goes away: the second refresh must not
|
||||
// strip the names the first one obtained.
|
||||
let enrichCalls = 0;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
if (href.includes("/api/pricing/models")) {
|
||||
enrichCalls += 1;
|
||||
if (enrichCalls > 1) {
|
||||
return { ok: false, status: 503, statusText: "Unavailable", json: async () => ({}) };
|
||||
}
|
||||
return ok({
|
||||
omni: {
|
||||
id: "omni",
|
||||
alias: "omni",
|
||||
name: "Omni",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/api/pricing")) return ok({});
|
||||
if (href.includes("/api/free-tier/summary")) return ok({});
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-ttl", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0]!(second.draft);
|
||||
const m1 = [...second.published.values()].find((m) => m["id"] === "m1");
|
||||
assert.equal(
|
||||
m1?.["name"],
|
||||
"Omni - Model One",
|
||||
"the second refresh must keep the name the first one resolved"
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes models while a hanging /api/combos holds nothing back", async () => {
|
||||
// Combos used to sit on the critical path (Promise.all with models), so a
|
||||
// gateway slow on /api/combos held the whole picker back. Regression pin:
|
||||
// models publish even when combos never answers.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false, combosHangs: true });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-combos-hang", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
const done = catalogCallbacks[0]!(draft);
|
||||
const raced = await Promise.race([
|
||||
done.then(() => "published" as const),
|
||||
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
|
||||
]);
|
||||
assert.equal(
|
||||
raced,
|
||||
"published",
|
||||
"models must publish without waiting for a hanging /api/combos"
|
||||
);
|
||||
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
|
||||
// "staged-combos-hang" contains "combo" as a substring — filter on the
|
||||
// model id suffix instead: no published model id may start with a
|
||||
// combo prefix.
|
||||
assert.equal(
|
||||
[...published.keys()].filter((k) => /\/combo/i.test(k)).length,
|
||||
0,
|
||||
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps names/pricing on the third transform when enrichment stays down", async () => {
|
||||
// The old all-empty guard is gone by design (it also blocked genuine
|
||||
// removals); the per-source failure signal replaces it. A persistent 503
|
||||
// on the overlay must keep last-known names on EVERY later transform,
|
||||
// not just the second one — this is the regression pin for the throw
|
||||
// instead of soft-fail change.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
let enrichCalls = 0;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
if (href.includes("/api/pricing/models")) {
|
||||
enrichCalls += 1;
|
||||
if (enrichCalls > 1) {
|
||||
return { ok: false, status: 503, statusText: "Unavailable", json: async () => ({}) };
|
||||
}
|
||||
return ok({
|
||||
omni: {
|
||||
id: "omni",
|
||||
alias: "omni",
|
||||
name: "Omni",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/api/pricing")) return ok({});
|
||||
if (href.includes("/api/free-tier/summary")) return ok({});
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-enrich-down", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const third = stubDraft();
|
||||
await catalogCallbacks[0]!(third.draft);
|
||||
const m1 = [...third.published.values()].find((m) => m["id"] === "m1");
|
||||
assert.equal(
|
||||
m1?.["name"],
|
||||
"Omni - Model One",
|
||||
`a persistently failing overlay must not wipe names, got ${JSON.stringify(m1?.["name"])}`
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("serves last-known without refetching inside the unreachable window", async () => {
|
||||
// A gateway that answers nothing at all gets a short breather instead of
|
||||
// a full fetch suite on every transform: the cooldown arms only once a
|
||||
// total failure is confirmed (no models AND a prior entry exists to
|
||||
// serve), and transforms inside the window must not issue new requests.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
let modelCalls = 0;
|
||||
let down = false;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) return ok({});
|
||||
modelCalls += 1;
|
||||
if (down) {
|
||||
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
|
||||
}
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-unreachable", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
// First transform: gateway healthy, entry stored.
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// Gateway goes down only now: the next transform fails totally while
|
||||
// a prior entry exists, arming the cooldown.
|
||||
down = true;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
const afterArming = modelCalls;
|
||||
assert.ok(afterArming >= 2, "the failing transform tries the network once");
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
assert.equal(modelCalls, afterArming, "a transform inside the cooldown must not refetch");
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on a synchronously refusing integration hook, catalog intact", async () => {
|
||||
// A host whose integration.transform throws while registering must cost
|
||||
// the plugin the connect action only — setup resolves, the catalog
|
||||
// callback is registered, and the throw is warned, not propagated.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false });
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "staged-integ", apiKey: "k" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => {
|
||||
throw new Error("host says no");
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1, "the catalog still registers");
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
});
|
||||
161
@omniroute/opencode-plugin-v2/tests/timeouts-logger.test.ts
Normal file
161
@omniroute/opencode-plugin-v2/tests/timeouts-logger.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parsePluginOptions, resolveTimeouts } from "../src/options.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";
|
||||
|
||||
function fakeDraft(): CatalogDraft {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
return {
|
||||
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 CatalogDraft;
|
||||
}
|
||||
|
||||
const BER = "https://gw.example.com";
|
||||
|
||||
describe("plugin-v2 P2 parity: per-endpoint timeouts", () => {
|
||||
it("timeoutMs default is 10s (v1 parity over the old 30s)", () => {
|
||||
const opts = parsePluginOptions({ baseURL: BER });
|
||||
assert.equal(opts.timeoutMs, 10000);
|
||||
});
|
||||
|
||||
it("endpoint timeouts default to the global timeout", () => {
|
||||
const opts = parsePluginOptions({ baseURL: BER });
|
||||
assert.deepEqual(resolveTimeouts(opts), {
|
||||
models: 10000,
|
||||
combos: 10000,
|
||||
autoCombos: 5000,
|
||||
enrichment: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it("endpoint overrides win over the global timeout", () => {
|
||||
const opts = parsePluginOptions({
|
||||
baseURL: BER,
|
||||
timeoutMs: 3000,
|
||||
timeouts: { models: 1111, combos: 2222, autoCombos: 3333, enrichment: 4444 },
|
||||
});
|
||||
assert.deepEqual(resolveTimeouts(opts), {
|
||||
models: 1111,
|
||||
combos: 2222,
|
||||
autoCombos: 3333,
|
||||
enrichment: 4444,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishCatalog forwards per-endpoint timeouts (slow models stub aborts at 20ms)", async () => {
|
||||
const seen: number[] = [];
|
||||
const t0 = Date.now();
|
||||
const slowModels = async (_base: string, _key: string, timeoutMs?: number) => {
|
||||
seen.push(timeoutMs ?? -1);
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
}, timeoutMs ?? 10000);
|
||||
});
|
||||
return [{ id: "m1" }];
|
||||
};
|
||||
const draft = fakeDraft();
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: BER,
|
||||
apiKey: "k",
|
||||
// A per-endpoint value must win over the global one: the 20ms
|
||||
// models budget is what this test asserts, not the 10s fallback.
|
||||
timeoutMs: 10000,
|
||||
timeouts: { models: 20, combos: 10000 },
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{ fetcher: slowModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.deepEqual(seen, [20]);
|
||||
assert.ok(Date.now() - t0 < 2000, "slow fetcher must abort near 20ms, not hang");
|
||||
});
|
||||
|
||||
it("logger option accepts logLevel plus startupDebug (default warn)", () => {
|
||||
assert.equal(parsePluginOptions({ baseURL: BER }).logLevel, undefined);
|
||||
assert.equal(parsePluginOptions({ baseURL: BER }).startupDebug, undefined);
|
||||
assert.equal(parsePluginOptions({ baseURL: BER, logLevel: "debug" }).logLevel, "debug");
|
||||
assert.equal(parsePluginOptions({ baseURL: BER, startupDebug: true }).startupDebug, true);
|
||||
assert.throws(() => parsePluginOptions({ baseURL: BER, logLevel: "verbose" }));
|
||||
});
|
||||
|
||||
it("startupDebug silences the boot line at default, shows it with startupDebug", async () => {
|
||||
const plugin = (await import("../src/index.js")).default as unknown as {
|
||||
setup: (ctx: unknown) => Promise<void>;
|
||||
};
|
||||
async function bootLogLines(options: Record<string, unknown>): Promise<string[]> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
const ctx = {
|
||||
options,
|
||||
catalog: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await plugin.setup(ctx);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
return warns;
|
||||
}
|
||||
const quiet = await bootLogLines({
|
||||
baseURL: BER,
|
||||
providerId: "p2-quiet",
|
||||
apiKey: "k",
|
||||
});
|
||||
assert.ok(
|
||||
!quiet.some((line) => line.includes("init providerId=")),
|
||||
`boot line must stay silent by default, got: ${JSON.stringify(quiet)}`
|
||||
);
|
||||
const loud = await bootLogLines({
|
||||
baseURL: BER,
|
||||
providerId: "p2-loud",
|
||||
apiKey: "k",
|
||||
startupDebug: true,
|
||||
});
|
||||
assert.ok(
|
||||
loud.some((line) => line.includes("init providerId=p2-loud")),
|
||||
`boot line must show with startupDebug, got: ${JSON.stringify(loud)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
222
@omniroute/opencode-plugin-v2/tests/usable-only.test.ts
Normal file
222
@omniroute/opencode-plugin-v2/tests/usable-only.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import { parsePluginOptions } from "../src/options.js";
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
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 CatalogDraft;
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
function silence() {
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
return {
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
},
|
||||
};
|
||||
}
|
||||
function enrichmentOf(...pairs: Array<[string, string]>) {
|
||||
return new Map(
|
||||
pairs.map(([alias, canonical]) => [
|
||||
`${alias}/model-x`,
|
||||
{ providerAlias: alias, providerCanonical: canonical },
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
};
|
||||
|
||||
describe("usableOnly option default", () => {
|
||||
it("defaults to false", () => {
|
||||
const opts = parsePluginOptions({ baseURL: "https://gw.example.com" });
|
||||
assert.equal(opts.usableOnly, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog usableOnly gating", () => {
|
||||
const stubModels = async () => [
|
||||
{ id: "cc/keep-me" },
|
||||
{ id: "dead/drop-me" },
|
||||
{ id: "agentrouter/mystery" },
|
||||
];
|
||||
|
||||
it("usableOnly=false performs zero providers fetches", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
let providersCalls = 0;
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, usableOnly: false },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
providersFetcher: async () => {
|
||||
providersCalls += 1;
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(providersCalls, 0);
|
||||
assert.equal(res.models, 3);
|
||||
assert.ok(models.has("omniroute/cc/keep-me"));
|
||||
assert.ok(models.has("omniroute/dead/drop-me"));
|
||||
});
|
||||
|
||||
it("usableOnly=true drops known-but-not-provisioned models, keeps unknown prefixes", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
...baseOpts,
|
||||
usableOnly: true,
|
||||
enrichment: enrichmentOf(["cc", "claude"], ["dead", "legacy"]),
|
||||
},
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
providersFetcher: async () => [
|
||||
{ id: "c1", provider: "claude", isActive: true, testStatus: "active" },
|
||||
],
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 2);
|
||||
assert.ok(models.has("omniroute/cc/keep-me"));
|
||||
assert.ok(!models.has("omniroute/dead/drop-me"));
|
||||
assert.ok(models.has("omniroute/agentrouter/mystery"));
|
||||
});
|
||||
|
||||
it("usableOnly=true with providers soft-fail keeps the full catalog", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, usableOnly: true },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
providersFetcher: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 3);
|
||||
assert.ok(models.has("omniroute/dead/drop-me"));
|
||||
});
|
||||
|
||||
it("usableOnly=true filters combos by member usability", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
...baseOpts,
|
||||
usableOnly: true,
|
||||
enrichment: enrichmentOf(["cc", "claude"], ["dead", "legacy"]),
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "cc/x" }, { id: "dead/y" }],
|
||||
combosFetcher: async () => [
|
||||
{ id: "good", name: "Good", models: [{ kind: "model", model: "cc/x" }] },
|
||||
{ id: "bad", name: "Bad", models: [{ kind: "model", model: "dead/y" }] },
|
||||
],
|
||||
providersFetcher: async () => [
|
||||
{ id: "c1", provider: "claude", isActive: true, testStatus: "active" },
|
||||
],
|
||||
}
|
||||
);
|
||||
assert.equal(res.combos, 1);
|
||||
assert.ok(models.has("omniroute/good"));
|
||||
assert.ok(!models.has("omniroute/bad"));
|
||||
});
|
||||
|
||||
it("usableOnly=false issues no providers request through setup (gating)", async () => {
|
||||
const seen: string[] = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
seen.push(href);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const plugin = (await import("../src/index.js")).default as unknown as {
|
||||
setup: (ctx: unknown) => Promise<void>;
|
||||
};
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
await plugin.setup({
|
||||
options: { baseURL: "https://gw.example.com", providerId: "usable-gate" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
});
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-usable-"));
|
||||
try {
|
||||
const { draft } = fakeDraft();
|
||||
await catalogCallbacks[0](draft as never);
|
||||
} finally {
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
}
|
||||
assert.ok(
|
||||
!seen.some((href) => href.includes("/api/providers")),
|
||||
`no providers fetch expected, got: ${JSON.stringify(seen)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
import { snapshotIdentityFingerprint, writeDiskSnapshot } from "../src/cache.js";
|
||||
|
||||
/**
|
||||
* The recommended setup stores the gateway key in the host's credential store,
|
||||
* so the key the plugin ends up using is not the one its options carry. The
|
||||
* disk snapshot is keyed by that credential: reading it before the credential
|
||||
* is resolved looks up the wrong identity and throws away a usable catalog —
|
||||
* exactly when it is needed, on a cold start against an unreachable gateway.
|
||||
*/
|
||||
describe("warm snapshot is read under the credential actually in use", () => {
|
||||
it("serves the snapshot written for the host credential, gateway down", async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-warm-id-"));
|
||||
const prevDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("gateway unreachable");
|
||||
}) as unknown as typeof fetch;
|
||||
const warn = console.warn;
|
||||
const log = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const baseURL = "https://gw.example.com";
|
||||
const hostKey = "key-from-the-host-store";
|
||||
await writeDiskSnapshot(
|
||||
"warmid",
|
||||
{
|
||||
models: [{ id: "m-snap" }],
|
||||
combos: [],
|
||||
autoCombos: [],
|
||||
providers: [],
|
||||
fetchedAt: Date.now(),
|
||||
} as never,
|
||||
snapshotIdentityFingerprint(baseURL, hostKey, hostKey)
|
||||
);
|
||||
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const ctx = {
|
||||
options: { baseURL, providerId: "warmid", apiKey: "key-written-in-the-config" },
|
||||
catalog: {
|
||||
transform: (cb: (d: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
return registration;
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: {
|
||||
transform: () => registration,
|
||||
connection: {
|
||||
active: async () => ({ type: "credential", id: "c", label: "l" }),
|
||||
resolve: async () => ({ type: "key", key: hostKey }),
|
||||
},
|
||||
},
|
||||
};
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const draft = {
|
||||
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const e: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(e);
|
||||
published.set(`${pid}/${mid}`, e);
|
||||
},
|
||||
},
|
||||
};
|
||||
await callbacks[0]!(draft);
|
||||
assert.ok(
|
||||
[...published.keys()].some((k) => k.endsWith("/m-snap")),
|
||||
`the snapshot must survive the credential switch, published: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
console.warn = warn;
|
||||
console.log = log;
|
||||
if (prevDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDir;
|
||||
}
|
||||
});
|
||||
});
|
||||
24
@omniroute/opencode-plugin-v2/tsconfig.json
Normal file
24
@omniroute/opencode-plugin-v2/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
16
@omniroute/opencode-plugin-v2/tsup.config.ts
Normal file
16
@omniroute/opencode-plugin-v2/tsup.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
splitting: false,
|
||||
treeshake: false,
|
||||
target: "node22",
|
||||
outDir: "dist",
|
||||
minify: false,
|
||||
cjsInterop: false,
|
||||
noExternal: ["zod"],
|
||||
});
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
AUTO_VARIANT_DESCRIPTIONS,
|
||||
type FreeModelFreeType,
|
||||
} from "./naming.js";
|
||||
import { applyOmniRouteInferenceTelemetry } from "./telemetry.js";
|
||||
|
||||
/**
|
||||
* Minimal leveled logger sink accepted by the default fetchers and the static
|
||||
@@ -1199,7 +1200,7 @@ export type OmniRouteModelsFetcher = (
|
||||
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000
|
||||
timeoutMs = 30_000
|
||||
) => {
|
||||
if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models");
|
||||
if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models");
|
||||
@@ -1221,9 +1222,12 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
const err = new Error(
|
||||
`@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`
|
||||
);
|
||||
) as Error & { statusCode: number; status: number };
|
||||
err.statusCode = res.status;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const rawList: unknown[] = Array.isArray(body)
|
||||
@@ -3477,7 +3481,7 @@ export function createOmniRouteProviderHook(
|
||||
|
||||
// ── Combo LCD across nested combo-refs (T-NN) ───────────────────────
|
||||
// Combos can nest other combos via `kind: "combo-ref"` members
|
||||
// (e.g. MASTER-LIGHT contains OldLLM, KIRO, Opecode Zen FREE). The
|
||||
// (e.g. MASTER-LIGHT contains LEGACY, KIRO, Opecode Zen FREE). The
|
||||
// nested combo's own `limit.context` is computed below in this same
|
||||
// loop, so we need a fixpoint iteration: if a combo-ref points at a
|
||||
// combo not yet processed, defer this combo and try again after the
|
||||
@@ -3766,6 +3770,8 @@ export function createOmniRouteFetchInterceptor(config: {
|
||||
baseOrigin = baseUrl.origin;
|
||||
const basePath = ensureV1Suffix(baseUrl.pathname);
|
||||
inferencePaths.add(`${basePath}/chat/completions`);
|
||||
inferencePaths.add(`${basePath}/responses`);
|
||||
inferencePaths.add(`${basePath}/messages`);
|
||||
inferencePaths.add(`${basePath}/models`);
|
||||
} catch {
|
||||
// Credential-attached base URLs are not schema-validated. A malformed
|
||||
@@ -3809,7 +3815,7 @@ export function createOmniRouteFetchInterceptor(config: {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return fetch(input, { ...init, headers });
|
||||
return applyOmniRouteInferenceTelemetry(await fetch(input, { ...init, headers }));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4495,7 +4501,7 @@ export function buildStaticProviderEntry(
|
||||
// ── Combo LCD across nested combo-refs (T-NN mirror) ─────────────────
|
||||
// Mirror of the dynamic-catalog fixpoint iteration: combos can nest
|
||||
// other combos via `kind: "combo-ref"` members (e.g. MASTER-LIGHT
|
||||
// contains OldLLM, KIRO, Opecode Zen FREE). The nested combo's own
|
||||
// contains LEGACY, KIRO, Opecode Zen FREE). The nested combo's own
|
||||
// capabilities and limits are computed in this same loop, so we need
|
||||
// a fixpoint pass: if a combo-ref points at a combo not yet processed,
|
||||
// defer this combo and try again after the sibling combos catch up.
|
||||
@@ -5398,7 +5404,7 @@ export function createOmniRouteConfigHook(
|
||||
// exact warn message so per-endpoint fallbacks are preserved.
|
||||
const doModels = async (): Promise<void> => {
|
||||
try {
|
||||
localRawModels = await fetcher(baseURL, apiKey, 10_000);
|
||||
localRawModels = await fetcher(baseURL, apiKey, 30_000);
|
||||
} catch (err) {
|
||||
logAt(
|
||||
"error",
|
||||
|
||||
249
@omniroute/opencode-plugin/src/telemetry.ts
Normal file
249
@omniroute/opencode-plugin/src/telemetry.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Map gateway-reported OmniRoute inference telemetry onto the JSON/SSE
|
||||
* payload OpenCode already consumes. Prefer headers / usage fields from the
|
||||
* gateway. Never invent tok/s from tokens / latency (that includes TTFT).
|
||||
*/
|
||||
export type OmniRouteInferenceTelemetry = {
|
||||
costUsd?: number;
|
||||
tokensIn?: number;
|
||||
tokensOut?: number;
|
||||
tokensPerSecond?: number;
|
||||
ttftMs?: number;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const HEADER = {
|
||||
cost: "x-omniroute-response-cost",
|
||||
tokensIn: "x-omniroute-tokens-in",
|
||||
tokensOut: "x-omniroute-tokens-out",
|
||||
tokensPerSecond: "x-omniroute-tokens-per-second",
|
||||
ttftMs: "x-omniroute-ttft-ms",
|
||||
latencyMs: "x-omniroute-latency-ms",
|
||||
model: "x-omniroute-model",
|
||||
provider: "x-omniroute-provider",
|
||||
} as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readFiniteNumber(raw: string | null): number | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readPositiveNumber(raw: string | null): number | undefined {
|
||||
const parsed = readFiniteNumber(raw);
|
||||
if (parsed === undefined || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readNonNegativeInt(raw: string | null): number | undefined {
|
||||
const parsed = readFiniteNumber(raw);
|
||||
if (parsed === undefined || parsed < 0) return undefined;
|
||||
return Math.round(parsed);
|
||||
}
|
||||
|
||||
function readToken(raw: string | null): string | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
export function parseOmniRouteInferenceTelemetry(headers: Headers): OmniRouteInferenceTelemetry {
|
||||
const out: OmniRouteInferenceTelemetry = {};
|
||||
const cost = readFiniteNumber(headers.get(HEADER.cost));
|
||||
if (cost !== undefined && cost >= 0) out.costUsd = cost;
|
||||
const tokensIn = readNonNegativeInt(headers.get(HEADER.tokensIn));
|
||||
if (tokensIn !== undefined) out.tokensIn = tokensIn;
|
||||
const tokensOut = readNonNegativeInt(headers.get(HEADER.tokensOut));
|
||||
if (tokensOut !== undefined) out.tokensOut = tokensOut;
|
||||
const tps = readPositiveNumber(headers.get(HEADER.tokensPerSecond));
|
||||
if (tps !== undefined) out.tokensPerSecond = tps;
|
||||
const ttft = readPositiveNumber(headers.get(HEADER.ttftMs));
|
||||
if (ttft !== undefined) out.ttftMs = ttft;
|
||||
const latency = readPositiveNumber(headers.get(HEADER.latencyMs));
|
||||
if (latency !== undefined) out.latencyMs = latency;
|
||||
const model = readToken(headers.get(HEADER.model));
|
||||
if (model) out.model = model;
|
||||
const provider = readToken(headers.get(HEADER.provider));
|
||||
if (provider) out.provider = provider;
|
||||
return out;
|
||||
}
|
||||
|
||||
function telemetryFromUsage(usage: Record<string, unknown>): OmniRouteInferenceTelemetry {
|
||||
const out: OmniRouteInferenceTelemetry = {};
|
||||
const tps = usage.tokens_per_second;
|
||||
if (typeof tps === "number" && Number.isFinite(tps) && tps > 0) {
|
||||
out.tokensPerSecond = tps;
|
||||
}
|
||||
const ttft = usage.ttft_ms;
|
||||
if (typeof ttft === "number" && Number.isFinite(ttft) && ttft > 0) {
|
||||
out.ttftMs = ttft;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeTelemetry(
|
||||
base: OmniRouteInferenceTelemetry,
|
||||
extra: OmniRouteInferenceTelemetry,
|
||||
): OmniRouteInferenceTelemetry {
|
||||
return {
|
||||
...base,
|
||||
...Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function isInferencePayload(payload: Record<string, unknown>): boolean {
|
||||
return (
|
||||
isRecord(payload.usage) ||
|
||||
Array.isArray(payload.choices) ||
|
||||
payload.object === "chat.completion" ||
|
||||
payload.object === "response" ||
|
||||
payload.type === "message" ||
|
||||
Array.isArray(payload.output)
|
||||
);
|
||||
}
|
||||
|
||||
function attachToUsage(
|
||||
usage: Record<string, unknown>,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...usage };
|
||||
if (
|
||||
telemetry.tokensPerSecond !== undefined &&
|
||||
(typeof next.tokens_per_second !== "number" || next.tokens_per_second <= 0)
|
||||
) {
|
||||
next.tokens_per_second = telemetry.tokensPerSecond;
|
||||
}
|
||||
if (telemetry.ttftMs !== undefined && (typeof next.ttft_ms !== "number" || next.ttft_ms <= 0)) {
|
||||
next.ttft_ms = telemetry.ttftMs;
|
||||
}
|
||||
if (telemetry.costUsd !== undefined && typeof next.cost !== "number") {
|
||||
next.cost = telemetry.costUsd;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function attachOmniRouteTelemetryToPayload(
|
||||
payload: unknown,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): unknown {
|
||||
if (!isRecord(payload) || !isInferencePayload(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const next: Record<string, unknown> = { ...payload };
|
||||
if (telemetry.model) {
|
||||
next.model = telemetry.model;
|
||||
}
|
||||
if (isRecord(next.usage)) {
|
||||
next.usage = attachToUsage(next.usage, mergeTelemetry(telemetry, telemetryFromUsage(next.usage)));
|
||||
}
|
||||
if (isRecord(next.response) && isRecord(next.response.usage)) {
|
||||
next.response = {
|
||||
...next.response,
|
||||
usage: attachToUsage(
|
||||
next.response.usage,
|
||||
mergeTelemetry(telemetry, telemetryFromUsage(next.response.usage)),
|
||||
),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function attachOmniRouteTelemetryToSseLine(
|
||||
line: string,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): string {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) {
|
||||
return line;
|
||||
}
|
||||
const jsonText = trimmed.slice("data:".length).trim();
|
||||
if (!jsonText.startsWith("{")) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as unknown;
|
||||
const updated = attachOmniRouteTelemetryToPayload(parsed, telemetry);
|
||||
if (updated === parsed) {
|
||||
return line;
|
||||
}
|
||||
const prefix = line.slice(0, line.indexOf(jsonText));
|
||||
const suffix = line.endsWith("\r") ? "\r" : "";
|
||||
return `${prefix}${JSON.stringify(updated)}${suffix}`;
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyOmniRouteInferenceTelemetry(response: Response): Promise<Response> {
|
||||
const telemetry = parseOmniRouteInferenceTelemetry(response.headers);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("text/event-stream") && response.body) {
|
||||
return new Response(mapSseBody(response.body, telemetry), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
if (!contentType.includes("json")) {
|
||||
return response;
|
||||
}
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
const next = attachOmniRouteTelemetryToPayload(parsed, telemetry);
|
||||
if (next === parsed) {
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(next), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
function mapSseBody(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let pending = "";
|
||||
let live = { ...telemetry };
|
||||
return body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
pending += decoder.decode(chunk, { stream: true });
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(`${attachOmniRouteTelemetryToSseLine(line, live)}\n`));
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (pending.length > 0) {
|
||||
controller.enqueue(encoder.encode(attachOmniRouteTelemetryToSseLine(pending, live)));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -641,13 +641,13 @@ test("models(): combos fetcher receives the resolved baseURL + apiKey", async ()
|
||||
|
||||
test("models(): nested combo-ref context is the min of nested + raw members", async () => {
|
||||
// Top-level combo MASTER-LIGHT has 1 raw model (claude-primary, 200k)
|
||||
// and 2 combo-refs: OldLLM (8k member) and KIRO (32k member). The OLD
|
||||
// and 2 combo-refs: LEGACY (8k member) and KIRO (32k member). The OLD
|
||||
// plugin would advertise 200k (only the raw model); the fix should
|
||||
// make it advertise 8k (the bottleneck across the member graph).
|
||||
const modelsFetcher = stubModelsFetcher([
|
||||
MODEL_PRIMARY,
|
||||
{
|
||||
id: "oldllm-member-1",
|
||||
id: "legacy-member-1",
|
||||
context_length: 8_000,
|
||||
max_output_tokens: 4_000,
|
||||
capabilities: {
|
||||
@@ -677,9 +677,9 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
]);
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "oldllm",
|
||||
name: "OldLLM",
|
||||
models: [{ id: "s1", kind: "model", model: "oldllm-member-1", weight: 100 }],
|
||||
id: "legacy",
|
||||
name: "LEGACY",
|
||||
models: [{ id: "s1", kind: "model", model: "legacy-member-1", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "kiro",
|
||||
@@ -691,7 +691,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
name: "MASTER-LIGHT",
|
||||
models: [
|
||||
{ id: "r1", kind: "model", model: "claude-primary", weight: 50 },
|
||||
{ id: "r2", kind: "combo-ref", comboName: "OldLLM", weight: 25 },
|
||||
{ id: "r2", kind: "combo-ref", comboName: "LEGACY", weight: 25 },
|
||||
{ id: "r3", kind: "combo-ref", comboName: "KIRO", weight: 25 },
|
||||
],
|
||||
},
|
||||
@@ -706,6 +706,6 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
8_000,
|
||||
`expected 8_000 (OldLLM bottleneck), got ${masterLight.limit.context}`
|
||||
`expected 8_000 (LEGACY bottleneck), got ${masterLight.limit.context}`
|
||||
);
|
||||
});
|
||||
|
||||
45
@omniroute/opencode-plugin/tests/models-fetcher.test.ts
Normal file
45
@omniroute/opencode-plugin/tests/models-fetcher.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteModelsFetcher } from "../src/index.js";
|
||||
|
||||
test("defaultOmniRouteModelsFetcher attaches statusCode on HTTP 401", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: "authentication expired" }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
const rec = err as Error & { statusCode?: number; status?: number };
|
||||
assert.equal(rec.statusCode, 401);
|
||||
assert.equal(rec.status, 401);
|
||||
assert.match(rec.message, /401/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteModelsFetcher default timeout is 30s", async () => {
|
||||
const original = globalThis.fetch;
|
||||
let signal: AbortSignal | undefined;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
return new Response(JSON.stringify({ object: "list", data: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key");
|
||||
assert.equal(signal instanceof AbortSignal, true);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
103
@omniroute/opencode-plugin/tests/telemetry.test.ts
Normal file
103
@omniroute/opencode-plugin/tests/telemetry.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyOmniRouteInferenceTelemetry,
|
||||
attachOmniRouteTelemetryToPayload,
|
||||
attachOmniRouteTelemetryToSseLine,
|
||||
parseOmniRouteInferenceTelemetry,
|
||||
} from "../src/telemetry.js";
|
||||
|
||||
test("parseOmniRouteInferenceTelemetry: copies cost, tokens, tok/s, winning model", () => {
|
||||
const headers = new Headers({
|
||||
"X-OmniRoute-Response-Cost": "0.0123",
|
||||
"X-OmniRoute-Tokens-In": "10",
|
||||
"X-OmniRoute-Tokens-Out": "200",
|
||||
"X-OmniRoute-Tokens-Per-Second": "100.5",
|
||||
"X-OmniRoute-Ttft-Ms": "300",
|
||||
"X-OmniRoute-Latency-Ms": "2300",
|
||||
"X-OmniRoute-Model": "winner-model",
|
||||
"X-OmniRoute-Provider": "openai",
|
||||
});
|
||||
const got = parseOmniRouteInferenceTelemetry(headers);
|
||||
assert.equal(got.costUsd, 0.0123);
|
||||
assert.equal(got.tokensIn, 10);
|
||||
assert.equal(got.tokensOut, 200);
|
||||
assert.equal(got.tokensPerSecond, 100.5);
|
||||
assert.equal(got.ttftMs, 300);
|
||||
assert.equal(got.model, "winner-model");
|
||||
assert.equal(got.provider, "openai");
|
||||
});
|
||||
|
||||
test("parseOmniRouteInferenceTelemetry: omits tok/s when header missing (do not invent from latency)", () => {
|
||||
const headers = new Headers({
|
||||
"X-OmniRoute-Tokens-Out": "200",
|
||||
"X-OmniRoute-Latency-Ms": "2000",
|
||||
});
|
||||
const got = parseOmniRouteInferenceTelemetry(headers);
|
||||
assert.equal(got.tokensPerSecond, undefined);
|
||||
assert.equal(got.tokensOut, 200);
|
||||
const payload = attachOmniRouteTelemetryToPayload(
|
||||
{ object: "chat.completion", usage: { prompt_tokens: 10, completion_tokens: 200 } },
|
||||
got,
|
||||
) as { usage: { tokens_per_second?: number } };
|
||||
assert.equal(payload.usage.tokens_per_second, undefined);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToPayload: writes usage.tokens_per_second and winning model", () => {
|
||||
const got = attachOmniRouteTelemetryToPayload(
|
||||
{
|
||||
object: "chat.completion",
|
||||
model: "combo/auto",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 200 },
|
||||
},
|
||||
{ tokensPerSecond: 80, ttftMs: 250, costUsd: 0, model: "gpt-winner" },
|
||||
) as {
|
||||
model: string;
|
||||
usage: { tokens_per_second: number; ttft_ms: number; cost: number };
|
||||
};
|
||||
assert.equal(got.model, "gpt-winner");
|
||||
assert.equal(got.usage.tokens_per_second, 80);
|
||||
assert.equal(got.usage.ttft_ms, 250);
|
||||
assert.equal(got.usage.cost, 0);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToPayload: does not mutate /v1/models catalog JSON", () => {
|
||||
const catalog = { object: "list", data: [{ id: "m1" }] };
|
||||
const got = attachOmniRouteTelemetryToPayload(catalog, {
|
||||
tokensPerSecond: 99,
|
||||
model: "should-not-apply",
|
||||
});
|
||||
assert.deepEqual(got, catalog);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToSseLine: patches terminal usage data line", () => {
|
||||
const line =
|
||||
'data: {"object":"chat.completion.chunk","usage":{"completion_tokens":200}}';
|
||||
const got = attachOmniRouteTelemetryToSseLine(line, { tokensPerSecond: 50 });
|
||||
assert.match(got, /"tokens_per_second":50/);
|
||||
assert.match(got, /^data: /);
|
||||
});
|
||||
|
||||
test("applyOmniRouteInferenceTelemetry: JSON response gets header tok/s", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
object: "chat.completion",
|
||||
model: "combo/auto",
|
||||
usage: { prompt_tokens: 1, completion_tokens: 20 },
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-OmniRoute-Tokens-Per-Second": "40",
|
||||
"X-OmniRoute-Model": "winner",
|
||||
},
|
||||
},
|
||||
);
|
||||
const next = await applyOmniRouteInferenceTelemetry(response);
|
||||
const body = JSON.parse(await next.text()) as {
|
||||
model: string;
|
||||
usage: { tokens_per_second: number };
|
||||
};
|
||||
assert.equal(body.model, "winner");
|
||||
assert.equal(body.usage.tokens_per_second, 40);
|
||||
});
|
||||
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 352 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 356 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 (167 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (169 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 |
|
||||
@@ -343,6 +343,7 @@ Documentation must describe verified behavior, not plausible behavior.
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
0. Check `docs/reference/REMOVED_PROVIDERS.md` first — providers removed at their operator's request must never be reintroduced (guarded by `tests/unit/removed-providers-blocklist.test.ts`)
|
||||
1. Register in `src/shared/constants/providers.ts` (Zod-validated at load)
|
||||
2. Add executor in `open-sse/executors/` if custom logic needed (extend `BaseExecutor`)
|
||||
3. Add translator in `open-sse/translator/` if non-OpenAI format
|
||||
|
||||
888
CHANGELOG.md
888
CHANGELOG.md
@@ -4,6 +4,7 @@
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(dashboard):** adaptive context-budget dial on the compression settings panel — mode (`off` / `floor` / `replace-autotrigger`) and policy (`reserve-output` / `percentage` / `absolute`) persist via `PUT /api/settings/compression` `contextBudget`. Completes the dashboard half of #7005 (API + DB already shipped in #7183).
|
||||
- **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting
|
||||
that hard-verifies every auto-combo candidate against live quota state and per-connection
|
||||
economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog
|
||||
@@ -91,16 +92,884 @@
|
||||
|
||||
## [3.8.51] — TBD
|
||||
|
||||
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
|
||||
_Living section — reconciled 2026-09-07 from all cycle commits (`release/v3.8.50` tip `091589089c` → `release/v3.8.51` tip `d6f315018a`, 696 non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each `/generate-release` phase._
|
||||
|
||||
### 📊 Release by the numbers
|
||||
|
||||
| | |
|
||||
| --- | ---: |
|
||||
| 👥 People who contributed | **120** |
|
||||
| 📝 Commits in the cycle | **696** |
|
||||
| 🔀 Pull requests referenced | **672** |
|
||||
| 📋 Changelog entries | **685** |
|
||||
| 🙌 Contributors credited in entries | **118** |
|
||||
| 🤖 Automated dependency commits | 18 |
|
||||
|
||||
**Entries by type**
|
||||
|
||||
| Type | Count |
|
||||
| --- | ---: |
|
||||
| 🐛 Fixes | 403 |
|
||||
| ✨ Features | 118 |
|
||||
| 🧹 Chore | 72 |
|
||||
| 📚 Docs | 36 |
|
||||
| 🧪 Tests | 29 |
|
||||
| ⚡ Performance | 7 |
|
||||
| ♻️ Refactor | 7 |
|
||||
| 🏗️ Build | 6 |
|
||||
| ⚙️ CI | 2 |
|
||||
| ⏪ Reverts | 2 |
|
||||
| 📦 Dependencies | 2 |
|
||||
| 🔒 Security | 1 |
|
||||
|
||||
### 🏆 Top 25 contributors this cycle
|
||||
|
||||
_By commits in `release/v3.8.50..release/v3.8.51` (`091589089c..d6f315018a`), author identities consolidated via `.mailmap` and the merged PR's GitHub login. Bots excluded._
|
||||
|
||||
| # | Contributor | Commits |
|
||||
| ---: | --- | ---: |
|
||||
| 🥇 | diegosouzapw | 240 |
|
||||
| 🥈 | Paco Cartones (@pacocartones) | 58 |
|
||||
| 🥉 | Bob.Hou (@HouMinXi) | 57 |
|
||||
| 4 | Dizzle (@maxmad64bis) | 27 |
|
||||
| 5 | Webman (@jonlwheat2-gif) | 22 |
|
||||
| 6 | Ravi Tharuma (@RaviTharuma) | 21 |
|
||||
| 7 | backryun | 17 |
|
||||
| 8 | Markus Hartung (@hartmark) | 17 |
|
||||
| 9 | Nguyen Thanh Dat (@ntdatt812) | 16 |
|
||||
| 10 | Rafa Martins (@rafacpti23) | 10 |
|
||||
| 11 | Syed Raheemuddin (@raheemuddin786) | 10 |
|
||||
| 12 | Koosha Paridehpour (@KooshaPari) | 9 |
|
||||
| 13 | Nguyễn Viết Tuấn (@TheDemonTuan) | 8 |
|
||||
| 14 | MumuTW | 7 |
|
||||
| 15 | Paijo (@oyi77) | 7 |
|
||||
| 16 | Tobias Andersen (@turbolego) | 6 |
|
||||
| 17 | Bl0ck (@Bl0ck154) | 5 |
|
||||
| 18 | SHANMUGAPRIYAN (@geek007git) | 5 |
|
||||
| 19 | KaspaPulse | 5 |
|
||||
| 20 | Abhishek Sharma (@abhisheksharma2411) | 4 |
|
||||
| 21 | Abhishek Divekar (@adivekar-utexas) | 4 |
|
||||
| 22 | Andrew B. (@AndrianBalanescu) | 4 |
|
||||
| 23 | NoxzRCW | 4 |
|
||||
| 24 | opensource-elearning | 4 |
|
||||
| 25 | Alvin T. Veroy (@alvinveroy) | 3 |
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(audio):** proxy native ElevenLabs voices, text-to-speech, and speech-to-text HTTP routes through stored OmniRoute credentials, preserving query strings, multipart uploads, binary responses, and upstream errors (#10556). ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
|
||||
- Added Google AI Studio Gemini batch text-to-speech support through `POST /v1/audio/speech`. ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
|
||||
- Run synchronous RTK and Caveman request compression in a bounded worker-thread pool, keeping large `/v1/responses` compression heaps outside the HTTP isolate while preserving strict fail-open behavior and per-engine telemetry. ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
|
||||
- **feat(routing):** subscription-first auto groupings — `auto/subscription` routes only through plan-included connections with a documented hard-stop overage and fails closed on exhaustion, while `auto/thrifty` orders the pool `subscription → keyless → free → cheap → premium` and steps up one rung at a time as each is exhausted. Billing class comes from a curated per-connection catalog (uncurated is treated as metered, never plan-included), both reuse STRICT_ZERO_COST's per-connection verification, and a quota reading whose `resetAt` has passed is now refreshed regardless of TTL so routing returns to plan capacity as soon as it resets ([#11146](https://github.com/diegosouzapw/OmniRoute/pull/11146)) — thanks @yourspraveen
|
||||
- **feat(providers):** publish a management-authenticated versioned web-session credential contract from OmniRoute's canonical browser credential metadata ([#11340](https://github.com/diegosouzapw/OmniRoute/pull/11340)) — thanks @Zartharas
|
||||
- **feat(video bridge):** harden the optional drill-down cache substrate with exact-path broker policy, canonical principal/session/media isolation, independent retained-byte quotas, cancellation-safe commits, rejection of excess or non-canonical Base64 padding and non-JPEG/truncated media, warning-sensitive full JPEG canonicalization that strips trailing polyglot bytes, server-derived dimensions, and auditable derivation metadata; production tenant binding and multi-resolution selection remain follow-up work ([#11369](https://github.com/diegosouzapw/OmniRoute/pull/11369)) ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
|
||||
- **feat(search):** Add Xquik X search with typed results, credential validation, REST routing, and MCP selection ([#11370](https://github.com/diegosouzapw/OmniRoute/pull/11370)) — thanks @kriptoburak
|
||||
- **feat(video):** add an opt-in focused analysis mode that safely uses a normalized, 500-code-point latest-user hint for task-aware frame captions while preserving full-mode prompts, temporal-window isolation, and cache identity without storing raw task text ([#11383](https://github.com/diegosouzapw/OmniRoute/pull/11383)). ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
|
||||
- **feat(dashboard):** surface durable exclusive managed leases in the existing Sessions view, keeping leased clients visible across idle gaps while marking connections with in-flight work as active ([#11389](https://github.com/diegosouzapw/OmniRoute/pull/11389)) — thanks @KaspaPulse
|
||||
- **build(bun):** allow Turbopack bundler flag on Bun 1.4+ with configurable Webpack fallback ([#11471](https://github.com/diegosouzapw/OmniRoute/pull/11471)) — thanks @TheDemonTuan
|
||||
- feat(api): add an opt-in `modelVisibilityAllowlist`/`modelVisibilityDenylist` settings pair to curate exactly which models `/v1/models` advertises, mirrored into every `auto/*` combo candidate pool so a denied model cannot be routed to via combo selection either (#11481) ([#11997](https://github.com/diegosouzapw/OmniRoute/pull/11997))
|
||||
- **feat(rankings):** the Free Provider Rankings page shows what each provider actually served over the last 24 h. It ranked by ELO alone, which left a provider that answers every call with an error in first place; the usage data was already served by the API but never requested. A provider with too small a sample shows a dash, not a number ([#11546](https://github.com/diegosouzapw/OmniRoute/pull/11546)) ([#11553](https://github.com/diegosouzapw/OmniRoute/pull/11553)) — thanks @maxmad64bis
|
||||
- **feat(guardrails):** enforce a bounded, deterministic contract for Video Bridge transcripts — 256 cues, 4096 input code units and 4 KiB UTF-8 per cue, 64 KiB total text, malformed-Unicode rejection, focus-window scoping, cross-source reconciliation with contributing-source metadata, and a structural provenance trust boundary so caller JSON can never self-assert `embedded`/`audio-bridge` provenance ([#11652](https://github.com/diegosouzapw/OmniRoute/issues/11652)) ([#12009](https://github.com/diegosouzapw/OmniRoute/pull/12009))
|
||||
- **feat(video):** orchestrate optional Video Bridge audio extraction and Audio Bridge STT behind a dual opt-in (operator setting AND per-request signal) — a new loopback-only broker `mode=audio` operation shares the frame path's exact process queue, deadline, AbortSignal, and byte budgets to extract a bounded mono 16 kHz PCM WAV from the same already-downloaded video, then reuses the existing Audio Bridge transcription boundary; provider segment timing is preserved when available and marked coarse otherwise, and every failure degrades to a visual-only-safe partial instead of throwing (#11654). ([#12012](https://github.com/diegosouzapw/OmniRoute/pull/12012))
|
||||
- Add a tenant-bound Video Bridge drill-down lifecycle on top of the existing secure cache substrate: opaque hashed handles (never raw session/video identifiers), preview/standard/detail multiresolution variants resampled on read, response pagination capped at 8 frames and 32 MiB, and a new authenticated `/api/v1/video-bridge/drilldown` consumer route that stays disabled for remote access by default and denies cross-key access with the same response as a nonexistent handle (no existence oracle). ([#12006](https://github.com/diegosouzapw/OmniRoute/pull/12006))
|
||||
- **test(video):** Add the Video Bridge FU-07/FU-09 promotion-evidence harness (#11656) — a frozen Zod manifest schema covering the 8 required scenario kinds (static scenes, rapid cuts, late facts, fades, blur, small text, close events, visual prompt injection) with a minimum of 3 repetitions per case, deterministic declarative fixture recipes (`videoBridgePromotionFixtures.ts`), a pure medians/p95 metrics aggregator, a pure FU-07/FU-09 promotion-verdict evaluator applying the ticket's exact thresholds (missing token usage always holds), a digest-only persistence layer that never retains raw media or raw model responses, and a versioned per-model promotion allowlist shipped empty with every model defaulting to `hold`. The FU-07/FU-09 promotion verdicts themselves remain HOLD — they require a real evidence run against real models on VPS 192.168.0.15. ([#12008](https://github.com/diegosouzapw/OmniRoute/pull/12008))
|
||||
- **feat(video bridge):** "embedded" transcript provenance can now be legitimately earned instead of merely asserted — a bounded, allowlisted (`mov_text`/`subrip`/`webvtt`) subtitle probe runs through the loopback-only Video Bridge broker (at most 2 streams, 10s subdeadline bounded by the request deadline, 256 KiB output, 4096-code-unit lines), normalized through a bounded, ReDoS-safe WebVTT parser and Zod-validated end to end. The adapter always resolves to an explicit `success`/`absent`/`transient_failure` outcome — a subtitle failure never breaks the visual description path, and only a fingerprint-verified broker response (never a caller-declared label) can produce embedded cues (#11659). ([#12011](https://github.com/diegosouzapw/OmniRoute/pull/12011))
|
||||
- Default new Antigravity-family connections (agy CLI imports and Antigravity OAuth connects) to model auto-sync, so live model discovery lands in the synced catalog and `/v1/models` picks up freshly released upstream models (e.g. Gemini 3.7 Flash tiers) without code changes. Existing connections keep their current setting; the per-connection dashboard toggle remains the opt-out. (#11685 — thanks @MumuTW) ([#11887](https://github.com/diegosouzapw/OmniRoute/pull/11887))
|
||||
- **feat(ui):** enable React Compiler (`reactCompiler: true` + `babel-plugin-react-compiler`) for automatic memoization at build time ([#11783](https://github.com/diegosouzapw/OmniRoute/pull/11783)) — thanks @jonlwheat2-gif
|
||||
- **feat(providers):** add SeekAi (`seekai.cc`) as an OpenAI-compatible New-API gateway — catalog id `seekai` (alias `ska`), `https://seekai.cc/v1`, live `/v1/models` via `passthroughModels`, aggregator-list membership so New-API balance detection can opt in. No referral/aff codes. ([#11786](https://github.com/diegosouzapw/OmniRoute/issues/11786)) ([#12557](https://github.com/diegosouzapw/OmniRoute/pull/12557)) — thanks @HouMinXi
|
||||
- **feat(zai):** add GLM-5.3-Flash Coding Plan support (1M context, 128K output, vision, `low|high|max` reasoning) and route `zai` GLM-5.3-family API-key traffic through the OpenAI-compatible Coding Plan endpoint with native thinking defaults ([#11801](https://github.com/diegosouzapw/OmniRoute/pull/11801)) — thanks @Neuron-Mr-White
|
||||
- **feat(combo):** choose how combo models are ordered — manual, provider, score, or name — via a sort control in the dashboard builder, persisted in `config.modelSort` and re-applied on load and after add ([#11812](https://github.com/diegosouzapw/OmniRoute/pull/11812)) — thanks @maxmad64bis
|
||||
- **feat(free):** custom models can be marked free-tier via `customModels[].isFree`; `isFreeModel()` is the first door and `hidePaidModels` respects it even for providers outside the free budget ([#11843](https://github.com/diegosouzapw/OmniRoute/pull/11843)) — thanks @maxmad64bis
|
||||
- **feat(nodejs):** add `5dive` as a `configure` target — `omniroute configure 5dive` / `omniroute setup-5dive` write a 5dive auth profile that points an agent fleet's `claude` seats at OmniRoute, with the root-only write, the loopback-vs-`https` endpoint rule and the per-seat model pin handled explicitly ([#11852](https://github.com/diegosouzapw/OmniRoute/pull/11852)) — thanks @5dive-bot
|
||||
- **feat(sse):** treat `max` as a first-class reasoning-effort tier and clamp per model family (GLM 5.1+/DeepSeek V4+/Kimi K3+ keep native `max`; o1/MiniMax/Grok/Muse Spark clamp to their upstream ceiling) ([#11875](https://github.com/diegosouzapw/OmniRoute/pull/11875)) — thanks @Chewji9875
|
||||
- **feat(providers):** the provider plugin manifest now advertises a `usage-fetch` capability for the 40 providers that have a wired usage/quota fetcher, so external dashboards can read it from `GET /api/v1/provider-plugin-manifest` instead of parsing `open-sse/services/usage.ts` after every release. Discovery only — no new fetcher, no quota change, and the Dashboard quota widget stays gated by `USAGE_SUPPORTED_PROVIDERS`. `USAGE_FETCHER_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/fetcherProviders.ts`) and is re-exported from `services/usage.ts`, keeping the manifest module a light leaf instead of pulling the ~490-module usage dispatcher into the manifest route. ([#11903](https://github.com/diegosouzapw/OmniRoute/pull/11903)) — thanks @maxmad64bis / @pacocartones
|
||||
- **feat(plugins):** `OMNIROUTE_PLUGINS_DIR` sets the directory the runtime plugin scanner reads — and the root the plugin manager installs into — overriding the `HOME`-derived default, so a Docker/K8s deployment can point straight at its bind-mounted plugin tree instead of moving `HOME` just to relocate the scan path. An image that exports no home no longer scans `/tmp/.omniroute/plugins` in silence: the resolved directory is logged once at startup as `scanner.dir_resolved`, naming the input that won. Unset, behaviour is unchanged. Distinct from the CLI-only `OMNIROUTE_PLUGIN_PATH`, which finds `omniroute-cmd-*` command packages and never reached this scanner ([#11906](https://github.com/diegosouzapw/OmniRoute/pull/11906)) — thanks @amaleta / @pacocartones
|
||||
- **feat(leases):** add an explicit owner-authenticated status action that returns only the active lease's privacy-safe configured connection and provider labels, with generation fencing and no credential or internal-id disclosure ([#11910](https://github.com/diegosouzapw/OmniRoute/pull/11910)) — thanks @KaspaPulse
|
||||
- **feat(providers):** import-from-file modal shows per-row API errors and ships a downloadable CSV template ([#12071](https://github.com/diegosouzapw/OmniRoute/issues/12071)) ([#12504](https://github.com/diegosouzapw/OmniRoute/pull/12504)) — thanks @HouMinXi
|
||||
- **feat(providers):** dashboard search matches connection name and `baseUrl` so imported OpenAI-compat nodes surface on the provider card ([#12108](https://github.com/diegosouzapw/OmniRoute/issues/12108)) ([#12495](https://github.com/diegosouzapw/OmniRoute/pull/12495)) — thanks @HouMinXi
|
||||
- **feat(routing):** add a `score` Auto router strategy that selects the highest configured weighted score and reuses `explorationRate`. ([#12155](https://github.com/diegosouzapw/OmniRoute/pull/12155)) — thanks @jacobsparts
|
||||
- Add a runtime feature flag to disable universal context handoffs globally without changing the default behavior. ([#12167](https://github.com/diegosouzapw/OmniRoute/pull/12167)) — thanks @jacobsparts
|
||||
- **feat(providers):** the provider plugin manifest now also advertises a `usage-supported` capability for the 46 providers whose usage API is accepted by the server and Dashboard routes, so integrators can distinguish "the server will serve quota for this provider" from "a fetcher is wired" without reading TypeScript. Discovery only — no fetcher or quota change. `usage-fetch` resolves on id or alias (the usage dispatcher accepts both); `usage-supported` resolves on id alone, matching the runtime guard `USAGE_SUPPORTED_PROVIDERS.includes(providerId)`. `USAGE_SUPPORTED_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/supportedProviders.ts`) and is re-exported from `providers.ts`, mirroring the `fetcherProviders` leaf from #11903 and keeping the manifest a light module. ([#12214](https://github.com/diegosouzapw/OmniRoute/pull/12214)) — thanks @maxmad64bis
|
||||
- **feat(rankings):** order Free Provider Rankings by what each provider actually served — `GET /api/free-provider-rankings?sortBy=reliability` and a "Most reliable first" toggle on the page. Providers with too few calls to state a success rate keep their score order below the measured ones; the default order is unchanged ([#12218](https://github.com/diegosouzapw/OmniRoute/pull/12218)). — thanks @maxmad64bis
|
||||
- **perf(sse):** defer `cloneLogPayload()` in the structured SSE collector until after the `maxEvents`/`maxBytes` cap check, eliminating ~9,800 wasted `structuredClone` calls per streaming response (65–71% faster `push()`). Reducer snapshot isolation restored for OpenAI and Responses summaries ([#12241](https://github.com/diegosouzapw/OmniRoute/pull/12241)) — thanks @PauloHSOliveira ([#12243](https://github.com/diegosouzapw/OmniRoute/pull/12243))
|
||||
- **feat(usage):** Devin CLI agentic quota (Codeium seat-management GetUserStatus) and OpenRouter key limits plus account credits now surface in Provider Limits ([#12256](https://github.com/diegosouzapw/OmniRoute/pull/12256) — thanks @Neuron-Mr-White)
|
||||
- **feat(settings):** persist `headroomUrl` through Settings so status/start use the operator URL instead of only `HEADROOM_URL` ([#12306](https://github.com/diegosouzapw/OmniRoute/issues/12306)) ([#12487](https://github.com/diegosouzapw/OmniRoute/pull/12487)) — thanks @HouMinXi
|
||||
- **feat(auto-combo):** Auto-Combo scoring can now weigh how often a provider/model has actually succeeded. The engine already carried that number on every candidate — 24 hours of usage history behind a ten-sample floor, real-time metrics otherwise — and the scoring function never read it, while the weight table described `stability` as if it did. `reliability` (`1 - failureRate`, with the same field precedence and the same rate-bounding the speed ranking already uses, so a corrupt reading means "nothing observed" rather than "fails every call") is now a declared factor shipping at weight `0`, so routing is unchanged until an operator gives it one, and the `stability` description now matches what that factor computes ([#12317](https://github.com/diegosouzapw/OmniRoute/pull/12317)) — thanks @maxmad64bis
|
||||
- **feat(routing):** With `freeAccessPolicy: "strict"`, the read-only candidate listing (`GET /v1/auto-combo/{channel}/candidates`) no longer hides the candidates the zero-cost guard excludes — the same read-only transparency the resilience filter already honours (#9133). Each candidate now carries `freeAccessExclusion` saying why it would be kept out, and it tells an exhausted allowance apart from a quota reading that never arrived or went stale, which used to look identical from the outside. Routing is unchanged: the listing reports, it never enforces. The separate `excludeTosAvoid` guard still drops its candidates without a reason; that gap is now documented rather than closed ([#12319](https://github.com/diegosouzapw/OmniRoute/pull/12319)) — thanks @maxmad64bis
|
||||
- **feat(radar):** The Radar catalog table now shows two facts it was already receiving from the feed and dropping on the floor: the per-model rate limits (requests and tokens, per minute and per day) in a new column, and a badge when a provider's terms state it may train on the prompts you send. A limit of zero renders as zero rather than "rate-only" — for a ceiling those are opposite facts — and a model with no training statement gets no badge, because an absent statement is not a guarantee ([#12320](https://github.com/diegosouzapw/OmniRoute/pull/12320)) — thanks @maxmad64bis
|
||||
- **feat(radar):** explain Community, single-use, contributor, supporter, recovery, abuse, offers, and privacy rules before either Radar activation action, and remove the superseded fixed-PR grant promise from every UI locale ([#12342](https://github.com/diegosouzapw/OmniRoute/pull/12342))
|
||||
- **feat(gamification):** the dashboard Profile page now shows the real daily streak — `/api/gamification/level` returns `streak: { current, longest }` (per key with `apiKeyId`, operator-wide maximum otherwise) and the streak card reads it instead of a hard-coded 0 (#2403) ([#12377](https://github.com/diegosouzapw/OmniRoute/pull/12377)) — thanks @pacocartones
|
||||
- **feat(gamification):** the dashboard leaderboard now shows each API key's display name under the Name column instead of a truncated key id; `GET /api/gamification/leaderboard` attaches `name` per entry (name only — no key material), while the shared ranking helper and the federation leaderboard stay id-only — thanks @pacocartones ([#12385](https://github.com/diegosouzapw/OmniRoute/pull/12385))
|
||||
- **feat(gamification):** enforce the documented 1000 XP/min per-API-key anti-cheat rate limit on the XP award path; over-limit awards are logged and skipped instead of persisted, and the sliding window now matches the timestamp format stored in `xp_audit_log` ([#2403](https://github.com/diegosouzapw/OmniRoute/issues/2403)) ([#12390](https://github.com/diegosouzapw/OmniRoute/pull/12390)) — thanks @pacocartones
|
||||
- **feat(admin):** localize the gamification anomalies page — the loading state, the Status column and the Suspicious badge now come from the `common` catalog (new `common.suspicious` key propagated to every locale) — add it to the Gamification sidebar group as `gamification-admin` (`/dashboard/gamification/admin`), and expose the loading and empty states as polite `role="status"` live regions (#12401 — thanks @pacocartones)
|
||||
- **feat(providers):** skip GitHub combo members missing from the live synced catalog, and drop Copilot models that are policy-disabled or hidden from the model picker ([#12473](https://github.com/diegosouzapw/OmniRoute/pull/12473)) — thanks @RaviTharuma
|
||||
- **feat(i18n):** locale-expansion tooling — `npm run i18n:add-locale` adds a language to config, dashboard, docs mirrors, CLI, README/indexes and site in one command; browser, CLI and cookie detection resolve `uk`, `fil`/`tl`, `zh-Hant` (and the retired legacy `in` → `id`) via config aliases; `config/i18n.json` now ships in the npm package so the published CLI can read it; new real-translation ratio gate (`npm run i18n:check-ratio`, advisory) with a per-locale ratchet baseline; `run-translation --adopt` restores `.i18n-state.json`; `sync-language-bars` generates 🌐 bars from config; `validate_translation.py` loads its allowlist again. Retires the duplicate `in` locale (Indonesian mislabelled as Hindi) — 42 honest locales; saved `NEXT_LOCALE=in` / `OMNIROUTE_LANG=in` keep working. (#12496)
|
||||
- **feat(api):** Emit gateway-measured `tokens_per_second` (TTFT excluded) on streaming usage and `X-OmniRoute-Tokens-Per-Second` when first-token latency is known ([#12616](https://github.com/diegosouzapw/OmniRoute/issues/12616)) ([#12631](https://github.com/diegosouzapw/OmniRoute/pull/12631)) — thanks @RaviTharuma
|
||||
- **feat(opencode-plugin): map gateway cost/usage/tok/s onto OpenCode inference payloads** — the official plugin copies `X-OmniRoute-Response-Cost`, token counts, `X-OmniRoute-Tokens-Per-Second` / `usage.tokens_per_second`, TTFT, and the winning `X-OmniRoute-Model` onto the JSON/SSE body OpenCode already consumes. Missing tok/s is left unset (never `tokens / latency`). (#12636) — thanks @RaviTharuma
|
||||
- **feat(combo):** add combo strategy `quota-weighted`: skip exhausted accounts, then weighted-draw by leftover / in-flight load; existing conversations stay pinned until the account is empty ([#12789](https://github.com/diegosouzapw/OmniRoute/pull/12789)) — thanks @HouMinXi
|
||||
- **feat(models):** Account live listings become the chat catalog source for Claude, Codex, Copilot, and AGY; public metadata only fills prices on IDs those accounts already list. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866)) — thanks @HouMinXi
|
||||
- **fix(models):** Union `agy` and `antigravity` live catalogs so `agy/gemini-3.8-flash-high` is not rejected after the prefix folds to `antigravity`. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866)) — thanks @HouMinXi
|
||||
- **feat(opencode):** Add `@omniroute/opencode-plugin-v2`, an opencode v2 plugin that gives a v2 install a route to an OmniRoute gateway: it publishes models, combos and auto-combos into the host catalog, refreshes them lazily behind a 300s TTL, and keeps serving the last known catalog from an on-disk snapshot when the gateway is unreachable. The v1 plugin (`@omniroute/opencode-plugin`) is untouched and keeps working as before. The plugin adapts to the host instead of assuming one shape: it reads the shape the host seeds into the catalog draft and publishes the `api` block, the pre-`api` top-level fields, or both. The gateway key comes from the host credential store when one is connected, so no secret needs to sit in a config file; a source the gateway refuses is named in a warning instead of silently degrading the catalog to raw model ids; and tool schemas bound for a Gemini model are stripped of the JSON-Schema keywords Gemini rejects, so tool calling survives that route ([#12870](https://github.com/diegosouzapw/OmniRoute/pull/12870)) — thanks @maxmad64bis
|
||||
- feat(services): show sanitized CLIProxyAPI account health from its authenticated management API without exposing credentials, file paths, or raw account metadata (#6342) ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
|
||||
- **feat(search):** Add AnySearch free web search + URL extract (webFetch) with typed results, credential validation, REST routing, and MCP selection - fallback-only ([#11690](https://github.com/diegosouzapw/OmniRoute/pull/11690)) — thanks @Xxx91n
|
||||
- **feat(dashboard):** display clamped `[0, 100]%` cached input token ratio in request logs table ([#11970](https://github.com/diegosouzapw/OmniRoute/pull/11970)) ([#11970](https://github.com/diegosouzapw/OmniRoute/pull/11970)) — thanks @b3nw
|
||||
- **feat(catalog):** add `OMNIROUTE_DISABLE_THINKING_LEVEL_VARIANTS` feature flag to optionally filter out thinking level variants from model catalog ([#11971](https://github.com/diegosouzapw/OmniRoute/pull/11971)) ([#11971](https://github.com/diegosouzapw/OmniRoute/pull/11971)) — thanks @b3nw
|
||||
- **feat(dashboard):** continuously export call logs to external analytics stores. A pluggable destination registry ships the full Logs-tab record set on an hourly `JobRegistry` cron, with a persisted per-destination cursor, batched inserts, a config UI rendered from each destination's own field descriptors, and a REST layer (`/api/log-export/*`) for CRUD, a connection test, and an on-demand run. A destination can opt into `includeBodies` to also ship the request and response payloads shown in the Logs detail pane, including the client and provider views of each call; this is off by default, and payloads inherit the dashboard's PII sanitisation, secret redaction and `noLog` handling. Google BigQuery is the first destination, using a service-account key stored encrypted at rest and streaming inserts keyed by call-log id, into a table that is day-partitioned on `timestamp` and clustered on `api_key_name`, `provider`, `model` and `status`. ([#11945](https://github.com/diegosouzapw/OmniRoute/pull/11945)) — thanks @dpozimski
|
||||
- **feat(providers):** add MaxAI as a signed, OpenAI-compatible provider serving its 13 paid chat models (GPT-5.6 / Luna / Thinking, Claude 5 Sonnet, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite, Grok 4.1-fast / 4.5, DeepSeek V3.2 / R1, Llama 3.3 70B) through OmniRoute's `/v1` endpoint, with per-request HMAC-SHA1→SM3→AES request signing, live model + context-window discovery from `/models/get_config`, and prompted tool-calling translated to OpenAI `tool_calls` ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
|
||||
- **feat(providers):** MaxAI vision input — image_url content parts are forwarded inline in `message_content` to the 6 vision-capable models (GPT-5.6 / Luna / Thinking, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite) ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
|
||||
- **feat(providers):** MaxAI image generation — 6 image models (gpt-image-1, dall-e-3, flux-1-schnell/dev/pro, sd3-medium) exposed through `POST /v1/images/generations` ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
|
||||
- **feat(providers):** MaxAI document RAG — inline base64 file/document attachments are uploaded to MaxAI (content-addressed `doc_id`) and attached to the chat via `doc_list` ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
|
||||
- **feat(providers):** browserless MaxAI onboarding — email device-pair login (`/api/providers/[id]/login`) and signed access-token refresh, so a connection can be created and kept fresh without a real browser or Google OAuth ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
|
||||
- **feat(providers):** per-provider TLS impersonation profile (MaxAI presents a Windows Firefox-150 client fingerprint) so its bot-sensitive endpoints accept OmniRoute traffic ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
|
||||
- **feat(providers):** add **Nimble** as a web-search and web-fetch provider (`nimble-search`) — `/v1/search` routes to Nimble's search API at `lite` depth (locale, freshness and include/exclude domain filters mapped onto the shared request contract), and `/v1/web/fetch` routes to Nimble Extract, which covers all four fetch formats (`markdown`, `html`, `links`, `screenshot`) from a single call. One API key serves both surfaces. ([#11620](https://github.com/diegosouzapw/OmniRoute/pull/11620)) — thanks @wildcard
|
||||
- The npm publish is automatic again, through npm Trusted Publishing (OIDC): the hosted `stage-npm` job publishes with a short-lived credential minted from GitHub's id-token — no `NPM_TOKEN`, no 2FA prompt, provenance attached. `publish_mode=staged` (owner approves with 2FA) and `direct` (token) remain available on `workflow_dispatch`. ([#11931](https://github.com/diegosouzapw/OmniRoute/pull/11931))
|
||||
- **feat(providers):** Add **Opper** as an API-key gateway provider — EU-hosted AI gateway with 700+ models from 30+ providers behind one OpenAI-compatible API and one key (`OPPER_API_KEY`); model ids use `provider/model` format (e.g. `anthropic/claude-sonnet-4-6`, `openai/gpt-5`); live model catalog at `https://api.opper.ai/v3/compat/models`; entry mirrors `requesty` (same shape, `passthroughModels: true`, no static seed) ([#11629](https://github.com/diegosouzapw/OmniRoute/pull/11629))
|
||||
- **feat(dashboard):** the `/dashboard/orchestration` snapshot hook now subscribes to the `agents` WebSocket channel (`agent.task.updated`) instead of `requests` as its refetch trigger, and relaxes its background poll from 5s to 30s while that WS connection is up — falling back to the tighter 5s cadence, reprogrammed live on any connect/disconnect transition, whenever the socket is down. ([#12409](https://github.com/diegosouzapw/OmniRoute/pull/12409))
|
||||
- **feat(dashboard):** Orchestration canvas quick wins — search box plus state/source/provider filter chips with a one-click clear, and per-source collapse/expand, all reflected in the URL so a filtered/collapsed view is shareable and survives a refresh; the detail drawer gained a "copy trace JSON" action and hardened error/empty-state and accessibility handling; the Agents-tab edges now animate traveling particles along active (running) connections; and the canvas node/edge status colors moved off fixed hex values onto theme-aware `--orch-status-*` CSS custom properties, so they adapt correctly to light/dark mode. ([#12393](https://github.com/diegosouzapw/OmniRoute/pull/12393))
|
||||
- **chore(dashboard):** Orchestration UI hardening pass and the missing component/model test coverage it called for — `OrchestratorNode`/`ActivityNode`/`OverflowNode` rendering, the `?node=`/overflow-click page routing, the Agents-tab orchestrator-click no-op and `showCompleted` toggle, and the overview kanban's done-column sort order (#12270, #12271). ([#12393](https://github.com/diegosouzapw/OmniRoute/pull/12393))
|
||||
- New `/dashboard/orchestration` page: live unified view of everything running — Cloud Agent, A2A and Conductor as a real-time graph (Agents tab), the combo cascade (Routing tab, reusing the Combo Live Studio) and a state kanban (Overview tab), with a detail drawer (trace, cost, approve/cancel). Read-only over existing APIs — no new backend. Canvas concept credit: PR #11815 design ([#12261](https://github.com/diegosouzapw/OmniRoute/pull/12261))
|
||||
- **feat(dashboard):** new "History" tab on `/dashboard/orchestration` — an Airflow-style grid of finished runs over a 24h/7d/30d preset window, one row per (source, identity), clicking a cell opens the existing detail drawer. It is backed by real persistence: A2A task lifecycle transitions are now written to the `a2a_tasks` table (purged after 30 days, configurable via `OMNIROUTE_A2A_HISTORY_RETENTION_DAYS`) and served by the new `GET /api/a2a/tasks/history` listing endpoint, with the task-detail route falling back to persisted history once a run leaves the in-memory snapshot. Conductor runs stay remote and are not persisted locally — the tab says so instead of silently omitting them. ([#12479](https://github.com/diegosouzapw/OmniRoute/pull/12479))
|
||||
- **feat(dashboard):** the orchestration detail drawer gained a "Repeat" action for Cloud Agent, A2A and Conductor tasks — a two-click confirm (click once to arm, click again within the confirm window to fire) re-submits the original prompt/input as a new run. The button is disabled with an explanatory tooltip whenever the original input can't be recovered from the loaded task detail (e.g. it never carried a prompt, or the detail failed to load). Two limitations of the A2A variant, by design: it targets the `/a2a` JSON-RPC endpoint, which authenticates with an API key only (`REQUIRE_API_KEY=true` or a configured `OMNIROUTE_API_KEY` makes a dashboard-session repeat answer `HTTP 400` — surfaced verbatim in the drawer's error line, never as a success), and the `message/send` call is SYNCHRONOUS: the POST blocks for the whole skill run, so the success confirmation only appears once the repeated task finishes. A dashboard-authenticated A2A creation path is deliberately left to a follow-up — widening the endpoint's auth posture is an operator decision, not a side effect of this feature. ([#12508](https://github.com/diegosouzapw/OmniRoute/pull/12508))
|
||||
- **feat(a2a):** A2A task execution now records which memories were consulted for the task's last user message as `metadata.memoryHits` (id/key/type/content-snippet) plus a `memory_hits` history event, purely for observability — the retrieved memory is never injected into a skill's prompt or behavior. Gated by the `OMNIROUTE_A2A_MEMORY_HITS` kill-switch (default enabled; set to `0` to skip the recall lookup entirely). The drawer's new "Memory used" section lists these hits for a2a tasks and is omitted whenever there are none. Known limitation: recall only resolves under the keyless posture — a keyed caller's task owner is a SHA-256 prefix of the API key, while memory rows are keyed by the database api-key id, and no hash→id lookup exists today, so the hit list stays empty for keyed callers. The recorded hits are also kept out of the task's own `input` (and therefore out of the persisted input and of the "Repeat" request body), so repeating a task never re-sends the previous run's memory snippets. ([#12508](https://github.com/diegosouzapw/OmniRoute/pull/12508))
|
||||
- **feat(providers):** add a Perplexity Agent API provider (`perplexity-agent` / `pplx-agent`) for Perplexity `/v1/responses`, including the documented Anthropic, OpenAI, Google, xAI, DeepSeek, Z.AI, Moonshot/Kimi, NVIDIA, and Perplexity model IDs plus Anthropic-model `max_output_tokens` compatibility. ([#12103](https://github.com/diegosouzapw/OmniRoute/pull/12103)) — thanks @AIB1TAL0S
|
||||
- **feat(providers):** add RPD (Requests Per Day) limit to provider rate limit overrides across UI, schemas, DB, and i18n ([#12147](https://github.com/diegosouzapw/OmniRoute/pull/12147)) ([#12147](https://github.com/diegosouzapw/OmniRoute/pull/12147)) — thanks @b3nw
|
||||
- **feat(providers): add UC Direct (uncensored.com Developer API), the metered OpenAI-compatible surface.** A standard OpenAI-compatible passthrough (default executor) for uncensored.com's official REST API at `https://api.uncensored.com/api/v1`: `X-api-key` auth (never-expiring `uai_sk_live_` key), `POST /chat/completions` with streaming SSE and native tool-calling, and the full live metered catalog (82 models across 15 providers, discovered from the public `GET /v1/models`). Registered as provider `uc-direct` (alias `ucd`). Complements the un-metered `uc` persona provider — same models, metered credits and a plain API key instead of a subscription session. ([#11513](https://github.com/diegosouzapw/OmniRoute/pull/11513)) — thanks @arminanton
|
||||
- **feat(providers): add UC (uncensored.com), the un-metered subscription "persona" chat as an OpenAI-compatible provider.** A WebSocket web-app port: a durable Clerk credential mints a short-lived session token per connect (browserless — no API key), driving UC's persona socket. Ships the browserless email-code login (request → verify → harvest), the 19 verified persona models (Claude Opus, Gemini, Grok, GLM, Kimi, DeepSeek, MiniMax, incl. the uncensored variants), prompted `<tool>` tool-calling with a per-model code-style dialect + auto-cure retry for guardrailed models, live `<think>`/reasoning split, streaming + non-streaming OpenAI responses, and full quota/auth error surfacing (paywall / message-limit / rate-limit → 429, invalid session → 401 re-login). Full multimodal parity via the persona blob-upload layer: **vision** (image input, 15 vision-capable models), **document RAG** (PDF/doc upload, server-side extraction), **image generation** (22 models), **video generation** (14 models, async signed-url → poll), and **TTS** (streaming MP3). Registered as provider `uc` (alias `ucn`). The metered OpenAI-compatible Developer API is a separate `uc-direct` provider. ([#11513](https://github.com/diegosouzapw/OmniRoute/pull/11513)) — thanks @arminanton
|
||||
- **feat(logs):** add copy-all button to request detail modal ([#11083](https://github.com/diegosouzapw/OmniRoute/pull/11083)) — thanks @AndrianBalanescu
|
||||
- **feat(check):** make serviceKinds required and add the reverse-walk provider consistency gate ([#11392](https://github.com/diegosouzapw/OmniRoute/pull/11392)) — thanks @Tushar49
|
||||
- **feat(providers):** only advertise verified-working connections by default ([#11448](https://github.com/diegosouzapw/OmniRoute/pull/11448)) — thanks @alltomatos
|
||||
- **feat(resilience):** add hierarchical concurrency admission ([#11493](https://github.com/diegosouzapw/OmniRoute/pull/11493))
|
||||
- **feat(providers):** derive + surface expiry for JWT-bearing web cookies (#11497) ([#11505](https://github.com/diegosouzapw/OmniRoute/pull/11505)) — thanks @oyi77
|
||||
- **feat(tunnels):** support named cloudflared tunnels via CLOUDFLARED_CONFIG ([#11561](https://github.com/diegosouzapw/OmniRoute/pull/11561)) — thanks @ggiak
|
||||
- **feat(compression):** make proactive context-compression threshold a live setting ([#11564](https://github.com/diegosouzapw/OmniRoute/pull/11564)) — thanks @ragnar-claude
|
||||
- **feat(resilience):** add provider peak-hour protection ([#11622](https://github.com/diegosouzapw/OmniRoute/pull/11622)) — thanks @Neuron-Mr-White
|
||||
- **feat(providers):** add 1min.ai provider ([#11631](https://github.com/diegosouzapw/OmniRoute/pull/11631)) — thanks @zero-executioner
|
||||
- **feat(dashboard):** collapsible JSON tree viewer for request/response payloads ([#11703](https://github.com/diegosouzapw/OmniRoute/pull/11703)) — thanks @hartmark
|
||||
- **feat(routing):** add LiquidAI LFM2.5-2.6B free tier via OpenRouter ([#11752](https://github.com/diegosouzapw/OmniRoute/pull/11752)) — thanks @brick30llc-ctrl
|
||||
- **feat(pricing):** add GLM-5.3-Flash pricing, model specs, and catalog registration ([#11830](https://github.com/diegosouzapw/OmniRoute/pull/11830)) — thanks @AndrianBalanescu
|
||||
- **feat(routing):** add relayMode for schema-locked context handoffs ([#11839](https://github.com/diegosouzapw/OmniRoute/pull/11839)) — thanks @raheemuddin786
|
||||
- **feat(config):** add support for runtime system prompt configuration and hot-reloading ([#11841](https://github.com/diegosouzapw/OmniRoute/pull/11841)) — thanks @raheemuddin786
|
||||
- **feat(routing):** expand connection-aware quota prefilter across combo strategies (#11682) ([#11850](https://github.com/diegosouzapw/OmniRoute/pull/11850)) — thanks @HouMinXi
|
||||
- **feat(models):** reactive discovery sync after Antigravity model-not-found 404 ([#11888](https://github.com/diegosouzapw/OmniRoute/pull/11888)) — thanks @MumuTW
|
||||
- **feat(models):** auto-aliases and token limits from synced Antigravity discovery ([#11889](https://github.com/diegosouzapw/OmniRoute/pull/11889)) — thanks @MumuTW
|
||||
- **feat(settings):** raise sticky round-robin limit caps to 1000 ([#12015](https://github.com/diegosouzapw/OmniRoute/pull/12015)) — thanks @adivekar-utexas
|
||||
- **feat(resilience):** operator-configurable global credential health check interval ([#12043](https://github.com/diegosouzapw/OmniRoute/pull/12043)) — thanks @adivekar-utexas
|
||||
- **feat(quota):** use official OpenCode Go usage API ([#12124](https://github.com/diegosouzapw/OmniRoute/pull/12124)) — thanks @ddarkr
|
||||
- **feat(resilience):** default the credential health check sweep to 60 minutes ([#12138](https://github.com/diegosouzapw/OmniRoute/pull/12138)) — thanks @adivekar-utexas
|
||||
- **feat(quality):** new-code mode for the complexity and dead-code ratchets (Clean as You Code) ([#12142](https://github.com/diegosouzapw/OmniRoute/pull/12142))
|
||||
- **feat(dashboard):** orchestration canvas — unified model + snapshot hook (part 1/2) ([#12156](https://github.com/diegosouzapw/OmniRoute/pull/12156))
|
||||
- **feat(usage):** add Kilo Code balance and Kilo Pass quotas ([#12178](https://github.com/diegosouzapw/OmniRoute/pull/12178)) — thanks @killer30001000
|
||||
- **feat(providers):** refresh vendored ChatGPT Web connector to v4.0.7 ([#12181](https://github.com/diegosouzapw/OmniRoute/pull/12181)) — thanks @backryun
|
||||
- **feat(providers):** manual "Clear cooldown" action in the cooling panel ([#12224](https://github.com/diegosouzapw/OmniRoute/pull/12224)) — thanks @patrykkopycinski
|
||||
- **feat(providers):** restore ChatGPT Web via clean-room browser transport ([#12239](https://github.com/diegosouzapw/OmniRoute/pull/12239)) — thanks @backryun
|
||||
- **feat(sse):** wire the PROVIDER_PROFILES window gate into the global provider cooldown ([#12247](https://github.com/diegosouzapw/OmniRoute/pull/12247))
|
||||
- **feat(providers):** modernize CLOVA Studio chat and embeddings ([#12277](https://github.com/diegosouzapw/OmniRoute/pull/12277)) — thanks @backryun
|
||||
- **feat(browser):** adopt Obscura as primary headless browser engine with Chromium fallback ([#12286](https://github.com/diegosouzapw/OmniRoute/pull/12286)) — thanks @opensource-elearning
|
||||
- **feat(sse):** full language parity for output styles (es/de/fr/it/ru/zh + autoDetect) ([#12289](https://github.com/diegosouzapw/OmniRoute/pull/12289))
|
||||
- **feat(quality):** complete test:scoped — --full map rebuild, stdin selection, CI loader parity (#8084 D1) ([#12353](https://github.com/diegosouzapw/OmniRoute/pull/12353))
|
||||
- **feat(providers):** refresh Fable, Cursor, and Devin catalogs ([#12367](https://github.com/diegosouzapw/OmniRoute/pull/12367)) — thanks @backryun
|
||||
- **feat(video):** redact transcript text from logs and durable memory (#12150 P1) ([#12427](https://github.com/diegosouzapw/OmniRoute/pull/12427))
|
||||
- **feat(dashboard):** parent-link, genuine-continuation badge, and modal perf fixes ([#12448](https://github.com/diegosouzapw/OmniRoute/pull/12448)) — thanks @hartmark
|
||||
- **feat(dashboard):** adaptive context-budget dial on compression panel ([#12488](https://github.com/diegosouzapw/OmniRoute/pull/12488)) — thanks @HouMinXi
|
||||
- **feat(providers):** refresh Z.ai Web models and browser transport ([#12524](https://github.com/diegosouzapw/OmniRoute/pull/12524)) — thanks @backryun
|
||||
- **feat(video):** redact raw client-snapshot transcript fields in the detailed log (#12150 P2) ([#12528](https://github.com/diegosouzapw/OmniRoute/pull/12528))
|
||||
- **feat(providers):** refresh NVIDIA hosted models ([#12538](https://github.com/diegosouzapw/OmniRoute/pull/12538)) — thanks @backryun
|
||||
- **feat(quota):** Moonshot Open Platform balance and TPD lock for custom nodes ([#12590](https://github.com/diegosouzapw/OmniRoute/pull/12590)) — thanks @HouMinXi
|
||||
- **feat(video):** redact transcript in the in-memory pending-request snapshot (#12430 item 6) ([#12596](https://github.com/diegosouzapw/OmniRoute/pull/12596))
|
||||
- **feat(dashboard):** link a log entry's Conversation Context to its owning conversation ([#12646](https://github.com/diegosouzapw/OmniRoute/pull/12646)) — thanks @hartmark
|
||||
- **feat(catalog):** eligibility-gated free-tier bucket ([#12669](https://github.com/diegosouzapw/OmniRoute/pull/12669))
|
||||
- **feat(db):** fail-closed previous_response_id continuation for redacted video turns (#12150 P2b) ([#12707](https://github.com/diegosouzapw/OmniRoute/pull/12707))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **security(streaming):** sanitize generic mid-stream error messages before emitting OpenAI,
|
||||
Responses, or Claude SSE failure frames and before diagnostic logging, while preserving raw
|
||||
failures for internal classification and keeping client disconnects out of provider failure state. ([#12457](https://github.com/diegosouzapw/OmniRoute/pull/12457))
|
||||
- **fix(claude):** `blockExtraUsage: false` no longer lets 5h quota preflight skip the account; extra usage is billed after the session bar is gone, so the request must reach Anthropic ([#12803](https://github.com/diegosouzapw/OmniRoute/pull/12803)) — thanks @HouMinXi
|
||||
- Sanitize HuggingChat conversation-creation and message-send transport failures before they reach client error bodies or provider logs. ([#12467](https://github.com/diegosouzapw/OmniRoute/pull/12467))
|
||||
- **fix(dashboard):** Keep local and theme-aware provider SVG icons at a definite layout size so Chromium does not collapse them to 0×0 after the v3.8.50 image-rendering change ([#12054](https://github.com/diegosouzapw/OmniRoute/pull/12054)) — thanks @ponkcore
|
||||
- **fix(security):** Sanitize provider and runtime failures before public API, SSE and MCP responses and before persistent request, proxy and usage logs, preventing credentials, stack traces and host filesystem paths from crossing those boundaries while preserving stable error codes and useful diagnostics. ([#12506](https://github.com/diegosouzapw/OmniRoute/pull/12506))
|
||||
- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295)) ([#12368](https://github.com/diegosouzapw/OmniRoute/pull/12368)) — thanks @gonisulaimann
|
||||
- Stop advertising Gemini Live-only models as supported audio endpoints until OmniRoute proxies the bidirectional Live protocol. ([#11710](https://github.com/diegosouzapw/OmniRoute/pull/11710)) — thanks @RaviTharuma
|
||||
- **fix(kie):** reroute `flux/kontext` off the KIE Market `createTask` flow — it is catalogued with `isMarket: true` but has no Market catalog page, so KIE rejected it with "model name not supported"; it now hits the dedicated `POST /api/v1/flux/kontext/generate` / `GET /api/v1/flux/kontext/record-info` endpoints instead (#11296). ([#11985](https://github.com/diegosouzapw/OmniRoute/pull/11985)) — thanks @hartmark
|
||||
- **fix(kie):** correct 12 more KIE Market catalog ids that were sent to `createTask` unchanged but diverge from KIE's documented upstream `model` values — GPT Image 2 T2I/I2I (drops the `gpt/` prefix), GPT Image 1.5 T2I/I2I (`gpt-image/` namespace), Seedream 5.0 Lite T2I/I2I (drops the `.0`), all 4 Flux 2 variants (`flux-2/` namespace, generic variant renamed `flex`), and Wan 2.7 Image / Image Pro (dash instead of dot) — each verified individually against the literal example request published on docs.kie.ai. `#11326`'s "everything else already matches" claim was wrong a second time (#11296); `z-image/4.0-*`/`z-image/4.5-*` and `flux/kontext` remain open, documented as unresolved in `KIE_MARKET_UPSTREAM_MODEL_IDS`'s comment pending further verification. ([#11667](https://github.com/diegosouzapw/OmniRoute/pull/11667))
|
||||
- **fix(dashboard):** `useApiKeySave.handleSaveApiKey` no longer forces a full upstream `/models` catalog sync on every non-curated provider connection save — callers can now pass `skipModelSync: true` to opt out, so a workflow that only wants to add one manual model no longer floods the provider's available-models list with hundreds/thousands of synced entries. The flag is a client-side intent signal only and is stripped before the connection payload is POSTed to `/api/providers`; default behavior (full sync on save) is unchanged when the flag is omitted (#11324) ([#11664](https://github.com/diegosouzapw/OmniRoute/pull/11664))
|
||||
- **Provider connections:** keep `tokenExpiresAt` when a connection is created. The create-path allowlist omitted it, so every insert stored NULL and the dashboard token badge could read a fresh connection as expired until its first background refresh ([#11368](https://github.com/diegosouzapw/OmniRoute/pull/11368)). — thanks @ntdatt812
|
||||
- **fix(combos):** the combo builder's precision-select, global-model-search, and manual-entry flows now serialize a model step's `model` string using the provider's already-computed routing-alias prefix (e.g. `oc/`) instead of rebuilding it from the raw canonical `providerId`, fixing the no-auth "OpenCode Free" provider (`opencode`) being routed to the unrelated paid "OpenCode Zen" provider (`opencode-zen`) because `opencode` doubles as a manual routing-prefix override ([#11433](https://github.com/diegosouzapw/OmniRoute/issues/11433)). ([#11670](https://github.com/diegosouzapw/OmniRoute/pull/11670))
|
||||
- **fix(radar):** the catalog feed cache now keeps `generatedAt`, the date the feed's data was built, next to `fetchedAt`, the date this install downloaded it (#11435). The feed schema requires that date and the sync path validates it, but the cache dropped it — so a feed fetched minutes ago and one carrying weeks-old figures looked identical to everything downstream, including the dashboard's "Last fetched" line. `getRadarCatalog().meta` and `GET /api/radar/status` now report both dates, the latter as its own field rather than folded into `version` — and omitted entirely for the offers and intel caches, which keep no build date, where a `null` would read as "unknown" rather than "never stored". The dashboard still shows only the fetch time; surfacing the build date there needs a new translated label and is left to a follow-up. Rows cached before migration 163 read back as `null`: unknown stays unknown instead of borrowing the fetch time. The referrals cache has persisted the same date since migration 142. — thanks @maxmad64bis
|
||||
- **fix(cli):** postinstall no longer fills `JWT_SECRET` and `API_KEY_SECRET` in the installed package's `.env` (#11436). `.env.example` ships both blank on purpose: the server restores them from its durable store, or generates and persists them there, in `ensureSecrets()`. Pre-filling them defeated that — the file lives inside the package directory, so `npm i -g` replaced it and postinstall wrote _different_ values, while `ensureSecrets()` (which only acts on an empty variable) never got to restore the real ones. Both secrets rotated silently on every update, invalidating dashboard sessions and API-key CRCs. `STORAGE_ENCRYPTION_KEY` left the same list for the same reason in #1622; its comment pointed at a function that no longer exists and now names the real provisioning path. — thanks @maxmad64bis
|
||||
- **fix(cli):** the CLI now says when a loaded `.env` lives inside the installed package directory (#11437). It already announces every env file it reads, without distinguishing the ones that survive an update from the one that does not: `npm i -g` replaces the package directory wholesale, so values set there are gone at the next update, silently. The warning names the durable path to move them to, and fires only when that file actually supplied a value — a file entirely shadowed by a durable one supplied nothing. A development checkout stays silent: there the same path is stable and documented in `SETUP_GUIDE.md`. — thanks @maxmad64bis
|
||||
- **fix(free-models):** the shared `isFreeModel()` predicate no longer reports catalog entries marked `freeType: "discontinued"` as free, so `hidePaidModels` can't route to Pollinations' seven premium models that now require a paid key ([#11441](https://github.com/diegosouzapw/OmniRoute/pull/11441)) — thanks @maxmad64bis
|
||||
- fix(ui): let AnySearch use the normal provider-icon fallback when LobeHub has no matching icon (#11449)
|
||||
- Fixed the v3.8.50 Costs and Analytics dashboards so flat-rate Claude Code usage can be shown as an explicitly requested token-price estimate without changing default billed-cost semantics. ([#11460](https://github.com/diegosouzapw/OmniRoute/pull/11460)) — thanks @xiaoyaner0201
|
||||
- Fixed archived usage retention so each request is priced individually instead of pricing a day's summed tokens once, which understated archived cost whenever a day mixed cache-heavy and ordinary requests. ([#11460](https://github.com/diegosouzapw/OmniRoute/pull/11460)) — thanks @xiaoyaner0201
|
||||
- Fixed the Costs dashboard so it discloses when displayed figures include flat-rate token-price estimates instead of labelling them as billed spend, using the flag the analytics API already returns; the month-end projection and the CSV/JSON exports carry the same marker, and billed-cost mode is unchanged. ([#11460](https://github.com/diegosouzapw/OmniRoute/pull/11460)) — thanks @xiaoyaner0201
|
||||
- **fix(combo):** attach the same combo-diagnostics trace (`poolSize`/`attemptOrder`/`excluded`/`terminalReason`, plus `x-omniroute-combo-*` headers) to the round-robin strategy's and the nested pipeline/fusion runtime-unit loop's "Maximum combo retry limit reached" 503 that the priority-strategy path already attaches for the identical terminal condition — previously those two paths returned a bare, contextless 503 ([#11462](https://github.com/diegosouzapw/OmniRoute/issues/11462)). ([#11665](https://github.com/diegosouzapw/OmniRoute/pull/11665))
|
||||
- **fix(bun):** use native `bun:sqlite` in `bootstrap-env` and `sync-env` to avoid loading `better-sqlite3` N-API addon during Bun startup ([#11468](https://github.com/diegosouzapw/OmniRoute/pull/11468)) — thanks @TheDemonTuan
|
||||
- **fix(docker-bun):** make `Dockerfile.bun` install reliable with pre-install script helpers and native `bun:sqlite` smoke check ([#11470](https://github.com/diegosouzapw/OmniRoute/pull/11470)) — thanks @TheDemonTuan
|
||||
- **fix(docker-bun):** remove every vendored `better-sqlite3` native addon from the Bun runtime image so startup cannot abort before the `bun:sqlite` fallback ([#11482](https://github.com/diegosouzapw/OmniRoute/pull/11482)) — thanks @TheDemonTuan
|
||||
- **fix(autoCombo):** effort/alias model variants (`gpt-5.6-sol-xhigh`, `gpt-5.6`, cursor's `claude-4.6-opus-high`) inherit their base model's task fitness instead of falling to the wildcard 0.5 ([#11492](https://github.com/diegosouzapw/OmniRoute/pull/11492)) — thanks @MumuTW
|
||||
- fix(db): rate-limit repeated Arena ELO leaderboard fetch-failure warnings instead of logging one per sync attempt (#11500) ([#11989](https://github.com/diegosouzapw/OmniRoute/pull/11989)) — thanks @hartmark
|
||||
- **fix(db):** dedupe the raw `[Encryption] Decryption failed...` log line emitted by the lazy-decrypt views (`createLazyRowProxy`/`createLazyConnectionView`), which power `getProviderConnections()` and were re-triggering that line on every CredentialHealth/model-sync cycle for the same corrupt or stale-key credential — a fresh Proxy over a fresh row on every cycle meant the per-proxy memoization never suppressed it, unlike the dedup `decryptConnectionFields()` already had since [#9927](https://github.com/diegosouzapw/OmniRoute/issues/9927). Now shares that dedupe tracking so the line logs at most once per credential ([#11500](https://github.com/diegosouzapw/OmniRoute/issues/11500)). ([#11678](https://github.com/diegosouzapw/OmniRoute/pull/11678))
|
||||
- **fix(intelligence):** Arena ELO sync stores a synthesized base-model row for every effort/harness variant (`claude-opus-5-max` → `claude-opus-5`, `gpt-5.6-sol-xhigh (codex-harness)` → `gpt-5.6-sol`), so bare model requests reach the synced intelligence layer instead of the static fallback table; the hand-maintained `MODEL_ALIAS_MAP` — which collapsed generations (`gpt-5.5` → `gpt-5`) — is gone ([#11506](https://github.com/diegosouzapw/OmniRoute/pull/11506)) — thanks @MumuTW
|
||||
- **fix(autoCombo,sse):** catalog hygiene — the static task-fitness table no longer ranks retired models above live ones (dead rows dropped, patterns matched on segment boundaries), the 7 deprecation aliases that forwarded to retired or non-existent models now point at the vendors' published replacements, an alias is no longer rewritten when the serving provider offers the id as-is, and a new `check:model-lifecycle` gate diffs both tables against a vendor lifecycle snapshot ([#11507](https://github.com/diegosouzapw/OmniRoute/pull/11507)) — thanks @MumuTW
|
||||
- **fix(sse):** stop dropping resolved `thoughtSignature` values on parallel (multi tool-call) turns sent to Gemini 3.x — the claude→gemini and openai→gemini translators previously kept the signature only on the *first* function call of a message, causing Gemini to reject subsequent calls in the same turn with HTTP 400 "Function call is missing a thought_signature"; each function call now keeps its own resolved signature ([#11510](https://github.com/diegosouzapw/OmniRoute/issues/11510)). ([#11669](https://github.com/diegosouzapw/OmniRoute/pull/11669))
|
||||
- Treat Docker publishing from a non-default release branch as an expected skip instead of a failed workflow, while preserving the guard that prevents it from overwriting the `next` channel. ([#11527](https://github.com/diegosouzapw/OmniRoute/pull/11527)) — thanks @pacocartones
|
||||
- fix(search): prefer credentialed providers over duckduckgo-free fallback (#11524) — the fallback-only loop ran before the credentialed-providers loop in `executeWebSearch`, making configured providers unreachable when `duckduckgo-free` was available. ([#11565](https://github.com/diegosouzapw/OmniRoute/pull/11565)) — thanks @vsd2807
|
||||
- **fix(sse):** cap the upstream headers-wait phase for STREAMING requests to a client-realistic ceiling (110s, under Codex's own ~120s hard client-abort window) instead of the flat 10-minute `FETCH_TIMEOUT_MS` default — that default was 5x longer than the body-phase readiness watchdog's own adaptive bound, so a request whose upstream never returned any response at all (not even headers, e.g. a stalled NVIDIA target behind a tool-heavy Responses→Chat translation) kept the client connection alive on keepalives only, guaranteeing the client's own patience ran out first with an opaque 499 instead of OmniRoute detecting and failing the stall fast. Non-streaming requests are unaffected — they keep the existing flat default (`open-sse/utils/fetchStartTimeoutPolicy.ts`) (#11526) ([#11668](https://github.com/diegosouzapw/OmniRoute/pull/11668))
|
||||
- **fix(ci):** protocol-clients E2E harness boots the peer-stamped custom server (`run-next.mjs dev`) instead of the bare `next` CLI, so `LOCAL_ONLY` locality resolves from the real TCP peer stamp and `/api/mcp/audit` answers 200 (open bootstrap preserved via an `OMNIROUTE_E2E_BOOTSTRAP_MODE=open` credential clear in `run-next.mjs`, pinned to loopback `HOST`) instead of a deterministic 403 (#11535) ([#11549](https://github.com/diegosouzapw/OmniRoute/pull/11549)) — thanks @jonlwheat2-gif
|
||||
- **fix(docs):** the Auto-Combo engine is described with the number of factors it actually declares. Four different counts were in circulation — 6, 9, 13 and 14 across `AGENTS.md`, four guides and six code comments — while `DEFAULT_WEIGHTS` declares 15. `check:docs-counts` now reads that number from the source, so adding a factor without updating the prose fails CI ([#11545](https://github.com/diegosouzapw/OmniRoute/pull/11545)) ([#11553](https://github.com/diegosouzapw/OmniRoute/pull/11553)) — thanks @maxmad64bis
|
||||
- **fix(quota-share):** in-flight leases are per request, so an aborted request's slot ages out instead of being kept alive by later traffic on the same connection ([#11547](https://github.com/diegosouzapw/OmniRoute/pull/11547)) — thanks @abhisheksharma2411
|
||||
- The free-tier summary route now serves the refreshed Radar catalog when the feed is active — the same numbers the dashboard shows — and states which catalog answered plus its real build date, instead of always reporting release-frozen figures with a stale curation date. The supporter-key live feed stays reserved to authenticated callers of the instance. ([#11550](https://github.com/diegosouzapw/OmniRoute/pull/11550)) — thanks @maxmad64bis
|
||||
- **sse:** fix LiveWS/embed-WS servers crashing at startup under the Node/tsx runtime — `liveServer.ts`, `embedWsProxy.ts` and `apiBridgeServer.ts` imported `@/shared/utils/httpClientAbortGuard` without the `.mjs` extension, so the client-abort crash guard added by [#11556](https://github.com/diegosouzapw/OmniRoute/pull/11556) was unreachable and every dependent test failed with `ERR_MODULE_NOT_FOUND` ([#11556](https://github.com/diegosouzapw/OmniRoute/pull/11556)). ([#11671](https://github.com/diegosouzapw/OmniRoute/pull/11671)) — thanks @jonlwheat2-gif / @yxyxy
|
||||
- fix(chat): shed any of the matched provider-node's routing identifiers (public prefix or internal id) before model lookup, so composites like `<connId>/<connId>/<model>` no longer reach upstream verbatim (#11557) — thanks @maxmad64bis
|
||||
- **fix(cache):** a semantic-cache entry now expires on its TTL instead of surviving until the next UTC midnight, and `dbEntries` stops counting expired rows ([#11573](https://github.com/diegosouzapw/OmniRoute/pull/11573)) — thanks @ntdatt812
|
||||
- **fix(api):** `/v1/models` schedules its stale-while-revalidate rebuild through Next's `after()` again, so a stale catalog reaches the client before the rebuild blocks the event loop ([#11574](https://github.com/diegosouzapw/OmniRoute/pull/11574)) — thanks @ntdatt812
|
||||
- **fix(check):** the `known-symbols` executor conformance gate awaits the now-async `getExecutor()`, so it stops reporting all 142 registered aliases as dead symbols and can detect a lazy import that fails ([#11582](https://github.com/diegosouzapw/OmniRoute/pull/11582)) — thanks @ntdatt812
|
||||
- **fix(pack):** register `bin/cli/utils/volatileEnvPath.mjs` in `PACK_ARTIFACT_REQUIRED_PATHS`, so the prune can no longer silently drop a module the published CLI imports on every boot ([#11588](https://github.com/diegosouzapw/OmniRoute/pull/11588)) — thanks @ntdatt812
|
||||
- **i18n(vi):** translate the five reliability-column strings on the free-provider rankings page, which rendered as `__MISSING__:` markers in the Vietnamese UI ([#11589](https://github.com/diegosouzapw/OmniRoute/pull/11589)) — thanks @ntdatt812
|
||||
- **resilience:** restore the expired-connection retry-budget probe in the token-health sweep — the `!isGitHubAccessTokenOnlyConnection` carve-out reintroduced by #11608 contradicted the boundary pinned by #11592, so a GitHub connection parked at `expired` with retry budget remaining was never probed and could never self-heal ([#11592](https://github.com/diegosouzapw/OmniRoute/pull/11592)). ([#11672](https://github.com/diegosouzapw/OmniRoute/pull/11672)) — thanks @jonlwheat2-gif / @ntdatt812
|
||||
- **fix(dashboard):** Provider Stats now displays combo success rates on their native 0–100 percentage scale instead of multiplying them twice ([#11595](https://github.com/diegosouzapw/OmniRoute/pull/11595)) — thanks @pacocartones
|
||||
- **fix(providers):** route DuckDuckGo Web Chat through the verified `duck.ai` endpoint, refresh its free model catalog, and avoid forwarding unsolved anti-abuse challenges ([#11597](https://github.com/diegosouzapw/OmniRoute/pull/11597)) — thanks @solstxce
|
||||
- **fix(ui):** Console log Refresh and Copy controls now expose localized accessible names, keep copy actions visible on keyboard focus, and announce copy completion safely ([#11599](https://github.com/diegosouzapw/OmniRoute/pull/11599)) — thanks @pacocartones
|
||||
- Dropped the stale-`.eslintcache` `restore-keys` fallback from both "Restore ESLint file cache" steps in `ci.yml`, so the blocking `Lint` job can no longer be served per-file verdicts computed under a different lint config, suppressions file or lockfile. `quality.yml` had already dropped it in #11963; `ci.yml` — the workflow that actually gates PRs — had not (#11600). ([#11996](https://github.com/diegosouzapw/OmniRoute/pull/11996))
|
||||
- fix(ci): pass `--pass-on-unpruned-suppressions` in `run-eslint-json.mjs` so the CI Lint job no longer fails when a suppression is merely orphaned by a genuine fix, mirroring the identical fix already in `validate-release-green.mjs` (#11600) ([#11983](https://github.com/diegosouzapw/OmniRoute/pull/11983)) — thanks @hartmark
|
||||
- fix(handoff): enforce provider allowlist for universal handoff (#11602) — universal handoff now skips summarization when the selected summary model's provider is not included in the configured provider allowlist. ([#11619](https://github.com/diegosouzapw/OmniRoute/pull/11619)) — thanks @vsd2807
|
||||
- **fix(dashboard):** Show a stable error state when Search Analytics returns an HTTP error or malformed data ([#11603](https://github.com/diegosouzapw/OmniRoute/pull/11603)) — thanks @pacocartones
|
||||
- **test(gamification):** pin the aggregate profile level to the XP-derived semantics of #11604 — `getAggregateXp()` now derives `currentLevel` from the summed XP (`calculateLevel(sum)`), not `MAX(stored current_level)`, and the #3484 fixture levels are aligned with the XP curve ([#11604](https://github.com/diegosouzapw/OmniRoute/pull/11604)). ([#11676](https://github.com/diegosouzapw/OmniRoute/pull/11676)) — thanks @jonlwheat2-gif / @pacocartones
|
||||
- **fix(dashboard):** Keep the Profile level and progress aligned with aggregate XP, including bounded handling for invalid totals ([#11604](https://github.com/diegosouzapw/OmniRoute/pull/11604)) — thanks @pacocartones
|
||||
- **fix(dashboard):** Prevent locked hidden badges from revealing their icon or opening private badge details before they are earned ([#11605](https://github.com/diegosouzapw/OmniRoute/pull/11605)) — thanks @pacocartones
|
||||
- **fix(dashboard):** Restore keyboard focus after shared modals close and cancel delayed autofocus during cleanup ([#11607](https://github.com/diegosouzapw/OmniRoute/pull/11607)) — thanks @pacocartones
|
||||
- **fix(dashboard):** Enable Enter and Space activation for clickable data-table rows without hijacking nested controls ([#11610](https://github.com/diegosouzapw/OmniRoute/pull/11610)) — thanks @pacocartones
|
||||
- **fix(autoCombo,sse):** vendor-retired catalog ids are dropped from the auto-combo candidate pool and no longer win on leftover `arena_elo` / `user_override` rows; `getModelLifecycleDecision` consults `model-lifecycle.json` (prefix-stripped) so aggregator traffic is not `untracked` for ids the snapshot already knows ([#11625](https://github.com/diegosouzapw/OmniRoute/issues/11625)) ([#11626](https://github.com/diegosouzapw/OmniRoute/pull/11626)) — thanks @MumuTW
|
||||
- **test(chatcore):** move Codex/Claude combo fixtures off models the lifecycle guard now rejects — `gpt-5.1-codex`/`gpt-5-codex` are vendor-retired (snapshot, #11626) and `claude-3-5-sonnet-20241022` is shut down, so native-passthrough and combo-fallback tests switched to `gpt-5.6-sol` and `claude-sonnet-4.6` ([#11626](https://github.com/diegosouzapw/OmniRoute/pull/11626)). ([#11675](https://github.com/diegosouzapw/OmniRoute/pull/11675)) — thanks @jonlwheat2-gif / @MumuTW
|
||||
- fix(opencode-plugin): stop a free-tier budget that rounds up across a magnitude from rendering as `1000K`/`1000M` in the model picker — `fmtTokens` chose its unit from the raw token count and then rounded with `toFixed(1)`, so 999,950–999,999 printed as `1000K` rather than `1M` and just under a billion printed as `1000M` rather than `1B` ([#11684](https://github.com/diegosouzapw/OmniRoute/pull/11684)) — thanks @abhisheksharma2411
|
||||
- Cap prose-derived Antigravity quota resets at the configured model cooldown maximum while preserving authoritative Retry-After headers and Google RetryInfo hints. (#11695) Thanks @pacocartones. ([#11823](https://github.com/diegosouzapw/OmniRoute/pull/11823))
|
||||
- Compare `_rebaseline_*` notes structurally in the ratchet-bank verifier so an object-valued note no longer reports itself as rewritten on an unmodified tree — the nightly `bank-ratchet-shrinks` lane had been aborting on every run since 2026-08-11, leaving 41,672 lines of earned file-size shrinks unbanked (#11702). ([#11892](https://github.com/diegosouzapw/OmniRoute/pull/11892)) — thanks @MumuTW
|
||||
- **fix(build):** `prepublish.ts` bundles the ChatGPT Web (Codex) MCP bridge through `runBuildTool()` instead of spawning `npx.cmd` raw, fixing the build crash on Node ≥ 20/Windows where `.cmd` shims cannot be spawned without a shell (EINVAL) ([#11704](https://github.com/diegosouzapw/OmniRoute/issues/11704)) ([#11706](https://github.com/diegosouzapw/OmniRoute/pull/11706)) — thanks @jonlwheat2-gif
|
||||
- fix(codex): keep `parallel_tool_calls:false` on the translated Codex Responses Lite path (#11707) ([#11984](https://github.com/diegosouzapw/OmniRoute/pull/11984)) — thanks @hartmark
|
||||
- **fix(tests):** the new Gemini Web Images retirement test (#11708) called the now-async `getExecutor()` synchronously, throwing `TypeError: getExecutor(...).getProvider is not a function` instead of asserting Gemini Web chat stays available. Awaited to match the R0.3 executor-registry refactor (#11220). ([#11868](https://github.com/diegosouzapw/OmniRoute/pull/11868))
|
||||
- **fix(search):** Allow REST and MCP web-fetch requests to select the advertised `anysearch-search` provider explicitly ([#11716](https://github.com/diegosouzapw/OmniRoute/pull/11716)) — thanks @pacocartones
|
||||
- **fix(providers):** Keep overnight peak-hour protection active after midnight according to the window's configured start day ([#11718](https://github.com/diegosouzapw/OmniRoute/pull/11718)) — thanks @pacocartones
|
||||
- **fix(providers):** correct all five g4f.space gateway descriptions to document proof-of-work credits or member API keys, variable limits, and the remote prompt boundary instead of promising free keyless access at five requests per minute ([#11738](https://github.com/diegosouzapw/OmniRoute/pull/11738)).
|
||||
- **fix(db):** `model_capabilities` is created by a migration instead of lazily on the first models.dev sync, so a clean install and an upgraded install converge on the same schema regardless of which features have run ([#11845](https://github.com/diegosouzapw/OmniRoute/pull/11845))
|
||||
- **fix(ci):** `check:install-upgrade` now fails on an `npm` install truncated by ENOSPC (npm reports it as a warning and still exits 0), authenticates its health probe so the version assertion works against the hardened health payload, frees the clean-install tree before the upgrade phase, and no longer reports a schema divergence computed from a boot that never served ([#11845](https://github.com/diegosouzapw/OmniRoute/pull/11845))
|
||||
- **fix(routing):** harden session-affinity key derivation against oversized explicit IDs and empty structural-payload collisions, while recognizing Gemini and common text request shapes ([#11746](https://github.com/diegosouzapw/OmniRoute/pull/11746)) — thanks @MumuTW
|
||||
- **fix(packages/browser-pool):** regenerate `package-lock.json` so the `packages/browser-pool` workspace's locked `playwright`/`@types/node` (and transitives) match its `package.json` specs, fixing cache-only/offline installs (`npm ci --offline`, Nix `buildNpmPackage`) that previously failed with `ENOTCACHED` ([#11747](https://github.com/diegosouzapw/OmniRoute/issues/11747)) — thanks @benjaminkitt ([#11784](https://github.com/diegosouzapw/OmniRoute/pull/11784)) — thanks @geekyNads
|
||||
- **fix(assets):** Stop distributing 79 provider and CLI logos without verified source/license provenance, keep every provider/tool available through the internal generic icon, and prevent those IDs from falling through to an external icon CDN. ([#11750](https://github.com/diegosouzapw/OmniRoute/pull/11750))
|
||||
- **fix(providers):** retire the common `chatgpt-web` / `cgpt-web` provider because its pre-key and proof-of-work provenance could not be cleared ([#11754](https://github.com/diegosouzapw/OmniRoute/pull/11754)) — explicit chat and image requests, slash-prefixed models, persisted aliases, proxy overrides, and combo targets now fail closed with a sanitized HTTP `410` / `PROVIDER_RETIRED` before upstream dispatch. Migration 163 tombstones matching connections and invalidates their active leases without deleting history or widening API-key allowlists. The independent `chatgpt-web-codex` / `cgpt-codex` provider and its MIT-noticed vendored implementation remain available.
|
||||
- **fix(usage):** quota and usage refresh no longer 409 when an exclusive lease reserves the connection ([#11758](https://github.com/diegosouzapw/OmniRoute/pull/11758)) — thanks @TheDemonTuan
|
||||
- Keep the embedding registry's vector width and `embedding` type on models when a synced model exists for the same id, so `/v1/models` no longer reports registry-described embedding models widthless or untyped (#11761) — thanks @rahilmavani
|
||||
- Correct `google/gemini-embedding-001` on the OpenRouter route to 3072 dimensions, the width it returns when `dimensions` is not sent (#11761) — thanks @rahilmavani
|
||||
- **fix(vision-bridge):** Restrict automatic vision routing and fallbacks to models available in authoritative live provider catalogs, including aliases and effort variants ([#11767](https://github.com/diegosouzapw/OmniRoute/issues/11767)) — thanks @pacocartones ([#11813](https://github.com/diegosouzapw/OmniRoute/pull/11813))
|
||||
- **fix(providers):** reclassify Cerebras as a one-time $5 signup credit (payment method required, 30-day validity), not a recurring no-card 1M tokens/day trial ([#11773](https://github.com/diegosouzapw/OmniRoute/issues/11773)) ([#12591](https://github.com/diegosouzapw/OmniRoute/pull/12591)) — thanks @HouMinXi
|
||||
- fix(cli): stop prepublish from re-rebuilding the already-built ESM-only opencode-plugin dist (#11787) ([#11990](https://github.com/diegosouzapw/OmniRoute/pull/11990)) — thanks @hartmark
|
||||
- **fix(db):** Fresh installs no longer log a non-fatal `no such table: compression_run_telemetry` warning when retention cleanup runs before the lazily-created telemetry table exists ([#11802](https://github.com/diegosouzapw/OmniRoute/pull/11802)) — thanks @RaviTharuma
|
||||
- fix(sse): set X-OmniRoute-Selected-Connection-Id on successful combo dispatches so downstream consumers stop falling back to an empty connection id (#11810) ([#11986](https://github.com/diegosouzapw/OmniRoute/pull/11986)) — thanks @hartmark
|
||||
- **fix(services):** embedded CLIProxyAPI lifecycle checks now use public `/healthz`, while model discovery uses the configured dedicated data-plane API key instead of the management password ([#11811](https://github.com/diegosouzapw/OmniRoute/pull/11811)) — thanks @RaviTharuma
|
||||
- Fix persisted model steps returning an error in the playground route simulator, and warn when structural steps cannot be simulated. ([#11862](https://github.com/diegosouzapw/OmniRoute/pull/11862)) — thanks @pacocartones
|
||||
- **fix(providers):** Antigravity's dynamic mitmAlias table no longer routes `gemini-3.7-flash-{high,medium,low}` to a literal tier-suffixed upstream id just because one connected account's own discovery listed it directly — those display ids always resolve through the safe `gemini-3.7-flash-tiered` static alias, so one account's Google-provisioned access no longer 404s every sibling account of the provider ([#11824](https://github.com/diegosouzapw/OmniRoute/issues/11824), [#11651](https://github.com/diegosouzapw/OmniRoute/issues/11651)) ([#11988](https://github.com/diegosouzapw/OmniRoute/pull/11988)) — thanks @hartmark
|
||||
- **fix(dashboard):** Expose Profile loading, errors, page structure, and XP progress to assistive technologies ([#11838](https://github.com/diegosouzapw/OmniRoute/pull/11838)) — thanks @pacocartones
|
||||
- **fix(socks):** `Agent.connectTimeout` now bounds both SOCKS handshake and TLS connect, and `family === null` no longer falls back to `fetch-socks` ([#11842](https://github.com/diegosouzapw/OmniRoute/pull/11842)) — thanks @maxmad64bis
|
||||
- **perf(compression):** OOM mitigations for large payload hashing, memoization, and token estimation ([#11844](https://github.com/diegosouzapw/OmniRoute/pull/11844) — thanks @AndrianBalanescu)
|
||||
- Excluded Next.js Node File Trace manifests (`*.nft.json`) from the published npm tarball. They are build-time metadata and are never read while serving, but had grown to 668.7 MB — 61% of the package — which pushed the upload past the registry limit and made `npm publish` fail with `413 Payload Too Large`. ([#11864](https://github.com/diegosouzapw/OmniRoute/pull/11864))
|
||||
- **fix(cli):** keep the global OmniRoute server `--base-url` separate from provider-node `--endpoint` values in add, update, and validate commands ([#11860](https://github.com/diegosouzapw/OmniRoute/pull/11860)) — thanks @pacocartones
|
||||
- **fix(config):** Nous Research's `Hermes-4-405B` model now displays as "Hermes 4 405B (Nous Research)" in both the provider registry and the free-model catalog, instead of the mislabelled "Hermes 4 7B" ([#11861](https://github.com/diegosouzapw/OmniRoute/issues/11861)) — thanks @Karan825 ([#11993](https://github.com/diegosouzapw/OmniRoute/pull/11993))
|
||||
- **fix(provider/nous):** inject required user= tag into Nous Research inference requests to resolve upstream 400 "missing tags" error ([#11861](https://github.com/diegosouzapw/OmniRoute/issues/11861)) — thanks @Karan825 ([#12044](https://github.com/diegosouzapw/OmniRoute/pull/12044))
|
||||
- **fix(build):** `npm run build` now fails in one second with a named package and a copy-pasteable fix when npm silently drops an externalised optional native dependency, instead of dying four minutes in with `Module not found: Can't resolve 'better-sqlite3'` ([#11863](https://github.com/diegosouzapw/OmniRoute/pull/11863)) — thanks @ujjawalkaushik1110
|
||||
- **fix(install):** `postinstall` no longer throws `ReferenceError: isAndroid is not defined` — failing the whole `npm install` — when the `better-sqlite3` rebuild fallback times out; the manual-fix guidance is reachable again ([#11863](https://github.com/diegosouzapw/OmniRoute/pull/11863)) — thanks @ujjawalkaushik1110
|
||||
- Fixed the Alibaba free-tier allowlist test that went red on its own once the shipped catalog's `validUntil` (2026-08-27) passed, leaving every PR and `main` with a failing `Unit Tests (1/8)`. The test now builds its own packs with dates it controls, and covers the expired-pack fallback that production has actually been serving. ([#11867](https://github.com/diegosouzapw/OmniRoute/pull/11867))
|
||||
- Split the npm registry upload into its own GitHub-hosted job. npm refuses `--provenance` from a self-hosted runner (`422 ... Only "github-hosted" runners are supported`), which blocked the v3.8.50 publish; the heavy verification cannot move to a hosted runner, so it now hands the proven tarball over instead. ([#11868](https://github.com/diegosouzapw/OmniRoute/pull/11868))
|
||||
- **fix(ui):** the shared `DataTable` loading state no longer reads its decorative ⏳ glyph out to assistive technology, and now carries the same `role="status"` / `aria-live="polite"` / `aria-busy="true"` semantics as `PageLoading` ([#11869](https://github.com/diegosouzapw/OmniRoute/pull/11869)) — thanks @pacocartones
|
||||
- **fix(api):** Bound the `timeout` accepted by `POST /api/providers/[id]/login` to the same 15s–600s window the Adobe Firefly and Conol browser-login paths already enforce, so a request can no longer pin a headful browser and the single active-login slot with an arbitrarily large poll budget. ([#11871](https://github.com/diegosouzapw/OmniRoute/pull/11871)) — thanks @pacocartones
|
||||
- **fix(api):** Stop the tunnel and MITM routes from returning raw child-process error text, which disclosed host paths, binary install locations and Tailscale `tskey-*` credentials in a body some of these routes serve to non-loopback callers, and return a real 400 instead of a framework 500 when a tunnel request body fails validation. ([#11872](https://github.com/diegosouzapw/OmniRoute/pull/11872)) — thanks @pacocartones
|
||||
- **fix(open-sse):** stream TTFT/ITL are sampled from a monotonic clock (`performance.now`) instead of `Date.now`, so an NTP correction or wall-clock jump can no longer inflate a genuine TTFT or produce a negative one that the `ttft >= 0` guard silently drops — protecting the router's OTel/EWMA/`usage_history`/speed-ranking signals ([#11873](https://github.com/diegosouzapw/OmniRoute/pull/11873)) — thanks @pacocartones
|
||||
- **fix(providers):** xAI `reasoning_effort: "xhigh"` now reaches grok-4.6+ instead of being silently clamped to `"high"` ([#11879](https://github.com/diegosouzapw/OmniRoute/pull/11879)) — thanks @NoxzRCW
|
||||
- **fix(dashboard):** the providers page no longer crashes into the error boundary when a provider id collides with an `Object.prototype` member (`constructor`, `__proto__`); icon lookups are own-property guarded ([#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)) — thanks @NoxzRCW
|
||||
- **fix(skills):** injected skill tools declared in shorthand (`{"content": "string"}`) now forward valid JSON Schema, unblocking providers that validate tool schemas strictly such as Zhipu GLM on the Console Go tier ([#11881](https://github.com/diegosouzapw/OmniRoute/pull/11881)) — thanks @NoxzRCW
|
||||
- **fix(api):** `POST /api/playground/simulate-route` now surfaces `combo-ref` and `provider-wildcard` persisted combo steps with a specific warning (naming the referenced combo, or the unresolved `provider/model` wildcard) instead of folding them into a generic "unsupported step" count; a `provider-wildcard` step is also now included as an unresolved target so the operator can see it is in the route (ported from [#11882](https://github.com/diegosouzapw/OmniRoute/pull/11882) — thanks @NoxzRCW). ([#11930](https://github.com/diegosouzapw/OmniRoute/pull/11930))
|
||||
- **fix(translator):** the streaming OpenAI→Claude translator keeps upstream usage, including prompt-cache tokens, when it arrives on a trailing `choices: []` chunk (Fireworks and any upstream using `stream_options.include_usage`) ([#11883](https://github.com/diegosouzapw/OmniRoute/pull/11883)) — thanks @NoxzRCW
|
||||
- **fix(cli):** `omniroute update --apply` now tells you explicitly whether a running server was detected and, if so, that you must run `omniroute restart` to apply the update — it never restarted anything and previously implied the update was already live once files were installed. The dashboard's npm-mode Update flow (`/api/system/version`) now tries OmniRoute's own PID-file-managed supervisor before falling back to pm2, and reports an honest "restart required" step instead of a silent pm2-only "skipped" that read like a completed update. The server-side latest-version lookup backing the dashboard's update banner also gained `--prefer-online`, closing the same stale-npm-cache class already fixed in the CLI's own copy for #4376 ([#11885](https://github.com/diegosouzapw/OmniRoute/issues/11885)). ([#12005](https://github.com/diegosouzapw/OmniRoute/pull/12005))
|
||||
- **fix(resilience):** clear persisted LKGP pins when a target suffers connection/provider exhaustion or is skipped before dispatch due to cooldown/exhaustion/unavailability, preventing subsequent requests from repeatedly prioritizing known-dead providers ([#11911](https://github.com/diegosouzapw/OmniRoute/issues/11911)). ([#12013](https://github.com/diegosouzapw/OmniRoute/pull/12013)) — thanks @HouMinXi
|
||||
- **fix(sse):** the OpenAI→Claude stream translator now defers the terminal `message_delta`/`message_stop` emission until the real usage block has arrived (or a genuine end-of-stream flush forces it) instead of emitting it immediately on `finish_reason` — previously, when the trailing usage-only chunk (`{"choices":[],"usage":{...}}`) arrived *after* the `finish_reason` chunk (the normal order for Fireworks/vLLM/Together and other `stream_options.include_usage` upstreams), the client-visible `message_delta` still carried stale/zero usage even though `state.usage` was internally corrected too late to matter (ported from [#11915](https://github.com/diegosouzapw/OmniRoute/pull/11915) — thanks @HouMinXi). ([#11933](https://github.com/diegosouzapw/OmniRoute/pull/11933))
|
||||
- **fix(sse):** `stripStore()` now forces `store=false` for stateless OpenAI-compatible Responses-API targets (unless the connection has explicitly opted in via `providerSpecificData.openaiStoreEnabled`), instead of only handling the `openai`/`agentrouter` cases — a client-supplied `store` value previously passed through untouched to backends that don't actually persist responses server-side ([#11916](https://github.com/diegosouzapw/OmniRoute/pull/11916) — thanks @HouMinXi).
|
||||
- **fix(models):** custom provider-node models (synced, custom, and alias-backed) now appear under their configured prefix in the unified catalog when the operator's model-id prefix mode is canonical, instead of being dropped whenever alias-inclusion was otherwise disabled ([#11918](https://github.com/diegosouzapw/OmniRoute/pull/11918) — thanks @HouMinXi).
|
||||
- **fix(models):** the unified model catalog now suppresses stale static registry models (including effort-tier variants) for any provider whose active connection has an authoritative live synced catalog, not only providers already using exclusive-synced-listing — a connection with `providerUsesAuthoritativeLiveCatalog` previously kept serving both the live-synced models and the stale static rows side by side ([#11919](https://github.com/diegosouzapw/OmniRoute/pull/11919) — thanks @HouMinXi).
|
||||
- **fix(ui):** `ProviderIcon`'s three remaining unguarded lookups (`PROVIDER_ICON_ALIASES`, `LOCAL_SVG_ALIASES`, `THEMED_SVGS`) now use `Object.hasOwn()` own-property checks — a provider id such as `constructor` or `__proto__` previously resolved through the prototype chain instead of falling through to the unknown-provider CDN fallback (`getLobeProviderIcon()` itself was already guarded by [#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)); `ProviderPageHeader`'s `color` field is also now optional, matching the rest of the component's defensive typing (ported from [#11920](https://github.com/diegosouzapw/OmniRoute/pull/11920) — thanks @HouMinXi). ([#11935](https://github.com/diegosouzapw/OmniRoute/pull/11935)) — thanks @NoxzRCW
|
||||
- fix(ollama): preserve multi-byte UTF-8 content split across stream chunks in the Ollama NDJSON transform, which previously corrupted CJK/emoji into U+FFFD (#11921) — thanks @pacocartones
|
||||
- **fix(providers):** OrcaRouter chat requests now target `/v1/chat/completions` instead of the bare `/v1` API root, fixing the upstream `404 Invalid URL (POST /v1)` ([#11923](https://github.com/diegosouzapw/OmniRoute/pull/11923)). — thanks @echel0nn
|
||||
- **fix(plugins):** deliver the `onStreamComplete` event to disk-installed plugins. The event shipped in v3.8.50 (#9669) was emitted internally but had no plugin-facing wiring, so no plugin could ever subscribe: the manifest schema silently dropped `hooks.onStreamComplete`, and the loader/manager only knew the seven legacy hooks. `onStreamComplete` is now a declarable manifest hook, wired through the loader and registered by the manager like the other hooks, and its payload carries a `requestId` so consumers can correlate the stream-completion event with the originating request ([#11934](https://github.com/diegosouzapw/OmniRoute/pull/11934)) — thanks @amaleta / @pacocartones
|
||||
- **fix(db):** the Qdrant embedding-model dropdown now lists local/self-hosted providers (Ollama, LM Studio, vLLM, etc.) — an active connection is treated as "configured" when the provider allows an optional API key, not only when it has a real key or OAuth, so a running local embedding provider is no longer hidden from the picker ([#11949](https://github.com/diegosouzapw/OmniRoute/issues/11949)) ([#11995](https://github.com/diegosouzapw/OmniRoute/pull/11995))
|
||||
- **fix(providers):** Vertex AI Anthropic partner-model discovery now calls the Model Garden `v1beta1` publisher list (`/v1beta1/publishers/anthropic/models`, global) and parses its `publisherModels` envelope, so Claude models auto-synced from Vertex populate the active live catalog and route at request time instead of returning `Model '<id>' is not available in the active live catalog` ([#11991](https://github.com/diegosouzapw/OmniRoute/issues/11991)) — thanks @fabioluissilva ([#11998](https://github.com/diegosouzapw/OmniRoute/pull/11998))
|
||||
- **fix(cli):** support `--base-url` alongside `--endpoint` in `omniroute nodes add`, `update`, and `validate` subcommands to prevent global `--base-url` shadowing issues ([#11999](https://github.com/diegosouzapw/OmniRoute/issues/11999)). ([#12033](https://github.com/diegosouzapw/OmniRoute/pull/12033)) — thanks @HouMinXi
|
||||
- **fix(providers):** `cloudflare-ai` no longer refuses image content parts for every Workers AI model ([#12002](https://github.com/diegosouzapw/OmniRoute/pull/12002)) — the plain-string `content` requirement behind #2539 is carried by the _model_ schema, not by the `/ai/v1/chat/completions` endpoint (measured: an all-text part array returns 200 on `@cf/mistralai/mistral-small-3.1-24b-instruct`, `@cf/meta/llama-4-scout-17b-16e-instruct` and `@cf/meta/llama-3.3-70b-instruct-fp8-fast`, and 400 on the text-only `@cf/qwen/qwen2.5-coder-32b-instruct`). `transformRequest()` flattened every array and threw on the first non-text part (#6390), so image input was refused for vision-capable Cloudflare models that accept it. All-text arrays are still flattened — the one shape every model accepts — while an array carrying a non-text part is passed through untouched, so the attachment is still never silently dropped. Regression guards: `tests/unit/cloudflare-ai-image-parts-6390.test.ts`. — thanks @davidlinfr
|
||||
- **fix(resilience):** decouple the limiter-managed execution backstop from the queue-wait budget — new `requestQueue.executionMaxWaitMs` (env `RATE_LIMIT_EXECUTION_MAX_WAIT_MS`, default 600000 = 10 min) now feeds Bottleneck's post-dispatch `expiration`, while `requestQueue.maxWaitMs` keeps its documented queue-wait semantics. Previously the queue-wait budget doubled as the execution expiration, so legitimate long-running calls on non-incremental gateways (whole generation buffered before the first upstream byte, e.g. Console Go / Command Code tiers serving GLM models) were killed mid-flight at the queue budget with a false 504 `RATE_LIMIT_EXECUTION_TIMEOUT` — the local limiter undercut the provider-aware upstream fetch-start timeouts. The surfaced 504 message now names `requestQueue.executionMaxWaitMs`; the error keeps the #4165 guarantees (disclaims an upstream timeout, preserves the Bottleneck error as `cause`, branded code + trusted provenance, classified request-scoped so combo falls back). A real queue-wait bound (the `Promise.race` around `limiter.schedule()` sketched in #9533) remains future work. (#12025) ([#12027](https://github.com/diegosouzapw/OmniRoute/pull/12027)) — thanks @alvinveroy
|
||||
- **Call logs:** keep the `error` field when an artifact exceeds the storage cap, instead of replacing it with the omission marker. The error is the only field that says *why* a request failed and is typically ~90 bytes next to the multi-hundred-KB bodies that trip the cap, so dropping it left a size-limited row undiagnosable — a provider outage, a local timeout and an upstream 400 all rendered identically. It is now preserved at every fallback stage, truncated to 4KB if it is itself large ([#12026](https://github.com/diegosouzapw/OmniRoute/issues/12026)). ([#12095](https://github.com/diegosouzapw/OmniRoute/pull/12095)) — thanks @ntdat812
|
||||
- **fix(diagnostics):** preserve the error field (truncated to 4KB with a `[truncated: …]` suffix) in every call-log artifact size-limit fallback stage. Previously the minimal fallback replaced the error with `[omitted: call log artifact size limit exceeded]`, so an oversized artifact row showed nothing about WHY the request failed — e.g. 91 of 847 opencode-go 504 rows on one production instance were undiagnosable from the dashboard. Oversized request/response bodies are still omitted exactly as before; the error cap is independent of the payload sizes that tripped the fallback. (#12026) ([#12027](https://github.com/diegosouzapw/OmniRoute/pull/12027)) — thanks @alvinveroy
|
||||
- **fix(sse):** OpenAI Responses clients that declare the native `web_search` tool now receive a spec-shaped `web_search_call` output item with `action.sources` alongside the preserved function-call round-trip, so search results executed through OmniRoute's own search backend are consumable by standard Responses clients (Codex, pi-web-access, …). ([#12031](https://github.com/diegosouzapw/OmniRoute/pull/12031)) — thanks @watchingdogs
|
||||
- **fix(cli):** use in-thread alias resolver hooks on modern runtimes to avoid deprecation noise and improve Node.js forward compatibility ([#12073](https://github.com/diegosouzapw/OmniRoute/issues/12073)). ([#12083](https://github.com/diegosouzapw/OmniRoute/pull/12083)) — thanks @xiaoyaner0201
|
||||
- **fix(combo):** an operator-set **Agent Features → Context length** on a combo is now honored at request time. The value was persisted and advertised through `/v1/models`, but `resolveComboContextLimit()` never consulted it — so a multi-target combo whose members carry no per-model window fell through to the provider's generic `defaultContextLength` (openrouter 128000, command-code 200000) and rejected large requests with `Input exceeds context window … limit 128000` despite the combo being explicitly sized much larger. An identical single-target combo worked, because it collapses to its concrete target before the guard runs. Invalid values (0/negative/NaN/Infinity) are ignored, so the existing target → combo-min → fallback order is unchanged. ([#12090](https://github.com/diegosouzapw/OmniRoute/pull/12090)) — thanks @adivekar-utexas
|
||||
- **fix(sse):** passthrough streams now estimate usage on finish when upstream closes without usage even with `stream_options.include_usage` — avoids `0 tokens / 0%` for providers that stay silent (and correctly handles trailing empty-choices usage) ([#12151](https://github.com/diegosouzapw/OmniRoute/pull/12151)) — thanks @maxmad64bis
|
||||
- **fix(combos):** clearing an agent feature in the combos editor now persists — unchecking context cache protection, or emptying the system message or tool filter, sends an explicit `null` instead of dropping the field from the `PUT` body, which the update merge read as "leave unchanged" ([#12177](https://github.com/diegosouzapw/OmniRoute/pull/12177)) — thanks @foreveryh / @ntdatt812
|
||||
- **fix(memory):** self-hosted embedding endpoints now vectorize — the vector width is measured from the first embedding that comes back instead of being read from a registry that cannot describe them, so `vec_memories` is created and memories stop piling up unvectorized behind a green health check ([#12180](https://github.com/diegosouzapw/OmniRoute/pull/12180)) — thanks @kanade-hoshino / @ntdatt812
|
||||
- **fix(translator):** the leading `system` message now reaches Responses-API upstreams when its `content` is a content-part array — it was read as `typeof content === "string" ? content : ""`, so a prompt-caching client (Anthropic `cache_control`, the shape LiteLLM and the Anthropic SDK emit) had its entire system prompt replaced by an empty `instructions`. The request was still accepted with a normal `prompt_tokens` count, so the model answered with no instructions and nothing in the response said they were missing. Mid-conversation system turns already handled the array shape ([#7056](https://github.com/diegosouzapw/OmniRoute/pull/7056)); only the first one did not ([#12206](https://github.com/diegosouzapw/OmniRoute/issues/12206)). Regression guard: `tests/unit/translator-openai-responses-system-content-parts.test.ts`. ([#12207](https://github.com/diegosouzapw/OmniRoute/pull/12207)) — thanks @Gorillaz322
|
||||
- **fix(free-tier):** `/api/free-tier/summary` no longer computes its totals from a Radar feed built before the catalog the running release ships. When the cached feed is older — or carries no build date at all — the route answers from the shipped catalog, resolved through the operator's local model state so disabled and tombstoned models stay out of the numbers ([#12215](https://github.com/diegosouzapw/OmniRoute/pull/12215)). — thanks @maxmad64bis
|
||||
- **fix(oauth):** Keep a Claude personal workspace and a Team organization as separate connections — they share the same email and `accountUUID`, so the email-only OAuth dedup let the second login overwrite the first account's tokens; `organizationUUID` now disambiguates them, the way `workspaceId` does for Codex ([#12222](https://github.com/diegosouzapw/OmniRoute/pull/12222)) — thanks @davidebaraldo
|
||||
- **fix(resilience):** a 402 on a single paid model of a passthrough/gateway provider (e.g. `kilo-gateway`, `ollama-cloud`) no longer terminalizes the whole connection with a never-auto-recovered `credits_exhausted` status — only the paid model is locked out, so free models on the same key keep serving. 402 variant of [#3027](https://github.com/diegosouzapw/OmniRoute/issues/3027). Single-credential providers are unaffected — a 402 there is still treated as the key being genuinely out of credit ([#5239](https://github.com/diegosouzapw/OmniRoute/issues/5239) / [#10616](https://github.com/diegosouzapw/OmniRoute/issues/10616)) ([#12242](https://github.com/diegosouzapw/OmniRoute/issues/12242)) — thanks @brick30llc-ctrl ([#12266](https://github.com/diegosouzapw/OmniRoute/pull/12266)) — thanks @geekyNads
|
||||
- **fix(sse):** trust `finish_reason: "length"`/`"max_tokens"` over the reasoning-consumed-token ratio in response quality validation, so a reasoning model truncated below the old 90% threshold correctly fails and retries instead of returning empty content as a silent "success" ([#12262](https://github.com/diegosouzapw/OmniRoute/pull/12262)) — thanks @brick30llc-ctrl
|
||||
- **fix(settings):** `PUT /api/settings/cache-config` now persists `alwaysPreserveClientCache` to the flat general settings the runtime cache-control policy actually reads; previously the value landed in the databaseSettings "cache" section and was silently ignored, so the endpoint had no effect on `cache_control` passthrough ([#12304](https://github.com/diegosouzapw/OmniRoute/pull/12304)) — thanks @davidebaraldo
|
||||
- **fix(grok-cli):** treat omitted SuperGrokPro `creditUsagePercent` as 0% used so Provider Limits still renders a weekly bar (proto3 zero-elision) ([#12312](https://github.com/diegosouzapw/OmniRoute/pull/12312)) — thanks @HouMinXi
|
||||
- **fix(combo):** Expose the two Auto-Combo scoring factors nobody could set — the combo validation schema and the dashboard weight sliders both declared 13 of the scorer's 15 factors, so `connectionDensity` (spreads load across a provider's connections) and `quality` were dropped on save and offered nowhere. The sliders also shipped their own default table that differed from the engine's on every non-zero factor and summed to 1.05, so the percentages shown next to them added up to 105%. Both lists now match `DEFAULT_WEIGHTS`, and a test keeps them there. Note that a combo whose stored `weights` omitted the two keys was effectively running with them at zero and the other thirteen renormalized upward; it now runs with the engine's intended distribution, so its routing does shift ([#12314](https://github.com/diegosouzapw/OmniRoute/pull/12314)) — thanks @maxmad64bis
|
||||
- **fix(docs):** The free-tier reference no longer says its numbers come "confidence tagged per row" — no catalog entry carries a confidence tag and the API serves none, so every figure on that page is an estimate of the same, unstated quality. The page now states what an entry does vouch for: an independently documented hard stop (set by hand with the source in a comment, never defaulted to `true`) and a prompt-training disclosure, both with live counts the `check:docs-counts` gate keeps honest ([#12318](https://github.com/diegosouzapw/OmniRoute/pull/12318)) — thanks @maxmad64bis
|
||||
- **fix(usage):** `adobe-firefly` and `firefly` have had a working usage fetcher since Adobe Firefly landed, but neither was ever added to the registration list, so the provider-plugin manifest, `genericQuotaFetcher` and the free-access quota cache all reported them as having no usage support — while `USAGE_SUPPORTED_PROVIDERS` said the opposite. Both are now declared, which also means their credit balance is fetched like any other declared provider's: `registerGenericQuotaFetchers` now registers a generic quota fetcher for them, and `resolveFreeAccessState` no longer returns early. A test holds the registration list to the dispatcher's switch in both directions, which is what the module's own docstring already asked for in prose ([#12321](https://github.com/diegosouzapw/OmniRoute/pull/12321)) — thanks @maxmad64bis
|
||||
- **fix(quota):** drop the generic quota cache (agy / Antigravity / Claude OAuth) on an upstream 429 so reset-aware scoring does not keep a 60s stale snapshot, and force-refresh the next usage fetch so inner provider caches cannot recache the same window ([#12325](https://github.com/diegosouzapw/OmniRoute/pull/12325)) — thanks @HouMinXi
|
||||
- **fix(combos):** deleting a combo now clears its persisted LKGP pins instead of leaving unreachable `key_value` rows behind ([#12326](https://github.com/diegosouzapw/OmniRoute/issues/12326)) ([#12425](https://github.com/diegosouzapw/OmniRoute/pull/12425)) — thanks @KrzysiekSko
|
||||
- **fix(sse):** Keep ZWNJ (U+200C) and ZWJ (U+200D) in assistant text, reasoning and tool-call arguments — Persian/Kurdish half-space (`ارائهدهنده`), Arabic/Indic shaping and emoji sequences no longer lose them; the response de-obfuscation now removes joiners only between ASCII word characters, where the request side inserts them ([#12186](https://github.com/diegosouzapw/OmniRoute/issues/12186)) — thanks @rezjalibd ([#12359](https://github.com/diegosouzapw/OmniRoute/pull/12359)) — thanks @pacocartones
|
||||
- **fix(resilience):** count resolved upstream 5xx results against the provider circuit breaker on the chat path — `CircuitBreaker.execute()` no longer reads a resolved `{ success: false, status: 5xx }` as a success that cancels the call-site failure, so a provider answering 503s now trips its breaker instead of staying `CLOSED` at `failureCount: 1`; single-model and combo dispatches are each accounted exactly once ([#12254](https://github.com/diegosouzapw/OmniRoute/issues/12254)) ([#12360](https://github.com/diegosouzapw/OmniRoute/pull/12360)) — thanks @pacocartones
|
||||
- **fix(providers):** resolve the Codex quota auto-ping model from the live provider catalog and lifecycle registry instead of the retired `gpt-5.1-codex-mini`, and pause the ping with one actionable warning when no selectable Codex model exists rather than retrying a shut-down id every cooldown window ([#11905](https://github.com/diegosouzapw/OmniRoute/issues/11905)) ([#12361](https://github.com/diegosouzapw/OmniRoute/pull/12361)) — thanks @pacocartones
|
||||
- **fix(api):** keep the `{created, data}` wrapper on combo-routed `/v1/images/generations` responses and default Codex image results to `b64_json` on both `/v1/images/generations` and `/v1/images/edits` so Codex CLI's built-in `image_gen` can decode them ([#12268](https://github.com/diegosouzapw/OmniRoute/issues/12268)) ([#12362](https://github.com/diegosouzapw/OmniRoute/pull/12362)) — thanks @pacocartones
|
||||
- **fix(sse):** Name the shadowed custom provider node when a built-in provider id/alias (e.g. `openference` → `of`) reserves the prefix of an existing OpenAI/Anthropic-compatible node, so the runtime `No active credentials for provider: <built-in>` error explains that the prefix routed to the built-in and never reached the node's healthy connections, instead of contradicting the dashboard ([#11943](https://github.com/diegosouzapw/OmniRoute/issues/11943)) — thanks @morpheus9393 ([#12365](https://github.com/diegosouzapw/OmniRoute/pull/12365)) — thanks @pacocartones
|
||||
- **fix(i18n):** wrap `ccOnboardingKeyPlaceholder` in ICU single quotes across all 43 locales so angle brackets render literally instead of being parsed as rich-text tags, which crashed the Claude Code onboarding block with `INVALID_MESSAGE: INVALID_TAG` ([#12302](https://github.com/diegosouzapw/OmniRoute/issues/12302)) ([#12369](https://github.com/diegosouzapw/OmniRoute/pull/12369)) — thanks @gonisulaimann
|
||||
- **fix(models):** publish `effort_tiers` on Kimi K3's synced base-model entries (`kmca/k3`, `kmca/k3-256k`) so catalog-only clients (OpenCode, plain SDK pickers) can see and select the reasoning tiers (`low`/`high`/`max`) the synced metadata already carried — the `isSkippedEffortProvider` gate no longer suppresses tier visibility on those base entries, while synthetic `<id>-<tier>` variant generation stays prevented and Codex/GLM base models remain excluded unchanged ([#12299](https://github.com/diegosouzapw/OmniRoute/issues/12299)) ([#12371](https://github.com/diegosouzapw/OmniRoute/pull/12371)) — thanks @gonisulaimann
|
||||
- **fix(guardrails):** keep `auto`/`auto/*` virtual combos exempt from the Vision Bridge `fixedModel` credential guard so a combo target is passed through instead of silently falling back to global auto-selection ([#12237](https://github.com/diegosouzapw/OmniRoute/issues/12237)) ([#12373](https://github.com/diegosouzapw/OmniRoute/pull/12373)) — thanks @pacocartones
|
||||
- **fix(combo):** capability-filter exhaustion caused by `max_tokens` above every target's known output limit now reports that reason (requested `max_tokens` vs the pool's highest known ceiling) instead of the unrelated "supports structured output" message ([#12229](https://github.com/diegosouzapw/OmniRoute/issues/12229)) — thanks @DW-MediaLab ([#12374](https://github.com/diegosouzapw/OmniRoute/pull/12374)) — thanks @pacocartones
|
||||
- **fix(auth):** the `least-used` account strategy now prefers accounts without backoff before falling back to oldest `lastUsedAt`, the same tie-break `round-robin` already applies, so a failover no longer lands on a just-rate-limited account for a single request ([#12279](https://github.com/diegosouzapw/OmniRoute/issues/12279)) — thanks @tenshiak ([#12375](https://github.com/diegosouzapw/OmniRoute/pull/12375)) — thanks @pacocartones
|
||||
- **fix(docker):** the `chatgpt-web-codex-browser` image now finds the Chrome binary under `chrome-linux64/` (Chrome for Testing layout in `playwright:v1.62.0-noble`) as well as the legacy `chrome-linux/`, so the container no longer crash-loops with `exec: --headless=new: not found` ([#12024](https://github.com/diegosouzapw/OmniRoute/issues/12024)) ([#12376](https://github.com/diegosouzapw/OmniRoute/pull/12376)) — thanks @pacocartones
|
||||
- **fix(providers):** declare `groq/compound` and `allam-2-7b` as non-reasoning models in the curated Groq registry so `reasoning_effort` / `output_config.effort` / `thinking` from Claude Code are stripped instead of forwarded, which Groq rejected with HTTP 400 ([#12134](https://github.com/diegosouzapw/OmniRoute/issues/12134)) ([#12379](https://github.com/diegosouzapw/OmniRoute/pull/12379)) — thanks @pacocartones
|
||||
- **fix(executors):** `OpencodeExecutor` no longer forces a direct connection when the connection has a proxy assigned in Proxy Management but no per-account proxies: the single-account fast path used to wrap the upstream dispatch in the direct-egress sentinel, discarding the ambient proxy context the chat handler had pinned from `proxy_assignments`, so API-key `opencode`/`opencode-go` connections egressed from the host IP (and hit geoblocks) despite the assignment. The direct pin is now applied only when no ambient proxy context exists ([#11894](https://github.com/diegosouzapw/OmniRoute/issues/11894) — thanks @hizzt) ([#12380](https://github.com/diegosouzapw/OmniRoute/pull/12380)) — thanks @pacocartones
|
||||
- **fix(api):** `GET /v1/models` with `MODELS_CATALOG_PREFIX_MODE=canonical` (or `?prefix=canonical`) now lists providers whose registry alias is undefined or equal to their own id (Antigravity, Antigravity CLI and other self-aliased built-ins) — their single `provider/model` id was dropped by the alias/canonical duplicate guard in the static, synced, custom and alias-backed catalog loops ([#12058](https://github.com/diegosouzapw/OmniRoute/issues/12058)) — thanks @cheynetom ([#12381](https://github.com/diegosouzapw/OmniRoute/pull/12381)) — thanks @pacocartones
|
||||
- **fix(translator):** Drop replayed `thinking` blocks that carry no signature (the shape produced from cross-provider `reasoning_content`) instead of stamping the default Claude signature on them, which Anthropic rejected with `400 Invalid signature in thinking block` on the next turn served by an Anthropic rung ([#12105](https://github.com/diegosouzapw/OmniRoute/issues/12105)) — thanks @atescivitci-cmd ([#12386](https://github.com/diegosouzapw/OmniRoute/pull/12386)) — thanks @pacocartones
|
||||
- **fix(cli):** Resolve Bun's `--preload` polyfill path against the package root instead of `dist/`, so `omniroute` installed with `bun install -g` no longer crashes at startup with `error: preload not found …/dist/open-sse/utils/setupPolyfill.ts` ([#11980](https://github.com/diegosouzapw/OmniRoute/issues/11980)) — thanks @joglomedia ([#12387](https://github.com/diegosouzapw/OmniRoute/pull/12387)) — thanks @pacocartones
|
||||
- **fix(providers):** `gemini-business` now publishes its model catalog — `/v1/models` and `/v1/providers/gemini-business/models` list the 12 enterprise Gemini ids the executor understands instead of returning an empty list (#12107) ([#12389](https://github.com/diegosouzapw/OmniRoute/pull/12389)) — thanks @pacocartones
|
||||
- **fix(db):** install `busy_timeout` before the SQLite connection's first statement so a process opening the database while another one closes its WAL connection waits out the transient EXCLUSIVE lock instead of dying with `database is locked`, and recognise the drivers' real BUSY/PROTOCOL/IOERR errors as transient in the corruption probe so the same lock no longer renames the database away as corrupt; deflakes `cross-process contenders never both acquire the same connection` (#12394 — thanks @pacocartones)
|
||||
- **fix(chat-admission):** derive the `chat_admission_busy` 503 `Retry-After` from observed heavyweight-lease occupancy — the larger of the exhausted `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` window and the time since capacity last turned over, capped at 60 s — instead of a fixed 1 s (structural) / 2 s (byte-stage) hint that invited Codex/agent fan-out clients to re-send ~1 MiB `/v1/responses` bodies every second into a gate held for the whole SSE lifetime; an idle gate keeps the historical floors ([#12135](https://github.com/diegosouzapw/OmniRoute/issues/12135)) (#12395 — thanks @pacocartones)
|
||||
- **fix(api-manager):** the API key permissions modal no longer silently drops `allowedCombos` entries its Combo picker cannot render — routing-rule names such as `rt-*`, which the backend already honours — when "All" is clicked and the key is switched back to "Restrict"; those entries now survive the toggle, are listed read-only under the combo list so the count and the list agree, and are saved back verbatim instead of persisting `[]` (deny-all) (#12397 — thanks @pacocartones)
|
||||
- **fix(catalog):** write the NUL separator of the catalog connection memo key, the provider serviceKind memo key, the Video Bridge promotion group key and a JSON-exactness test fixture as the `\u0000` escape instead of a raw byte — same runtime value, but the raw byte made git, GitHub and ripgrep treat those files as binary (hidden PR diffs, silently skipped searches); a guard test now keeps raw NUL bytes out of `src/`, `open-sse/` and `tests/` (#12403 — thanks @pacocartones)
|
||||
- **fix(providers):** add `CLAUDE_CODE_CLIENT_VERSION` and `GITHUB_COPILOT_CLI_VERSION` env overrides so Anthropic/Copilot client-version gates can be unblocked without a rebuild ([#12417](https://github.com/diegosouzapw/OmniRoute/issues/12417)) ([#12632](https://github.com/diegosouzapw/OmniRoute/pull/12632)) — thanks @HouMinXi
|
||||
- **fix(db):** back-fill `last_ping_at` and `last_pinged_reset_key` on `provider_connections` during schema reconciliation so divergent lineages that skipped `123_quota_auto_ping` still accept quota auto-ping writes ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470) — thanks @KooshaPari)
|
||||
- **fix(ci):** document MIT exceptions for `@eloqnt/{config,format-json,format-po}` (next-intl transitive; locked tarballs omit `license`) and keep the A2A lifecycle vitest off the real SQLite persistence seam ([#12581](https://github.com/diegosouzapw/OmniRoute/issues/12581)) ([#12595](https://github.com/diegosouzapw/OmniRoute/pull/12595))
|
||||
- OpenCode plugin `/v1/models` catalog fetch now waits 30s by default and attaches HTTP `statusCode` on 401/5xx so host fallback plugins can hop instead of seeing an untyped AbortError/UnknownError. ([#12607](https://github.com/diegosouzapw/OmniRoute/pull/12607)) — thanks @RaviTharuma
|
||||
- **CI:** the OpenAPI security-tier gate now mirrors `isAlwaysProtectedPath()` in full — it also reads `ALWAYS_PROTECTED_API_PATTERNS`, so the pattern-gated credential routes (`/api/providers/{id}/{claude,codex}-auth/{export,apply-local}`, GHSA-5926-2w35-7h4q) no longer report as unannotated. (#12605)
|
||||
- **fix(api):** GET /v1/models no longer waits forever on a hung coalesced catalog rebuild; cold-path waits are bounded (`CATALOG_BUILD_TIMEOUT_MS`, default 8s) and a last-good 200 is served when the rebuild times out ([#12627](https://github.com/diegosouzapw/OmniRoute/issues/12627)). ([#12628](https://github.com/diegosouzapw/OmniRoute/pull/12628)) — thanks @RaviTharuma
|
||||
- **fix(cursor):** a non-array `tool_calls` on an assistant message no longer crashes the cursor request translator with a `TypeError`; both loops now require an array ([#12689](https://github.com/diegosouzapw/OmniRoute/issues/12689)) ([#12691](https://github.com/diegosouzapw/OmniRoute/pull/12691)) — thanks @soroush5
|
||||
- **fix(chat):** Chat Completions no longer return empty `content` after a server-owned memory or skills tool; the first provider send and account/model recovery now share one pipeline so a follow-up round-trip can fill the reply ([#12696](https://github.com/diegosouzapw/OmniRoute/issues/12696)) — thanks @HouMinXi ([#12867](https://github.com/diegosouzapw/OmniRoute/pull/12867))
|
||||
- **fix(dashboard):** batch-deleting provider keys no longer toasts failure after a successful delete when the confirm button's click event is forwarded as `onAfter` ([#12711](https://github.com/diegosouzapw/OmniRoute/pull/12711)) — thanks @HouMinXi
|
||||
- **fix(glm):** drop the extra 16th argument to `createSSETransformStreamWithLogger` that TypeScript rejected (TS2554) and that never reached the TransformStream ([#12711](https://github.com/diegosouzapw/OmniRoute/pull/12711)) ([#12770](https://github.com/diegosouzapw/OmniRoute/pull/12770)) — thanks @HouMinXi
|
||||
- **fix(dashboard):** pass `nodeMap` into Runtime `QuotaGroup` so a quota monitor in error/exhausted/alerting no longer throws `ReferenceError: nodeMap is not defined`. ([#12868](https://github.com/diegosouzapw/OmniRoute/pull/12868)) — thanks @HouMinXi
|
||||
- Restricted API keys whose `allowedModels` lists a combo name no longer skip every combo member at pre-dispatch (`ALL_TARGETS_SKIPPED`). Inner-target filtering still applies when the allow-list is a provider prefix or `disableNonPublicModels` is on ([#12899](https://github.com/diegosouzapw/OmniRoute/pull/12899)). — thanks @HouMinXi
|
||||
- **fix(combos):** Effort-suffixed combo members inherit the base model's `model_context_overrides` row so priority order is not inverted on large requests ([#12926](https://github.com/diegosouzapw/OmniRoute/pull/12926)) — thanks @HouMinXi
|
||||
- **fix(catalog):** Picker-added `customModels` enter the dispatch-time live catalog so combo members and bare inference no longer 400 ([#12934](https://github.com/diegosouzapw/OmniRoute/pull/12934)) — thanks @HouMinXi
|
||||
- **fix(docker):** re-derive the Docker build's worker-pool memory budget from the MEASURED ~4.5 GB per-process RSS (the issue owner's own VPS dmesg OOM-killer reproduction) instead of the stale 2560 MB/worker inference #11419 shipped, and lower `OMNIROUTE_BUILD_WORKERS` 3 → 2 so 1 parent + 1 page-data worker (2 processes × 4.5 GB = 9 GB) fits the 12.288 GB (75%) budget on a 16 GB GitHub Actions runner — the previous default (1 parent + 2 workers = 13.5 GB) still overcommitted the runner and kept "Publish to Docker Hub" failing with `cannot allocate memory` after #11419 merged (#7518). ([#11663](https://github.com/diegosouzapw/OmniRoute/pull/11663))
|
||||
- **fix(electron):** the packaged Windows desktop build now passes the #7592 cold-restart smoke end to end. Five defects were found and fixed on the way: (1) optional-pack staging failed on any machine whose `tar` is GNU tar — it reads the drive letter in an absolute `-f C:\...` archive path as a remote rsh host (`Cannot connect to C:`), so staging now passes a bare filename with `cwd` at the tarball directory and surfaces tar stderr on failure; (2) the Electron `files` allowlist was missing `lib/loginHeaderCapture.js`, so the packaged main process crashed at startup with `Cannot find module './lib/loginHeaderCapture'`; (3) electron-builder ≥26 injects an `!**/node_modules/**` ignore into every extraResources pattern list that no positive filter can override, which silently dropped the staged runtime `node_modules` (including the better-sqlite3 N-API prebuild) from `resources/app` — a fresh v3.8.50 desktop build would have shipped with no native SQLite driver and reproduced the #7592 sql.js fallback on every machine; an `afterPack` hook now restores the staged `node_modules` after packing; (4) the packaged-app smoke harness redirected `USERPROFILE` into its temp DATA_DIR, but Electron resolves the Roaming profile from `%USERPROFILE%\AppData\Roaming\<name>` (USERPROFILE takes precedence over the APPDATA env var) and the path service throws instead of creating it, so `requestSingleInstanceLock()` returned false and the app exited(0) silently before `app.whenReady()` — the harness now pre-creates the derived tree, and `ensureSmokeEnvDirs` is exported and unit-tested; (5) the #7592 driver assertion parsed a `[DB] Driver: ...` line that the server's primary DB path never printed (only the unused `openDatabaseAsync()` did) — `getDbInstance()` now emits the same line on open so the guard can actually assert the native driver. Also: the smoke env-allowlist unit test hardcoded POSIX paths and could only pass on Linux/macOS; expectations are now host-agnostic, with new regression tests for the USERPROFILE-derived tree and `tarPack` under absolute Windows-style paths ([#11443](https://github.com/diegosouzapw/OmniRoute/pull/11443)) — thanks @jonlwheat2-gif
|
||||
- fix(sse): stop the auto-combo candidates inspector from silently dropping model-locked/cooled-down rows (#9133) ([#11994](https://github.com/diegosouzapw/OmniRoute/pull/11994)) — thanks @hartmark
|
||||
- **fix(providers):** Perplexity Web no longer turns upstream stream failures into successful assistant text; pre-content failures remain eligible for fallback, partial output ends with a structured sanitized error, and failed sessions are not persisted ([#12465](https://github.com/diegosouzapw/OmniRoute/pull/12465))
|
||||
- **fix(video-bridge):** preserve chronological video and transcript fusion, source-frame timestamps, transcript provenance, full contact-sheet intervals, and pre-abort callback isolation ([#11681](https://github.com/diegosouzapw/OmniRoute/pull/11681))
|
||||
- **Z.ai Web:** HTTP 200 streams carrying an upstream error now terminate with a structured failure instead of assistant text plus a normal stop, preserving partial output while allowing pre-content combo fallback. ([#12454](https://github.com/diegosouzapw/OmniRoute/pull/12454))
|
||||
- Absorb `Error [AbortError]: request_signal_aborted` and DOMException AbortError shapes in the process-level client-abort crash guard so routine client disconnects no longer kill the server (exit code 7). ([#12165](https://github.com/diegosouzapw/OmniRoute/pull/12165)) — thanks @alvinveroy
|
||||
- **fix(sse):** Treat Adapta Web `type:error` SSE events as sanitized non-stream failures instead of empty HTTP 200 completions. ([#12459](https://github.com/diegosouzapw/OmniRoute/pull/12459))
|
||||
- **fix(autoCombo):** layer 3 (`models_dev_tier`) no longer scores retired models as premium, merge cross-provider capability rows deterministically, and inherit tier scores through variant ids ([#11508](https://github.com/diegosouzapw/OmniRoute/issues/11508)). `getModelsDevTierFitness` now vetoes ids present in `config/quality/model-lifecycle.json` with status `retired` before any other signal; `loadModelCapabilities()` aggregates per `model_id` (any-non-null-true for booleans, max for `limit_context`) instead of last-write-wins over SQLite's undefined row order; a capabilities miss retries through `resolveScoresAs()`'s base id and reports `models_dev_tier:inherited`, matching layers 1–2 (#11492). Adds `getModelsDevTierFitnessWithSource()` and an `invalidateCapabilitiesCache()` test/ops hook. ([#11598](https://github.com/diegosouzapw/OmniRoute/pull/11598)) — thanks @Meet6338-X
|
||||
- fix(sse): accept Claude `content:[]` + `stop_reason: max_tokens`/`tool_use` in `detectMalformedNonStream` (match `isEmptyContentResponse`) so Claude Code `/model` probes with `max_tokens: 1` no longer become a false 502 ([#11568](https://github.com/diegosouzapw/OmniRoute/pull/11568)) — thanks @Seramicx
|
||||
- **fix(security):** sanitize `request.failed` diagnostics before publishing them to live dashboard listeners and replay history, while keeping status, model, provider, latency, and internal call-log diagnostics intact. ([#12469](https://github.com/diegosouzapw/OmniRoute/pull/12469))
|
||||
- **fix(pwa):** do not intercept dashboard navigations so Chrome can fall back from a stale HTTP/3 Alt-Svc advertisement (UDP :20128 is unpublished; F5 on a long-lived tab hung until a new tab opened a fresh TCP connection). ([#12767](https://github.com/diegosouzapw/OmniRoute/pull/12767)) — thanks @HouMinXi
|
||||
- **fix(executors):** handle DuckDuckGo ERR_BN_LIMIT (418) without retrying — when the upstream returns `418 ERR_BN_LIMIT` (rate-limit/ban), the executor now returns the error immediately instead of burning another VQD acquisition that would only count against the IP limit. The retry logic for `418 ERR_CHALLENGE` (unsolved challenge) remains unchanged. ([#11598](https://github.com/diegosouzapw/OmniRoute/pull/11598)) ([#12110](https://github.com/diegosouzapw/OmniRoute/pull/12110)) — thanks @oyi77 / @Meet6338-X
|
||||
- Fixed DuckDuckGo streaming responses losing JSON lines and UTF-8 characters split across network chunks. ([#11528](https://github.com/diegosouzapw/OmniRoute/pull/11528)) — thanks @pacocartones
|
||||
- Harden generated Cloudflare, Vercel, and Deno relays by stripping hop-by-hop, framing, and proxy authentication headers before forwarding requests upstream. ([#11533](https://github.com/diegosouzapw/OmniRoute/pull/11533)) — thanks @pacocartones
|
||||
- **feat(grok-cli):** Provider Limits shows grok-cli banked reset credits from `GetRemainingResets` (including a real zero; a failed RPC omits the row) and the existing View credits button now calls `ConsumerUiSvc/RedeemReset` for grok-cli. Live tokens use nested fields 10/20/30 (id + Timestamp), not compact 1/2/3. ([#12805](https://github.com/diegosouzapw/OmniRoute/pull/12805)) — thanks @HouMinXi
|
||||
- **fix(memory):** Embedding Model Quick select, Embedding Source remote dropdown, and Rerank selector now list every configured provider with embedding/rerank support instead of only chat-catalog text matches plus OpenRouter live discovery; a generic OpenAI-compatible `/embeddings` + Cohere-compatible `/rerank` runtime fallback resolves any configured chat provider's embedding/rerank endpoint, so unlisted providers no longer fail with "Unknown embedding provider"; both memory selectors gained a free-text model override ([#11390](https://github.com/diegosouzapw/OmniRoute/pull/11390)) — thanks @rqzbeh
|
||||
- Harden SQLite upgrades around the historical migration-074 version collision: missing discovery and inspector tables are replayed atomically, pre-existing databases (including setup-created skeletons) receive reusable content-addressed safety snapshots, and Node test/eval probes without `DATA_DIR` are isolated from the operator database. ([#12435](https://github.com/diegosouzapw/OmniRoute/pull/12435))
|
||||
- **fix(dashboard):** Moonshot/Kimi Open Platform voucher and cash leftover percentages follow the bucket balance, so an empty wallet no longer paints those rows as 100% while Available is 0% ([#12733](https://github.com/diegosouzapw/OmniRoute/pull/12733)) — thanks @HouMinXi
|
||||
- **fix(combo):** return non-retryable HTTP 400 when all candidates for a pinned native Codex turn are unavailable due to model-scoped lockout, terminating the turn cleanly while preserving turn continuity and enabling standard Combo routing on subsequent turns ([#12240](https://github.com/diegosouzapw/OmniRoute/pull/12240)) — thanks @mdigitalbh81
|
||||
- Fixed auto-router strategies preserving the selected connection through ranking and dispatch when multiple connections share the same provider and model. ([#11530](https://github.com/diegosouzapw/OmniRoute/pull/11530)) — thanks @pacocartones
|
||||
- Preserve Chat Completions JSON-object response formats when translating requests to the Responses API. ([#11529](https://github.com/diegosouzapw/OmniRoute/pull/11529)) — thanks @pacocartones
|
||||
- Fix idempotency fingerprints so Responses API requests include `input` and other semantic generation fields using stable serialization, while excluding credentials and request metadata. ([#11532](https://github.com/diegosouzapw/OmniRoute/pull/11532)) — thanks @pacocartones
|
||||
- **fix(api):** Generated API CLI commands now enforce required OpenAPI request bodies; Combo test commands forward the required `comboName` body, while API keys created by older writers after migration 149 preserve legacy allow-all Combo access without widening explicit empty allowlists — thanks @marcelokarval ([#12070](https://github.com/diegosouzapw/OmniRoute/pull/12070))
|
||||
- **fix(grok-web):** treat upstream streaming failures as failures instead of successful assistant text: error-only streams now fail readiness with HTTP 502, while failures after legitimate content preserve that partial output and terminate through the sanitized stream failure path without a normal `stop` completion. ([#12458](https://github.com/diegosouzapw/OmniRoute/pull/12458))
|
||||
- HuggingChat now turns HTTP 200 JSONL generation failures into a sanitized 502 before content, or a fixed public stream failure after partial output, so fallback and request persistence no longer record a false successful stop. ([#12456](https://github.com/diegosouzapw/OmniRoute/pull/12456))
|
||||
- **fix(providers):** keep 1min.ai HTTP 200 stream errors out of assistant content, preserve partial output, and expose sanitized terminal errors so pre-content failures can fall back. ([#12466](https://github.com/diegosouzapw/OmniRoute/pull/12466))
|
||||
- **fix(sessions):** preserve legacy Sessions data when the additive exclusive-session projection is unavailable, returning an empty projection and warning only once per contiguous outage instead of failing the endpoint; the Sessions badge intentionally reflects the merged legacy and exclusive row count introduced by [#11389](https://github.com/diegosouzapw/OmniRoute/pull/11389) ([#11469](https://github.com/diegosouzapw/OmniRoute/pull/11469)) — thanks @KaspaPulse
|
||||
- Render Nimble Search with the generic provider icon and serve Opper's proven logo locally. ([#12415](https://github.com/diegosouzapw/OmniRoute/pull/12415))
|
||||
- **fix(quality):** `config/quality/provider-assets-provenance.jsonl` was stale against the physical `public/providers/` tree (still listing 225 assets and 5 duplicate-content groups after several provider-retirement PRs removed 85 icon files), which would have made `check:provider-asset-provenance` fail on the next full run. Reconciled the manifest to the real 142-file snapshot (142 asset records, 1 duplicate-content group, 2 newly-registered files marked `unresolved` pending provenance research), repinned `auditedCommit`, and updated the regression test's hardcoded counts to match. ([#11876](https://github.com/diegosouzapw/OmniRoute/pull/11876))
|
||||
- fix(ui): wrap quota card footer action buttons to prevent clipping Refresh button on narrow cards — when extra action buttons such as `View credits` are present on Codex cards, allow the footer row to flex-wrap with `shrink-0` buttons so `Refresh now` remains visible and clickable across all responsive card widths ([#11464](https://github.com/diegosouzapw/OmniRoute/pull/11464)) — thanks @adevwithpurpose
|
||||
- Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider. ([#12637](https://github.com/diegosouzapw/OmniRoute/pull/12637)) — thanks @HouMinXi
|
||||
- Fixed Tailscale tunnel setup to reject ports outside the valid 1-65535 range and safely install Windows MSI packages from paths containing apostrophes. ([#11534](https://github.com/diegosouzapw/OmniRoute/pull/11534)) — thanks @pacocartones
|
||||
- Classify Cloudflared and Tailscale process-management routes as local-only and spawn-capable while preserving remote access to read-only tunnel status endpoints. ([#11531](https://github.com/diegosouzapw/OmniRoute/pull/11531)) — thanks @pacocartones
|
||||
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets ([#11974](https://github.com/diegosouzapw/OmniRoute/pull/11974))
|
||||
- **fix(dashboard):** The Combos page usage guide now reads its dismissal through `useSyncExternalStore` instead of correcting SSR state inside an effect, removing an extra commit of the page tree on every load (and the `react-hooks/set-state-in-effect` error it raised). ([#12671](https://github.com/diegosouzapw/OmniRoute/pull/12671))
|
||||
- Electron release: `electron/package-lock.json` regained the optional `electron-builder-squirrel-windows` subtree (13 entries) that `npm ci` had been refusing as out of sync, `electron-release.yml` gained a `build_ref` dispatch input and stops regenerating release notes on a re-attach dispatch, and the npm publish workflow attaches the SBOM to the GitHub Release on dispatch publishes too — so the v3.8.51 tag ships every desktop asset and the SBOM like v3.8.49 did ([#12022](https://github.com/diegosouzapw/OmniRoute/pull/12022))
|
||||
- Drained the reds every PR against `release/v3.8.51` was born with: documented the three service-worker build-id variables, registered the six retirement/tunnel tests with the mutation gate, approved `eslint-plugin-react-hooks` in the dependency allowlist, added the six `combo.sort.*` strings to `vi` and `pt-BR`, pointed the ChatGPT Web doc at the real migration-168 test, worded the g4f hint around the member key, and realigned four tests to the retired-provider catalog and the legacy-schema fixtures the retirement migrations touch. ([#11940](https://github.com/diegosouzapw/OmniRoute/pull/11940))
|
||||
- **fix(providers):** Claude, Grok, LMArena, Notion, and Perplexity web-cookie transports now use pooled `wreq-js` 3.2 instead of the native sidecar, with all nine supported bindings pinned and audited, and the applicable platform binding plus native-license evidence included in each release artifact ([#12429](https://github.com/diegosouzapw/OmniRoute/pull/12429), supersedes [#11753](https://github.com/diegosouzapw/OmniRoute/pull/11753)).
|
||||
- **fix(providers):** Zed Hosted streaming failures now trigger fallback before content and end partial streams with a sanitized structured error instead of fake assistant text and a normal-success stop. ([#12455](https://github.com/diegosouzapw/OmniRoute/pull/12455))
|
||||
- **fix(test):** capture hook result after render (direct commit `b85d0abb0b`)
|
||||
- **fix(sync):** repair the two regressions the v3.8.50 sync-back left on release/v3.8.51 (direct commit `5ade9e0851`)
|
||||
- **revert(models):** drop the memo reset hook ported from #10055 (direct commit `282de841ae`)
|
||||
- **fix(typecheck):** guard the optional connectionId before the reactive model sync (direct commit `f2825bad75`)
|
||||
- **fix(quota-share):** release the winner's reserved in-flight slot (#11371) ([#11408](https://github.com/diegosouzapw/OmniRoute/pull/11408)) — thanks @oyi77
|
||||
- **fix(providers):** stop silently dropping reasoning effort on opencode families (#10788) ([#11409](https://github.com/diegosouzapw/OmniRoute/pull/11409)) — thanks @oyi77
|
||||
- **fix(combo):** enable genuine quota-aware routing for generic providers (antigravity, claude, etc.) ([#11411](https://github.com/diegosouzapw/OmniRoute/pull/11411)) — thanks @HouMinXi
|
||||
- **fix(oauth):** allow expired token retry before deactivation and activate expired retry loop (#P0) ([#11414](https://github.com/diegosouzapw/OmniRoute/pull/11414)) — thanks @HouMinXi
|
||||
- **perf(executors):** lazy-load the executor registry — defer class imports + construction to first use (#11220) ([#11421](https://github.com/diegosouzapw/OmniRoute/pull/11421)) — thanks @oyi77
|
||||
- **fix:** make release/v3.8.50 CI gates green (run 32786966560 root causes) ([#11450](https://github.com/diegosouzapw/OmniRoute/pull/11450)) — thanks @jonlwheat2-gif
|
||||
- **fix(sse):** exact-domain cookie match and origin-equality URL assertions ([#11452](https://github.com/diegosouzapw/OmniRoute/pull/11452)) — thanks @hartmark
|
||||
- **fix(providers):** send Responses API payload during OpenAI-compatible validation ([#11454](https://github.com/diegosouzapw/OmniRoute/pull/11454)) — thanks @tuandinh0801
|
||||
- **fix(command-code):** fallback to /alpha/generate for Go plan without Provider API access ([#11455](https://github.com/diegosouzapw/OmniRoute/pull/11455)) — thanks @Chewji9875
|
||||
- **fix(responses-continuation):** fail closed on a log-truncated stored input/output array ([#11473](https://github.com/diegosouzapw/OmniRoute/pull/11473)) — thanks @hartmark
|
||||
- **fix(cli):** write jcode settings as [providers.omniroute] in config.toml ([#11484](https://github.com/diegosouzapw/OmniRoute/pull/11484)) — thanks @hongnoul
|
||||
- **fix(logging):** raise chat-log array truncation cap to 1000 and unify duplicate implementations ([#11499](https://github.com/diegosouzapw/OmniRoute/pull/11499)) — thanks @hartmark
|
||||
- **fix:** restore live websocket URL helpers ([#11509](https://github.com/diegosouzapw/OmniRoute/pull/11509)) — thanks @PauloFH
|
||||
- **perf(core):** eliminate async waterfalls across home, cache, and db operations (#11396) ([#11512](https://github.com/diegosouzapw/OmniRoute/pull/11512)) — thanks @HouMinXi
|
||||
- **fix(kiro):** add runtime.us-east-1.kiro.dev as first-attempt endpoint ([#11517](https://github.com/diegosouzapw/OmniRoute/pull/11517)) — thanks @rifqiawl
|
||||
- **fix(providers):** remove duplicate 128-tool truncation in OpencodeExecutor (#11444) ([#11518](https://github.com/diegosouzapw/OmniRoute/pull/11518)) — thanks @HouMinXi
|
||||
- **fix(providers):** poll onboardUser LRO before classifying BYOP ([#11519](https://github.com/diegosouzapw/OmniRoute/pull/11519)) — thanks @rifqiawl
|
||||
- **fix(translator):** preserve exact tool name casing for Claude tool_use payloads (#11487) ([#11520](https://github.com/diegosouzapw/OmniRoute/pull/11520)) — thanks @HouMinXi
|
||||
- **fix(kimi-web):** align health probe with executor domain ([#11521](https://github.com/diegosouzapw/OmniRoute/pull/11521)) — thanks @pacocartones
|
||||
- **fix(cli):** preserve existing admin password during setup ([#11522](https://github.com/diegosouzapw/OmniRoute/pull/11522)) — thanks @pacocartones
|
||||
- **fix(mitm):** keep Windows DNS elevation off the POSIX sudo path (#11430) ([#11542](https://github.com/diegosouzapw/OmniRoute/pull/11542)) — thanks @official-burak
|
||||
- **fix(sse):** scale chat admission by ingest byte budget instead of a fixed request count ([#11548](https://github.com/diegosouzapw/OmniRoute/pull/11548)) — thanks @TheDemonTuan
|
||||
- **fix:** accept Gemini Web cookie exports ([#11554](https://github.com/diegosouzapw/OmniRoute/pull/11554)) — thanks @rafacpti23
|
||||
- **fix(i18n):** complete Brazilian Portuguese translations ([#11558](https://github.com/diegosouzapw/OmniRoute/pull/11558)) — thanks @rafacpti23
|
||||
- **fix(sse):** explicit types for openai-responses pureHelpers — clears last failing core typecheck gate ([#11567](https://github.com/diegosouzapw/OmniRoute/pull/11567)) — thanks @jonlwheat2-gif
|
||||
- **fix(opencode):** hydrate Proxy Pool references for no-auth connections ([#11584](https://github.com/diegosouzapw/OmniRoute/pull/11584)) — thanks @AStupidBear
|
||||
- **fix(ci):** resolve inherited release/v3.8.51 base-red CI failures ([#11608](https://github.com/diegosouzapw/OmniRoute/pull/11608)) — thanks @jonlwheat2-gif
|
||||
- **fix(db):** fallback load STORAGE_ENCRYPTION_KEY from env files ([#11614](https://github.com/diegosouzapw/OmniRoute/pull/11614)) — thanks @benzntech
|
||||
- **fix(providers):** mark Brave Search as no longer having a free tier ([#11621](https://github.com/diegosouzapw/OmniRoute/pull/11621)) — thanks @turbolego
|
||||
- **fix:** pass tool result content through as string in OpenAI/Claude -> Gemini translation ([#11624](https://github.com/diegosouzapw/OmniRoute/pull/11624)) — thanks @Hsia97
|
||||
- **fix(sse):** scope credential health to exact key ([#11628](https://github.com/diegosouzapw/OmniRoute/pull/11628)) — thanks @KaspaPulse
|
||||
- **fix(install):** guard prepare script against missing husky ([#11634](https://github.com/diegosouzapw/OmniRoute/pull/11634)) — thanks @Prajeeth-12
|
||||
- **fix(security):** redact NVIDIA_API_KEY at the log sink in the ad-hoc diag script ([#11639](https://github.com/diegosouzapw/OmniRoute/pull/11639))
|
||||
- **fix(types):** clear the two type regressions red on release/v3.8.51 ([#11640](https://github.com/diegosouzapw/OmniRoute/pull/11640)) — thanks @jonlwheat2-gif
|
||||
- **fix(build):** resolve workspace: protocol dependencies to concrete semver versions during prepublish (#11540) ([#11641](https://github.com/diegosouzapw/OmniRoute/pull/11641)) — thanks @HouMinXi
|
||||
- **fix(search):** properly resolve configured search connection in /v1/responses pipeline (#11524) ([#11642](https://github.com/diegosouzapw/OmniRoute/pull/11642)) — thanks @HouMinXi
|
||||
- **fix(authz):** trust cf-connecting-ip for via-proxy detection when peer is Cloudflare edge (#11514) ([#11643](https://github.com/diegosouzapw/OmniRoute/pull/11643)) — thanks @HouMinXi
|
||||
- **fix(security):** compare the OIDC state cookie in constant time ([#11646](https://github.com/diegosouzapw/OmniRoute/pull/11646))
|
||||
- **fix(security):** namespace the dedup hash by the calling API key ([#11649](https://github.com/diegosouzapw/OmniRoute/pull/11649))
|
||||
- **fix(models):** gate the Codex-native catalog loop on prefix mode (#11632) ([#11662](https://github.com/diegosouzapw/OmniRoute/pull/11662)) — thanks @xiaoyaner0201
|
||||
- **fix(dashboard):** project Codex routing quota in account pools ([#11666](https://github.com/diegosouzapw/OmniRoute/pull/11666)) — thanks @KaspaPulse
|
||||
- **fix(ddgw):** normaliza system no upstream da release v3.8.51 ([#11692](https://github.com/diegosouzapw/OmniRoute/pull/11692)) — thanks @RhianB14
|
||||
- **fix(memory):** honor category filter in GET /api/memory ([#11699](https://github.com/diegosouzapw/OmniRoute/pull/11699)) — thanks @Sabeekhann
|
||||
- **fix(ci):** detect stale workspace lockfile entries ([#11714](https://github.com/diegosouzapw/OmniRoute/pull/11714)) — thanks @pacocartones
|
||||
- **fix(ci):** stamp dist/BUILD_SHA before the npm publish provenance gate ([#11721](https://github.com/diegosouzapw/OmniRoute/pull/11721))
|
||||
- **fix(docker):** decouple the best-effort Bun image from the release manifest ([#11724](https://github.com/diegosouzapw/OmniRoute/pull/11724))
|
||||
- **perf(compression):** avoid cloning memo misses twice ([#11727](https://github.com/diegosouzapw/OmniRoute/pull/11727)) — thanks @RaviTharuma
|
||||
- **fix(docker):** let the bun digest artifact be absent, not fatal ([#11740](https://github.com/diegosouzapw/OmniRoute/pull/11740))
|
||||
- **fix(shared):** block cloud-metadata hosts under default remote-image guard ([#11755](https://github.com/diegosouzapw/OmniRoute/pull/11755)) — thanks @santosraju99-hub
|
||||
- **fix(api):** preserve caller-provided X-Correlation-Id on chat completions ([#11760](https://github.com/diegosouzapw/OmniRoute/pull/11760)) — thanks @aniruddhaadak80
|
||||
- **fix(resilience):** lock permanently retired models instead of short backoff (Gemini ban prevention) ([#11762](https://github.com/diegosouzapw/OmniRoute/pull/11762)) — thanks @turbolego
|
||||
- **fix:** mark Vercel AI Gateway as passthroughModels ([#11771](https://github.com/diegosouzapw/OmniRoute/pull/11771)) — thanks @Rahulsharma0810 / @Rahulsharma0810
|
||||
- **fix(resilience):** stop hammering permanently-moved endpoints and billing-suspended accounts ([#11774](https://github.com/diegosouzapw/OmniRoute/pull/11774)) — thanks @turbolego
|
||||
- **fix(lease):** remove global static reservation and gate routing on live active lease occupancy ([#11775](https://github.com/diegosouzapw/OmniRoute/pull/11775)) — thanks @TheDemonTuan
|
||||
- **fix(ci):** size the install-upgrade gate to a measured run, and log the pack cost ([#11776](https://github.com/diegosouzapw/OmniRoute/pull/11776))
|
||||
- **fix(pwa):** stop serving the stale shell after deploys ([#11779](https://github.com/diegosouzapw/OmniRoute/pull/11779)) — thanks @HouMinXi
|
||||
- **fix(combo):** lock GitHub models rejected as "not supported" for future requests ([#11781](https://github.com/diegosouzapw/OmniRoute/pull/11781)) — thanks @turbolego
|
||||
- **fix(cli):** probe both IPv4 and IPv6 loopback for server readiness (#11766) ([#11794](https://github.com/diegosouzapw/OmniRoute/pull/11794)) — thanks @vermasomesh835
|
||||
- **fix(sse):** preserve CLIProxyAPI wrappers per connection ([#11797](https://github.com/diegosouzapw/OmniRoute/pull/11797)) — thanks @RaviTharuma
|
||||
- **fix(providers):** honor model auto-fetch opt-in on create ([#11798](https://github.com/diegosouzapw/OmniRoute/pull/11798)) — thanks @RaviTharuma
|
||||
- **fix(dashboard):** make model auto-fetch order independent ([#11805](https://github.com/diegosouzapw/OmniRoute/pull/11805)) — thanks @RaviTharuma
|
||||
- **fix(docker):** make GHA cache exports best effort ([#11806](https://github.com/diegosouzapw/OmniRoute/pull/11806)) — thanks @RaviTharuma
|
||||
- **fix(kiro):** do not permanently ban on 'User is not authorized to make this call' ([#11809](https://github.com/diegosouzapw/OmniRoute/pull/11809)) — thanks @Deftera186
|
||||
- **fix(sse):** keep cache-write tokens in OpenAI-shaped usage ([#11814](https://github.com/diegosouzapw/OmniRoute/pull/11814)) — thanks @TheDemonTuan
|
||||
- **fix(docs):** resolve relative markdown and wiki links across Fumadocs and GitHub wiki ([#11834](https://github.com/diegosouzapw/OmniRoute/pull/11834)) — thanks @AndrianBalanescu
|
||||
- **fix(chat):** preserve unstripped model string for passthrough provider routing ([#11840](https://github.com/diegosouzapw/OmniRoute/pull/11840)) — thanks @raheemuddin786
|
||||
- **fix(mcp):** avoid async initialization inside sync esm wrappers (#11569) ([#11849](https://github.com/diegosouzapw/OmniRoute/pull/11849)) — thanks @HouMinXi
|
||||
- **fix(sse):** preserve coherent markdown token boundaries across stream translation chunks (#11606) ([#11851](https://github.com/diegosouzapw/OmniRoute/pull/11851)) — thanks @HouMinXi
|
||||
- **fix(ci):** run the install-upgrade gate on disk, not on the /tmp tmpfs ([#11855](https://github.com/diegosouzapw/OmniRoute/pull/11855))
|
||||
- **fix(skills):** expand shorthand property types in injected tool schemas ([#11857](https://github.com/diegosouzapw/OmniRoute/pull/11857)) — thanks @alvinveroy
|
||||
- **fix(ci):** publish npm from a hosted runner so provenance is accepted ([#11877](https://github.com/diegosouzapw/OmniRoute/pull/11877))
|
||||
- **fix(lint):** pin react hooks plugin to 7.0.1 ([#11886](https://github.com/diegosouzapw/OmniRoute/pull/11886))
|
||||
- **fix(ci):** surface and clear release-green ESLint failure ([#11890](https://github.com/diegosouzapw/OmniRoute/pull/11890)) — thanks @MumuTW
|
||||
- **fix(guardrails):** prevent duplicate prompt-injection-guard log output ([#11936](https://github.com/diegosouzapw/OmniRoute/pull/11936)) — thanks @patrykkopycinski
|
||||
- **fix(auth):** downgrade expected transient states from warn to debug ([#11937](https://github.com/diegosouzapw/OmniRoute/pull/11937)) — thanks @patrykkopycinski
|
||||
- **fix(codex):** fail over image generation for imported free plans ([#11948](https://github.com/diegosouzapw/OmniRoute/pull/11948)) — thanks @Bl0ck154
|
||||
- **fix(codex):** normalize non-stream responses ([#11951](https://github.com/diegosouzapw/OmniRoute/pull/11951)) — thanks @Bl0ck154
|
||||
- **fix(images):** forward Antigravity image size ([#11952](https://github.com/diegosouzapw/OmniRoute/pull/11952)) — thanks @Bl0ck154
|
||||
- **fix(db):** invalidate connection cache after upsert ([#11953](https://github.com/diegosouzapw/OmniRoute/pull/11953)) — thanks @Bl0ck154
|
||||
- **fix(codex):** restore imported account state ([#11954](https://github.com/diegosouzapw/OmniRoute/pull/11954)) — thanks @Bl0ck154
|
||||
- **fix(sse):** give extended-thinking targets the reasoning readiness budget ([#11959](https://github.com/diegosouzapw/OmniRoute/pull/11959)) — thanks @abhisheksharma2411
|
||||
- **fix(dashboard):** use opaque background and readable text color on cost chart tooltips ([#11960](https://github.com/diegosouzapw/OmniRoute/pull/11960)) — thanks @ZaimMarzuki
|
||||
- **fix(antigravity):** send complete loadCodeAssist metadata (ideType/platform/pluginType as numeric enums) ([#11969](https://github.com/diegosouzapw/OmniRoute/pull/11969)) — thanks @rifqiawl
|
||||
- **fix(core):** resolve DB init race condition and reasoning translation ([#12003](https://github.com/diegosouzapw/OmniRoute/pull/12003)) — thanks @raheemuddin786
|
||||
- **fix(admission):** exclude reclaimable page cache from the cgroup pressure ratio ([#12017](https://github.com/diegosouzapw/OmniRoute/pull/12017)) — thanks @HouMinXi
|
||||
- **fix(build):** prune non-production trees in NFT trace excludes and tsconfig ([#12028](https://github.com/diegosouzapw/OmniRoute/pull/12028)) — thanks @Chewji9875
|
||||
- **fix(migrations):** add renamed migration compatibility for 056/073/077/101 ([#12036](https://github.com/diegosouzapw/OmniRoute/pull/12036)) — thanks @oyi77
|
||||
- **fix(sse):** default crash-guard logger to console.warn, not console ([#12042](https://github.com/diegosouzapw/OmniRoute/pull/12042)) — thanks @HouMinXi
|
||||
- **fix(catalog):** derive vision/modalities for built-in auto combos from effective target pool ([#12046](https://github.com/diegosouzapw/OmniRoute/pull/12046)) — thanks @Prajeeth-12
|
||||
- **fix(release):** the packaged-app smoke verifies the database opened, not a driver line the primary path never prints (twin of #12032) ([#12047](https://github.com/diegosouzapw/OmniRoute/pull/12047))
|
||||
- **fix(barrel):** migrate src/app/ off the @/lib/localDb barrel import (#11795 Phase 2) ([#12051](https://github.com/diegosouzapw/OmniRoute/pull/12051)) — thanks @jonlwheat2-gif
|
||||
- **fix(barrel):** migrate src/lib/ off the localDb barrel to direct db imports (#59) ([#12052](https://github.com/diegosouzapw/OmniRoute/pull/12052)) — thanks @jonlwheat2-gif
|
||||
- **fix(barrel):** migrate open-sse, src/shared, src/sse, src/models, src/domain off the @/lib/localDb barrel import (#11795 Phase 4) ([#12053](https://github.com/diegosouzapw/OmniRoute/pull/12053)) — thanks @jonlwheat2-gif
|
||||
- **fix(barrel):** delete the @/lib/localDb barrel — every consumer migrated (#11795 Phase 5) ([#12055](https://github.com/diegosouzapw/OmniRoute/pull/12055)) — thanks @jonlwheat2-gif
|
||||
- **fix(radar):** restore D12 public boundary ([#12057](https://github.com/diegosouzapw/OmniRoute/pull/12057))
|
||||
- **fix(dev):** bound webpack and Tailwind scans ([#12075](https://github.com/diegosouzapw/OmniRoute/pull/12075)) — thanks @backryun
|
||||
- **fix(dev):** isolate root layout settings reads ([#12076](https://github.com/diegosouzapw/OmniRoute/pull/12076)) — thanks @backryun
|
||||
- **fix(dev):** reduce instrumentation executor fan-out (phase 3) ([#12078](https://github.com/diegosouzapw/OmniRoute/pull/12078)) — thanks @backryun
|
||||
- **fix(dev):** make logging resources HMR-singleton ([#12079](https://github.com/diegosouzapw/OmniRoute/pull/12079)) — thanks @backryun
|
||||
- **fix(sse):** keep unavailable forced connections scoped ([#12080](https://github.com/diegosouzapw/OmniRoute/pull/12080)) — thanks @keeltrace
|
||||
- **fix(dev):** isolate batch dispatch from instrumentation ([#12081](https://github.com/diegosouzapw/OmniRoute/pull/12081)) — thanks @backryun
|
||||
- **fix(ci):** reconcile release test contract drift ([#12082](https://github.com/diegosouzapw/OmniRoute/pull/12082)) — thanks @backryun
|
||||
- **fix(ci):** accept CVE-2025-68121 in the prebuilt tls-client .so, auto-close base-red issues, guard Scorecard on the default branch ([#12085](https://github.com/diegosouzapw/OmniRoute/pull/12085))
|
||||
- **fix(db):** drop three consumer-less 1proxy exports — dead-code base-red on release/v3.8.51 after the barrel deletion (#12055) ([#12087](https://github.com/diegosouzapw/OmniRoute/pull/12087))
|
||||
- **fix(api):** clear the six API-route TypeScript regressions the new gate landed red on ([#12094](https://github.com/diegosouzapw/OmniRoute/pull/12094))
|
||||
- **fix(release):** never let the tag-push Create Release append auto notes to the curated body ([#12096](https://github.com/diegosouzapw/OmniRoute/pull/12096))
|
||||
- **fix(sse):** honor CLIProxyAPI environment API key ([#12099](https://github.com/diegosouzapw/OmniRoute/pull/12099)) — thanks @RaviTharuma
|
||||
- **fix(oauth):** bind Google refresh to the client that issued the token ([#12106](https://github.com/diegosouzapw/OmniRoute/pull/12106)) — thanks @HouMinXi
|
||||
- **fix(dashboard):** make RequestLoggerDetail loadable outside Next — CSS via globals.css + CJS/ESM interop (#11703 base-reds) ([#12114](https://github.com/diegosouzapw/OmniRoute/pull/12114))
|
||||
- **fix(leases):** project status lease row to lease columns so joined connection PII never escapes ([#12115](https://github.com/diegosouzapw/OmniRoute/pull/12115)) — thanks @geek007git
|
||||
- **fix(plugins):** do not kill the plugin process when a fire-and-forget hook times out ([#12116](https://github.com/diegosouzapw/OmniRoute/pull/12116)) — thanks @geek007git
|
||||
- **fix(guardrails):** restore injection-guard logging on middleware-only routes ([#12117](https://github.com/diegosouzapw/OmniRoute/pull/12117)) — thanks @geek007git
|
||||
- **fix(plugins):** refresh stored manifest from disk on activate so new hook fields reach existing installs ([#12120](https://github.com/diegosouzapw/OmniRoute/pull/12120)) — thanks @geek007git
|
||||
- **fix(codex):** preserve existing provider state when bulk-import upserts a matching connection ([#12122](https://github.com/diegosouzapw/OmniRoute/pull/12122)) — thanks @geek007git
|
||||
- **fix(ci):** clear the two base-reds the 2026-08-30 merge batch left on release/v3.8.51 (round 3) ([#12123](https://github.com/diegosouzapw/OmniRoute/pull/12123))
|
||||
- **fix(sse):** strip type:'custom' from Claude tools on agentrouter dispatch ([#12126](https://github.com/diegosouzapw/OmniRoute/pull/12126)) — thanks @wahidsadik371-coder
|
||||
- **fix(sse):** stop advertising video providers the dispatcher cannot run ([#12131](https://github.com/diegosouzapw/OmniRoute/pull/12131)) — thanks @quiterunner-commits
|
||||
- **fix(search):** treat HTTP 432 and plan limit errors as transient cooldown ([#12139](https://github.com/diegosouzapw/OmniRoute/pull/12139)) — thanks @HouMinXi
|
||||
- **fix(sse):** refuse an AI Horde queue that cannot fit the request budget ([#12143](https://github.com/diegosouzapw/OmniRoute/pull/12143)) — thanks @quiterunner-commits
|
||||
- **fix(ci):** clear the base-reds the afternoon merge batch left on release/v3.8.51 (round 5: provider count 352, TS2554/TS2677) ([#12144](https://github.com/diegosouzapw/OmniRoute/pull/12144))
|
||||
- **fix(translator):** strip neutral tool_choice when tools absent in Responses-to-Chat (#12141) ([#12166](https://github.com/diegosouzapw/OmniRoute/pull/12166)) — thanks @HouMinXi
|
||||
- **fix(guardrails):** pass providerId to getResolvedModelCapabilities in checkComboVision (#12112) ([#12169](https://github.com/diegosouzapw/OmniRoute/pull/12169)) — thanks @HouMinXi
|
||||
- **fix(translator):** strip plaintext reasoning content for opaque responses backends (#12128) ([#12171](https://github.com/diegosouzapw/OmniRoute/pull/12171)) — thanks @HouMinXi
|
||||
- **perf(stream):** compile hot-path regexes once, bound token caches, fix quadratic buffering ([#12179](https://github.com/diegosouzapw/OmniRoute/pull/12179)) — thanks @opensource-elearning
|
||||
- **fix:** resolve compression worker file using runtime anchors instead of… ([#12183](https://github.com/diegosouzapw/OmniRoute/pull/12183)) — thanks @NightStalker-87
|
||||
- **fix:** prevent Claude Code session kills via liveness-aware readiness + auto model echo ([#12189](https://github.com/diegosouzapw/OmniRoute/pull/12189)) — thanks @opensource-elearning
|
||||
- **fix(translator):** preserve falsy primitive values in Gemini and Antigravity function response results ([#12191](https://github.com/diegosouzapw/OmniRoute/pull/12191)) — thanks @Siva010
|
||||
- **fix(oauth):** mark empty Antigravity projectId as degraded and clear stale errors (#11284) ([#12205](https://github.com/diegosouzapw/OmniRoute/pull/12205)) — thanks @HouMinXi
|
||||
- **fix(providers):** gate the Codex auto-ping usage read on the shared quota throttle ([#12209](https://github.com/diegosouzapw/OmniRoute/pull/12209)) — thanks @abhisheksharma2411
|
||||
- **fix(combos):** prioritize SQLite row id over inner JSON id and notify delete errors ([#12213](https://github.com/diegosouzapw/OmniRoute/pull/12213)) — thanks @HouMinXi
|
||||
- **fix(providers):** mark featherless.ai as no longer having a free tier (`hasFree: false`) ([#12216](https://github.com/diegosouzapw/OmniRoute/pull/12216)) — thanks @turbolego
|
||||
- **fix(deepseek-web):** stop Turbopack dev panic in the PoW worker path resolver ([#12221](https://github.com/diegosouzapw/OmniRoute/pull/12221)) — thanks @hartmark
|
||||
- **fix(chat-admission):** clarify local 503 source ([#12223](https://github.com/diegosouzapw/OmniRoute/pull/12223)) — thanks @keeltrace
|
||||
- **fix(dev):** silence webpack runtime module warnings ([#12228](https://github.com/diegosouzapw/OmniRoute/pull/12228)) — thanks @backryun
|
||||
- **fix(db):** use module.require for CommonJS runtime driver loading ([#12230](https://github.com/diegosouzapw/OmniRoute/pull/12230)) — thanks @raheemuddin786
|
||||
- **fix(memory):** honest probe-driven FTS5 keyword status + memory_id rowid sync ([#12231](https://github.com/diegosouzapw/OmniRoute/pull/12231)) — thanks @raheemuddin786
|
||||
- **fix(system):** propagate abort signal to stream reader in HTTP version checks ([#12232](https://github.com/diegosouzapw/OmniRoute/pull/12232)) — thanks @raheemuddin786
|
||||
- **fix(resilience):** isolate local host execution errors from provider circuit breakers ([#12233](https://github.com/diegosouzapw/OmniRoute/pull/12233)) — thanks @raheemuddin786
|
||||
- **fix(sse):** sort injected tools deterministically for prompt caching ([#12234](https://github.com/diegosouzapw/OmniRoute/pull/12234)) — thanks @raheemuddin786
|
||||
- **fix(combo):** always clear the loop-safety timer, not just on the happy path (#11804) ([#12245](https://github.com/diegosouzapw/OmniRoute/pull/12245))
|
||||
- **fix(quality):** base-red pair — stryker tap registration + turn-pin suites aligned to the window gate ([#12255](https://github.com/diegosouzapw/OmniRoute/pull/12255)) — thanks @backryun
|
||||
- **fix(dev):** qualify Turbopack runtime boundaries ([#12258](https://github.com/diegosouzapw/OmniRoute/pull/12258)) — thanks @backryun
|
||||
- **fix(combo):** fall back to full pool when collapsed sole survivor is context-too-small ([#12278](https://github.com/diegosouzapw/OmniRoute/pull/12278)) — thanks @opensource-elearning
|
||||
- **fix(combo):** bound the pre-dispatch unavailable skip so a stale label cannot dark a pool (#12168) ([#12285](https://github.com/diegosouzapw/OmniRoute/pull/12285))
|
||||
- **fix(security):** strip Qwen/Alibaba console-session cookies from provider API responses ([#12287](https://github.com/diegosouzapw/OmniRoute/pull/12287))
|
||||
- **fix(usage):** console-aware Token Plan guidance + subscription hint on bailian 401 ([#12288](https://github.com/diegosouzapw/OmniRoute/pull/12288))
|
||||
- **fix(resourcePressure):** log numeric detail on every rejection, recover faster ([#12293](https://github.com/diegosouzapw/OmniRoute/pull/12293)) — thanks @hartmark
|
||||
- **fix(release):** drain the 2026-09-01 base-red window — passthrough usage regression + radarPage i18n keys ([#12327](https://github.com/diegosouzapw/OmniRoute/pull/12327))
|
||||
- **fix(ci):** openapi-security-tiers checker must honor routeGuard patterns + imported prefixes ([#12350](https://github.com/diegosouzapw/OmniRoute/pull/12350)) — thanks @ggiak
|
||||
- **perf(ci):** cache node_modules in the npm-ci-retry composite (#8084 D3) ([#12408](https://github.com/diegosouzapw/OmniRoute/pull/12408))
|
||||
- **revert(ui):** point CTAs back at their real destinations ([#12410](https://github.com/diegosouzapw/OmniRoute/pull/12410))
|
||||
- **fix(release):** drain the 2026-09-02 base-red — rerank-providers import + api-typecheck baseline ratchet ([#12414](https://github.com/diegosouzapw/OmniRoute/pull/12414))
|
||||
- **fix(types):** make system prompt injection noImplicitAny-safe ([#12416](https://github.com/diegosouzapw/OmniRoute/pull/12416))
|
||||
- **fix(quality):** validate typecheck baseline schema ([#12419](https://github.com/diegosouzapw/OmniRoute/pull/12419))
|
||||
- **fix(memory):** resolve rerank provider node cache import ([#12421](https://github.com/diegosouzapw/OmniRoute/pull/12421))
|
||||
- **fix(ci):** repair release-root regressions — pack dedup, web-session syntax, uc-image ids ([#12423](https://github.com/diegosouzapw/OmniRoute/pull/12423)) — thanks @backryun
|
||||
- **fix(providers):** separate MaxAI and UC credential contracts ([#12431](https://github.com/diegosouzapw/OmniRoute/pull/12431))
|
||||
- **fix(cli):** preserve Claude settings on config set ([#12432](https://github.com/diegosouzapw/OmniRoute/pull/12432)) — thanks @KrzysiekSko
|
||||
- **fix(providers):** repair the maxai credential block truncated by merge auto-resolve ([#12433](https://github.com/diegosouzapw/OmniRoute/pull/12433))
|
||||
- **fix(quality):** record the 2026-09-02 merged growth in the file-size baseline ([#12434](https://github.com/diegosouzapw/OmniRoute/pull/12434))
|
||||
- **fix(uc):** route UC error strings through sanitizeErrorMessage; allowlist the retired codex id ([#12437](https://github.com/diegosouzapw/OmniRoute/pull/12437))
|
||||
- **fix(adapta):** redact streamed upstream errors ([#12438](https://github.com/diegosouzapw/OmniRoute/pull/12438))
|
||||
- **fix(sse):** remap non-contiguous upstream tool_calls index to a gap-free output_index ([#12445](https://github.com/diegosouzapw/OmniRoute/pull/12445)) — thanks @hartmark
|
||||
- **fix(quality):** detect duplicate tool_calls entries in one response ([#12446](https://github.com/diegosouzapw/OmniRoute/pull/12446)) — thanks @hartmark
|
||||
- **fix(conversations):** resolve turn content OmniRoute never sends back to the client ([#12447](https://github.com/diegosouzapw/OmniRoute/pull/12447)) — thanks @hartmark
|
||||
- **fix(combo):** do not treat credits-exhausted 401 as auth skip ([#12449](https://github.com/diegosouzapw/OmniRoute/pull/12449)) — thanks @RaviTharuma
|
||||
- **fix(auth):** do not park healthy quota accounts as expired ([#12452](https://github.com/diegosouzapw/OmniRoute/pull/12452)) — thanks @RaviTharuma
|
||||
- **fix(responses-continuation):** fail closed on a collector-truncated, empty output array ([#12460](https://github.com/diegosouzapw/OmniRoute/pull/12460)) — thanks @hartmark
|
||||
- **fix(logging):** raise the SSE payload collector's default cap ([#12461](https://github.com/diegosouzapw/OmniRoute/pull/12461)) — thanks @hartmark
|
||||
- **fix(resilience):** surface Responses failed.error.message in 502s ([#12472](https://github.com/diegosouzapw/OmniRoute/pull/12472)) — thanks @RaviTharuma
|
||||
- **fix(security):** unbiased maxai X-Random nonce + stricter URL/regex assertions ([#12502](https://github.com/diegosouzapw/OmniRoute/pull/12502))
|
||||
- **fix(video):** re-anchor transcript log-redaction so PII/credential maskers can't reopen the leak (#12150 P1 follow-up) ([#12503](https://github.com/diegosouzapw/OmniRoute/pull/12503))
|
||||
- **fix(monitoring):** serve cached credentialHealth off the request path ([#12533](https://github.com/diegosouzapw/OmniRoute/pull/12533)) — thanks @RaviTharuma
|
||||
- **fix(quota):** keep Antigravity Gemini usable when Claude weekly is empty ([#12566](https://github.com/diegosouzapw/OmniRoute/pull/12566)) — thanks @HouMinXi
|
||||
- **fix(authz):** hard-gate every credential export and CLI-config write (GHSA-5926-2w35-7h4q) ([#12600](https://github.com/diegosouzapw/OmniRoute/pull/12600))
|
||||
- **fix(docs):** restore the Next build — REMOVED_PROVIDERS.md had no frontmatter (base-red #12581) ([#12610](https://github.com/diegosouzapw/OmniRoute/pull/12610))
|
||||
- **fix(quality):** re-point the zcodeProtocol public-creds allowlist to line 313 ([#12615](https://github.com/diegosouzapw/OmniRoute/pull/12615))
|
||||
- **fix(security):** close 3 advisories — search baseUrl exfil, sk- in the error sanitizer, bifrost relay header leak ([#12620](https://github.com/diegosouzapw/OmniRoute/pull/12620))
|
||||
- **fix(providers):** sanitize boolean required and nested bare maps for Gemini (#12269) ([#12624](https://github.com/diegosouzapw/OmniRoute/pull/12624)) — thanks @HouMinXi
|
||||
- **fix(resilience):** keep Overloaded STREAM_EARLY_EOF off the provider breaker ([#12626](https://github.com/diegosouzapw/OmniRoute/pull/12626)) — thanks @HouMinXi
|
||||
- **fix(catalog):** re-audit free-tier quotas against official pages ([#12649](https://github.com/diegosouzapw/OmniRoute/pull/12649))
|
||||
- **fix(dashboard):** keep a request's pending-tracking id stable across combo target retries ([#12650](https://github.com/diegosouzapw/OmniRoute/pull/12650)) — thanks @hartmark
|
||||
- **fix(tests):** retire dead model ids from the chat-pipeline integration suite (base-red #12581) ([#12670](https://github.com/diegosouzapw/OmniRoute/pull/12670))
|
||||
- **fix:** resolve SqliteError no such table compression_run_telemetry during cleanup ([#12682](https://github.com/diegosouzapw/OmniRoute/pull/12682)) — thanks @groovecityJO
|
||||
- **fix(combo):** treat a pin-only step as implicit connection allowlist ([#12697](https://github.com/diegosouzapw/OmniRoute/pull/12697)) — thanks @HouMinXi
|
||||
- **fix(ci):** ship .npmrc in published package so legacy-peer-deps applies to consumers (#11544) ([#12699](https://github.com/diegosouzapw/OmniRoute/pull/12699)) — thanks @KooshaPari
|
||||
- **fix(docker):** pin 4 CLI tools to exact versions (#12576) ([#12703](https://github.com/diegosouzapw/OmniRoute/pull/12703)) — thanks @KooshaPari
|
||||
- **fix(docker):** document and harden cli profile trust boundary (#12570) ([#12706](https://github.com/diegosouzapw/OmniRoute/pull/12706)) — thanks @KooshaPari
|
||||
- **fix(sse):** redact video transcript in pre-guardrail rejected-request logs (#12150 P2 item 7) ([#12710](https://github.com/diegosouzapw/OmniRoute/pull/12710))
|
||||
- **fix(i18n):** quote <name> placeholder in OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES description (#12505) ([#12769](https://github.com/diegosouzapw/OmniRoute/pull/12769)) — thanks @KooshaPari
|
||||
- **fix(api/pricing):** surface validation error message as string, not raw object (#12494) ([#12771](https://github.com/diegosouzapw/OmniRoute/pull/12771)) — thanks @KooshaPari
|
||||
- **fix(sse):** re-enable prompt compression for native Codex passthrough ([#12834](https://github.com/diegosouzapw/OmniRoute/pull/12834)) — thanks @thomasmaerz
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
---
|
||||
- **docs(readme):** reconcile live v3.8.50 provider, free-tier, CLI, routing, test, community, sponsor, acknowledgment, and SVG metrics with their audited source denominators, including a deduplicated OmniRoute-in-Action snapshot and distinct contributor rankings for merged pull requests, GitHub-attributed commits, and Git history ([#11356](https://github.com/diegosouzapw/OmniRoute/pull/11356)). ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
|
||||
- **docs:** sync the canonical provider count 354 → 356 across `README.md`, `AGENTS.md`, `llm.txt` (+ 42 i18n mirrors), the four README SVG diagrams, `docs/reference/PROVIDER_REFERENCE.md` (regenerated) and the `package.json` description after Opper (#11629) and 1min.ai (#11631) boarded the catalog — closes the `check:docs-counts-sync` strict drifts that kept `release/v3.8.51` red ([#11449](https://github.com/diegosouzapw/OmniRoute/issues/11449)). ([#11673](https://github.com/diegosouzapw/OmniRoute/pull/11673)) — thanks @jonlwheat2-gif
|
||||
- **test(providers):** update count-derived assertions after the v3.8.51 provider additions — `APIKEY_PROVIDERS` 233 → 235 (Opper #11629 + 1min.ai #11631), reserved-prefix REGISTRY walk 395 → 398, `WEB_FETCH_PROVIDERS` now includes `nimble-search` (#11620), and the provider translate-path golden snapshot regenerated ([#11449](https://github.com/diegosouzapw/OmniRoute/issues/11449)). ([#11674](https://github.com/diegosouzapw/OmniRoute/pull/11674)) — thanks @jonlwheat2-gif
|
||||
- **test(sse):** bump the hard-lease connection-query inventory for `src/lib/tokenHealthCheck.ts` to 2 — the verify-only web-cookie sweep added by #11495 split the single `getProviderConnections` call into oauth + cookie variants, which the frozen inventory had not tracked ([#11495](https://github.com/diegosouzapw/OmniRoute/pull/11495)). ([#11677](https://github.com/diegosouzapw/OmniRoute/pull/11677)) — thanks @jonlwheat2-gif / @oyi77
|
||||
- **chore(release):** clear the `release/v3.8.51` base-red gates — docs migration count (+38 i18n mirrors), stryker `tap.testFiles`, lockfile registry host, stale ESLint suppressions, the 7 lint regressions they were hiding, the stale `APIKEY_PROVIDERS` partition count (231 → 233), the `glm-5.3-max` tier missing from the GLM test table, a dead `modelType === "chat"` comparison in the models catalog, and the `resolveLiveWsUrl` / `sanitizeLiveWsPort` helpers from #11388 that a later merge dropped from `wsPath.ts` (dashboard live WebSocket URL resolution) ([#11502](https://github.com/diegosouzapw/OmniRoute/pull/11502)) — thanks @MumuTW
|
||||
- **chore(free-models):** the free-tier regime table now declares which totals figure each regime feeds and whether it may take the no-auth shortcut, so the sets that used to repeat those answers by hand are derived from it — a new `freeType` no longer compiles until it has answered every question, instead of silently contributing to no total ([#11537](https://github.com/diegosouzapw/OmniRoute/pull/11537)) — thanks @maxmad64bis
|
||||
- **docs(routing):** LKGP strategy copy now states provider-level stickiness and the explicit rules fallback instead of promising per-target pinning ([#11555](https://github.com/diegosouzapw/OmniRoute/pull/11555)) — thanks @maxmad64bis
|
||||
- **test(chatcore):** pin the response tool-name alias resolution against the ordering hazard that broke every Gemini/Antigravity MCP tool call in v3.8.49. `extractRequestToolIdentityMap` deletes `translatedBody._toolNameMap`, so the later read is always undefined and the ledger survives only through the `requestToolIdentityMap` fallback — removing that fallback previously left the entire tool-name suite green. The resolution moves into `resolveResponseToolNameMap()` next to the map it depends on, with a regression guard that fails without the recovery. No behaviour change. ([#11575](https://github.com/diegosouzapw/OmniRoute/pull/11575)) — thanks @amartinawi
|
||||
- **docs(env):** document `OMNIROUTE_CORPUS_CACHE_SIZE` in `.env.example` and `ENVIRONMENT.md`, so the env/docs contract check stops failing Docs Gates on every branch ([#11576](https://github.com/diegosouzapw/OmniRoute/pull/11576)) — thanks @ntdatt812
|
||||
- **chore(stryker):** register `route-guard-tunnel-processes-local-only` and `universal-quota-aware-routing` in `tap.testFiles`, so their mutant kills count for the three modules they cover ([#11577](https://github.com/diegosouzapw/OmniRoute/pull/11577)) — thanks @ntdatt812
|
||||
- **test(authz):** pin `LOCAL_ONLY_API_GET_EXEMPTIONS` by exact membership instead of by entry count, so the guard names the offending path and also catches a substitution ([#11580](https://github.com/diegosouzapw/OmniRoute/pull/11580)) — thanks @ntdatt812
|
||||
- **test(build):** pin `resolveNextBuildBundlerFlag` to the `OMNIROUTE_USE_TURBOPACK` contract it implements, across runtimes, instead of asserting an unimplemented Bun override ([#11583](https://github.com/diegosouzapw/OmniRoute/pull/11583)) — thanks @ntdatt812
|
||||
- **test(claude-web):** the Claude Web transport test's barrel anchors no longer pin the exact construction spelling. #11421 made the executor map lazy, so `/"claude-web": new ClaudeWebExecutor\(\)/` stopped matching and the test went red on `release/v3.8.51` itself — a base-red gate every open PR inherited. The anchors now match the alias key and the class name and tolerate either spelling in between, and were checked to still fail when an alias is genuinely repointed at another executor ([#11585](https://github.com/diegosouzapw/OmniRoute/pull/11585)) — thanks @ntdat812
|
||||
- **test(models):** pin `qwen3.8-max` resolution per provider — two of the four catalogs now serve the bare id and no longer carry `-preview` ([#11590](https://github.com/diegosouzapw/OmniRoute/pull/11590)) — thanks @ntdatt812
|
||||
- **test(executors):** drop the stale 128-tool truncation assertion that contradicted the #11444 pass-through contract ([#11591](https://github.com/diegosouzapw/OmniRoute/pull/11591)) — thanks @ntdatt812
|
||||
- **test(token-health):** pin the current terminal-skip boundary — expired with retry budget is probed, `account_deactivated` stays skipped ([#11592](https://github.com/diegosouzapw/OmniRoute/pull/11592)) — thanks @ntdatt812
|
||||
- **test(models):** follow #11503's retarget of the `gemini-3-pro-high` alias to the hyphenated catalog id ([#11593](https://github.com/diegosouzapw/OmniRoute/pull/11593)) — thanks @ntdatt812
|
||||
- **refactor(video bridge):** extract per-video acquisition, whole-result caching, description, and metrics/abort/cleanup out of `VideoBridgeGuardrail.preCall` into a `processVideoPart` seam in a new `videoBridgePipeline.ts`, behind explicit `VideoMediaBrokerPort`, `VideoAudioTranscriptionPort`, and `VideoDrilldownPort` boundaries; `preCall` now only handles request traversal, policy, and response aggregation. The Video tab's FFmpeg/ffprobe runtime status is now an explicit `unknown` / `restricted` / `unavailable` / `available` state instead of a nullable boolean pair, fixing a case where an in-flight or failed probe was mislabeled as "install FFmpeg" ([#11657](https://github.com/diegosouzapw/OmniRoute/issues/11657)). ([#12016](https://github.com/diegosouzapw/OmniRoute/pull/12016))
|
||||
- **test(opencode-plugin):** add unit test coverage for `formatFreeBudget()` naming helper ([#11660](https://github.com/diegosouzapw/OmniRoute/pull/11660)) — thanks @f9td56dbgh-hub
|
||||
- **docs(video):** clarify that the Video Bridge transcript `source` field (`client`, `embedded`, `audio-bridge`) is presently caller-declared and not yet server-verified — OmniRoute enforces the enum shape but does not cryptographically confirm that an `embedded`/`audio-bridge` label came from a server-owned extraction ([#11661](https://github.com/diegosouzapw/OmniRoute/issues/11661)). ([#12001](https://github.com/diegosouzapw/OmniRoute/pull/12001))
|
||||
- **chore(providers):** retire the distributed Raycast Relay and Hailuo Web integrations after a provenance audit confirmed substantial derivation from GPL-3.0 sources; the official GitHub and MiniMax API providers remain available. Migration 163 disables legacy connections and invalidates their active leases while preserving connection IDs, audit/history rows, quota snapshots, usage records, and fail-closed API-key connection policies. ([#11691](https://github.com/diegosouzapw/OmniRoute/pull/11691))
|
||||
- **chore(providers):** retire the Felo Web provider while its GPL-derived provenance remains on licensing HOLD ([#11698](https://github.com/diegosouzapw/OmniRoute/pull/11698)) — stale runtime and database references now fail closed while existing connection, lease, and usage history remains preserved for auditability.
|
||||
- **fix(sse):** retire the Gemini Web image-generation bridge while provenance and licensing remain on HOLD, preserving Gemini Web chat/auth/tools and legitimate Gemini/Nano Banana image providers ([#11708](https://github.com/diegosouzapw/OmniRoute/pull/11708))
|
||||
- **chore(audio):** retire the built-in EdgeTTS provider and its 11-voice catalog while provenance/licensing review remains on HOLD; the generic `/v1/audio/speech` endpoint and the other speech providers remain available ([#11711](https://github.com/diegosouzapw/OmniRoute/pull/11711))
|
||||
- **fix(providers):** retire the unofficial Qwen Web integration and its `qwen-web`/`qw` identities while third-party provenance remains on HOLD ([#11713](https://github.com/diegosouzapw/OmniRoute/pull/11713)) — direct requests now fail closed with a sanitized HTTP 410 and combos skip the retired target, while migration guards preserve connection IDs, allowlists, usage/audit history and invalidate active leases. Supported Qwen API, Qwen Code, cloud, token-plan, audio, local and model-name surfaces remain available; restoring Qwen Web requires a qualified provenance/license decision.
|
||||
- **fix(providers):** retire `microsoft-designer-web` and its `msdesigner` alias from runtime and catalogs while preserving stored credentials, provider history and allowlists as inactive records — source dependency on gpt4free's [`MicrosoftDesigner.py`](https://github.com/xtekky/gpt4free/blob/0943d16eef757a44deb60651c07bbe5fa958cafe/g4f/Provider/needs_auth/MicrosoftDesigner.py) is proven at snapshot `0943d16eef757a44deb60651c07bbe5fa958cafe`, whose [`LICENSE`](https://github.com/xtekky/gpt4free/blob/0943d16eef757a44deb60651c07bbe5fa958cafe/LICENSE) is GPL-3.0; expressive adaptation is probable, which records provenance/licensing risk without asserting a legal conclusion ([#11720](https://github.com/diegosouzapw/OmniRoute/pull/11720)).
|
||||
- **docs(legal):** centralize pinned MIT notices for the vendored GCF codec and local flag assets ([#11726](https://github.com/diegosouzapw/OmniRoute/pull/11726))
|
||||
- Split the 2,952-line `adobeFireflyClient.ts` into nine focused modules (catalog, credentials, payload, ARP session, upload, responses, IMS, account, poll) behind an unchanged barrel — no consumer edits and no public API change (#11728). — thanks @MumuTW
|
||||
- **fix(dashboard):** replaced the Model Lockout card's unprovenanced notification MP3 with a short, locally synthesized Web Audio chime ([#11731](https://github.com/diegosouzapw/OmniRoute/pull/11731)) — both toggles retain optional audible feedback without shipping replacement media or third-party code, and unsupported, suspended, or rejected audio contexts fail open without blocking the setting change.
|
||||
- **chore(deepseek-web):** remove the provenance-unresolved DeepSeek PoW WASM binary and its runtime loader/tracing while retaining the existing JavaScript solver (slower at high difficulty) ([#11732](https://github.com/diegosouzapw/OmniRoute/pull/11732)).
|
||||
- **chore(assets):** remove four unused provider assets whose redistribution provenance could not be verified while retaining the active Freebuff SVG ([#11735](https://github.com/diegosouzapw/OmniRoute/pull/11735))
|
||||
- **docs(assets):** retired two raster images whose redistribution provenance could not be verified ([#11736](https://github.com/diegosouzapw/OmniRoute/pull/11736)) — PicoClaw documentation now reuses the existing SVG, while Zoo Code remains a text link without an image.
|
||||
- **docs(legal):** add pinned notices for six LobeHub-derived provider SVGs and 65 byte-exact theSVG assets, retaining registry claims as unverified metadata with explicit trademark and HOLD boundaries ([#11737](https://github.com/diegosouzapw/OmniRoute/pull/11737)).
|
||||
- **chore(quality):** added a versioned 225/225 provider-asset provenance manifest and blocking drift gate ([#11743](https://github.com/diegosouzapw/OmniRoute/pull/11743)) — the inventory pins magic MIME and SHA-256 for every physical file, records immutable source matches for 71 assets, keeps 69 probable and 85 unresolved entries explicit but non-blocking, and treats provenance as source evidence rather than copyright or trademark clearance.
|
||||
- **refactor(mitm):** replace four previously derived Traffic Inspector utilities — conversation normalization, SSE merging, secret masking, and header sanitization — with independent clean-room implementations based on public provider schemas, WHATWG SSE, and RFC 6750, while keeping the two independently classified passthrough surfaces unchanged ([#11748](https://github.com/diegosouzapw/OmniRoute/pull/11748)).
|
||||
- Added a unit test that fails seven days before any dated pack under `config/` (`validUntil` and sibling keys) lapses, naming the file and key. The Alibaba free-tier pack expired on 2026-08-27 and turned every PR red the next morning with no commit involved; renewal now happens on someone's terms, not the clock's. ([#11891](https://github.com/diegosouzapw/OmniRoute/pull/11891))
|
||||
- `check:workflows` now fails (under `--strict`/`--ratchet`) when any job routed to a self-hosted runner publishes with `--provenance` — npm rejects that with `422` at the registry, which in v3.8.50 only surfaced after the tag and Docker images were public. ([#11895](https://github.com/diegosouzapw/OmniRoute/pull/11895))
|
||||
- `scripts/ops/runner-janitor.sh` now proves a path is idle with one `lsof` snapshot and removes stale leftovers itself (tmpfs after 3 h — it is RAM — disk after 24 h), kills orphan `next-build` processes, prunes checkouts of stopped runners, and alerts on memory pressure; `--dry-run` shows exactly what it would do. `docs/ops/RUNNER_BOX.md` reconciled to the measured box (31 GB, 10 listeners). ([#11893](https://github.com/diegosouzapw/OmniRoute/pull/11893))
|
||||
- The `next-build` artefact (1.3 GB) is now written and read under `$RUNNER_TEMP` (per-runner, on disk) instead of `/tmp`, which on the self-hosted pool is a 12 GB tmpfs in RAM. Landing it there took 27–32 of the publish job's 76 minutes, and the fixed `/tmp/e2e-build.tar.gz` name let E2E jobs on different runners overwrite each other's download. ([#11896](https://github.com/diegosouzapw/OmniRoute/pull/11896))
|
||||
- The CI `build` job now runs in two concurrency lanes — `main` and pull requests — so a release build is never queued behind (or OOM-killed beside) PR builds on the self-hosted pool, which holds one `next-build` comfortably and two at the edge. ([#11901](https://github.com/diegosouzapw/OmniRoute/pull/11901))
|
||||
- Re-freeze the ESLint suppressions on `release/v3.8.51` from a clean-room measurement (2 stale file entries pruned, 55 pre-existing `no-explicit-any` in six new files frozen under #11924) and drop the dead `GPT_SIZE_MAP` constant orphaned by the Adobe Firefly client split, so `No new ESLint warnings` stops failing every PR with exit 2 (Refs #11924) ([#11955](https://github.com/diegosouzapw/OmniRoute/pull/11955))
|
||||
- Type the 55 `no-explicit-any` sites that had been frozen under #11924 — four redundant casts in `socksConnectorWithFamily.ts` (undici/socks types already accept them) and the mocks/fixtures of the socks-timeout and isFree suites — and drop their suppression entries; the ESLint ratchet shrinks from 5487 to 5432 (Closes #11924) ([#11975](https://github.com/diegosouzapw/OmniRoute/pull/11975))
|
||||
- Take the two hosted-runner builds off the PR rail: `Build App` (`build.yml`) is `workflow_dispatch`-only and `DAST smoke (PR)` runs only for PRs into `main` — the 7 GB hosted VM cannot build `release/v3.8.51` in any profile (19/30 red, VM shutdown ~8 min into `next build`) and both checks had turned into permanent noise on every release PR; the bundle stays validated by `ci.yml` on `main` and by `nightly-release-green` on `release/**` (Closes #11946) ([#11962](https://github.com/diegosouzapw/OmniRoute/pull/11962))
|
||||
- Move the four nightly jobs that build the backend (`nightly-schemathesis`, `nightly-llm-security` promptfoo + garak, `nightly-resilience` axe-a11y) off the hosted 7 GB runner — where they died on `release/v3.8.51` unseen — onto the box's new `omni-light` pool (two listeners), and document the reshaped fleet (4 active OmniRoute listeners: 2 `omni-build` + 2 `omni-light`, janitor ceiling 4) (Closes #11965) ([#11967](https://github.com/diegosouzapw/OmniRoute/pull/11967))
|
||||
- Stop painting every fork PR into `release/**` red: `quality.yml` `Build (advisory)` is skipped (GitHub still reports `continue-on-error` failures as check FAILURE). Hosted `ubuntu-latest` cannot finish `npm run build` on this tree — same class as #11962 taking `build.yml` off the PR rail. `docker-publish.yml` amd64 now runs on the `.113` `omni-build` pool (31 GB / 32 cores, two listeners) with Turbopack, shares the `heavy-build-main` lane with `ci.yml` `Build` so it queues instead of becoming a third heavy, and keeps per-ref concurrency (a merge storm was starting 8 concurrent OOM builds). arm64 stays on `ubuntu-24.04-arm` with webpack — there is no ARM box. Fallback when `USE_VPS_RUNNER` is off: hosted amd64 + webpack (#11976). ([#12021](https://github.com/diegosouzapw/OmniRoute/pull/12021))
|
||||
- `docker-publish.yml` builds with webpack (`OMNIROUTE_USE_TURBOPACK=0`) on **both** arches, including amd64 on the `.113` `omni-build` pool. Turbopack had the RAM (31 GB, `omniroute-113-6`) and still panicked (`TurbopackInternalError: there must be a path to a root`, run 33253576569, 26 min in). The same tree's arm64 webpack build on hosted `ubuntu-24.04-arm` succeeded (run 33264823398). Dockerfile already documents webpack as the Docker escape hatch (#11976). ([#12050](https://github.com/diegosouzapw/OmniRoute/pull/12050))
|
||||
- **docs(free-tier):** declare the counting vs deciding regimes for "is it free?" and guard the deciding path from DB-backed catalog resolution ([#12226](https://github.com/diegosouzapw/OmniRoute/pull/12226)) — thanks @maxmad64bis
|
||||
- **docs(auto-combo):** The mode pack table in `docs/routing/AUTO-COMBO.md` now lists all six shipped packs with every weight each one sets, replacing a four-pack table whose numbers had also drifted from the source. It states plainly that no pack sets `quality`, so selecting any pack silences the observed-quality signal. Six more documents that quote the scoring factor count joined the `check:docs-counts` gate, which caught five stale claims — including one naming nine factors that do not exist — and two stale mode pack counts. The dashboard routing panel, which offered four of the six packs and labelled the default strategy "6-Factor Scoring", is now covered by a test; the two packs it was missing are `reliability-first` and `chaos-mode`, the latter labelled as the fault-injection profile it is rather than as one more routing preference ([#12316](https://github.com/diegosouzapw/OmniRoute/pull/12316)) — thanks @maxmad64bis
|
||||
- **chore(quality):** rebaseline `src/lib/db/apiKeys.ts` for the ACL the key-creation path now preserves ([#12352](https://github.com/diegosouzapw/OmniRoute/pull/12352)) ([#12673](https://github.com/diegosouzapw/OmniRoute/pull/12673)) — thanks @KrzysiekSko
|
||||
- **chore(providers):** bump the Claude Code wire identity and the Devin bridge image pin from `2.1.220` to `2.1.258` ([#12402](https://github.com/diegosouzapw/OmniRoute/pull/12402)) — thanks @ggiak ([#12604](https://github.com/diegosouzapw/OmniRoute/pull/12604))
|
||||
- **docs(env):** align `.env.example`, the README Bun section, and the troubleshooting guide with the code: `OMNIROUTE_USE_TURBOPACK` also governs `npm run build` (not dev-only), `bun run build` follows that flag instead of auto-selecting Webpack, `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is unset by default (no request-count cap), and the structural `503 chat_admission_busy` message matches `chatAdmissionResponses.ts` (#12404 — thanks @pacocartones)
|
||||
- **chore(electron):** upgrade the desktop app to Electron 44 (Chromium 152, Node 24.18.1) ([#12217](https://github.com/diegosouzapw/OmniRoute/pull/12217)). **Requires macOS 13 (Ventura) or later** — Chromium dropped macOS 12 (Monterey), so Monterey users must stay on an earlier OmniRoute desktop build. Windows and Linux are unaffected; the app already shipped only x64/arm64, so Electron 44 dropping 32-bit builds changes nothing. Removes the `openAsHidden`/`wasOpenedAsHidden` login-item fields deleted in Electron 44 — hidden autostart continues to work through the `--hidden` argument registered with the login item ([#12554](https://github.com/diegosouzapw/OmniRoute/pull/12554)) — thanks @app/dependabot
|
||||
- **chore(quality):** rebaseline `src/sse/handlers/chat.ts` for the effective-input persistence the continuation fix needs ([#12641](https://github.com/diegosouzapw/OmniRoute/pull/12641)) ([#12680](https://github.com/diegosouzapw/OmniRoute/pull/12680)) — thanks @hartmark
|
||||
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it ([#11972](https://github.com/diegosouzapw/OmniRoute/pull/11972))
|
||||
- CI hardening on the PR rail: the four `Unit Tests fast-path` shards get `timeout-minutes: 30` (a hung shard held a PR for 64 min instead of GitHub's 6 h default) and both ESLint file caches lose their `restore-keys` fallback, so a cache built under another suppressions file or lint config can no longer report stale verdicts (Refs #11600, #11924) ([#11963](https://github.com/diegosouzapw/OmniRoute/pull/11963))
|
||||
- Every CI job that runs a `next build` (`build`, the npm `publish`, both release-green validations) now targets the `omni-build` runner label, which only two of the eight self-hosted runners carry. The box holds one build comfortably and two at the edge; a third now queues on GitHub instead of being OOM-killed by the kernel. ([#11932](https://github.com/diegosouzapw/OmniRoute/pull/11932))
|
||||
- **refactor(combo):** move `handleRoundRobinCombo` (and `resolveTargetTokenLimit`) into `open-sse/services/combo/roundRobinCombo.ts`. `combo.ts` drops from 2164 to 1014 lines (`split("\n").length`); the leaf is 1199 (under the 1200 new-file cap). The round-robin call site uses a dynamic `import()` so `releaseStickyPinOnFailure` / `clearStaleLKGP` can stay exported from `combo.ts` without a static cycle. Skip / sticky / semaphore / safety-timer behavior is unchanged. ([#12811](https://github.com/diegosouzapw/OmniRoute/pull/12811)) — thanks @HouMinXi
|
||||
- **chore(quality):** rebaseline the file-size caps the error-boundary campaign grew past (`open-sse/executors/codex.ts`, `open-sse/vendor/codex-chatgpt-web/bridge.ts`, both via [#12444](https://github.com/diegosouzapw/OmniRoute/pull/12444)) ([#12654](https://github.com/diegosouzapw/OmniRoute/pull/12654))
|
||||
- **chore(quality):** rebaseline the file-size caps the hartmark batch grew past (`combos/page.tsx` via [#12355](https://github.com/diegosouzapw/OmniRoute/pull/12355), `open-sse/services/combo.ts` via [#12338](https://github.com/diegosouzapw/OmniRoute/pull/12338)) ([#12623](https://github.com/diegosouzapw/OmniRoute/pull/12623)) — thanks @hartmark
|
||||
- **chore(quality):** rebaseline the file-size caps the HouMinXi batch grew past when its PRs stacked (`providers/page.tsx`, `chatCore.ts`, `accountFallback.ts`) — each PR measured correctly in isolation, none saw the stacking ([#12619](https://github.com/diegosouzapw/OmniRoute/pull/12619))
|
||||
- **chore(quality):** rebaseline `open-sse/services/combo.ts` for the reset-aware scoring the HouMinXi batch stacked ([#12637](https://github.com/diegosouzapw/OmniRoute/pull/12637)) ([#12678](https://github.com/diegosouzapw/OmniRoute/pull/12678)) — thanks @HouMinXi
|
||||
- **docs:** sync provider catalog count (direct commit `4f11b2ae3d`)
|
||||
- **docs(skills):** sync omni-inference search fallback (direct commit `b4e76a7ed9`)
|
||||
- **chore(lint):** prune two stale base suppressions (direct commit `1325047d56`)
|
||||
- **test(auto-combo):** tolerate full-suite rotation load (direct commit `ad3e293f9f`)
|
||||
- **test:** drain inherited quality snapshot reds (direct commit `21ed68d8ac`)
|
||||
- **chore(quality):** rebaseline measured CLI bundle growth (direct commit `d35b3f9779`)
|
||||
- **test(ui):** reproduce invalid AnySearch icon alias (direct commit `12cc6ca834`)
|
||||
- **test(translator):** fix the relative imports of the relocated deferred-finish test (direct commit `777d9d1629`)
|
||||
- **docs(counts):** bring the documented provider and migration counts to the measured values (direct commit `e70bea30e9`)
|
||||
- **test(security):** drop the guard-target set left unused by the sync (direct commit `a32ba0e362`)
|
||||
- **chore(db):** renumber model_capabilities migration 163 → 169 (cross-branch collision with 163_radar_feed_cache_generated_at) (direct commit `70f5d4cbf9`)
|
||||
- **docs(cli):** document CLI_PRIME_AGENT_BIN and correct the CLI Agents count (direct commit `9058e39b61`)
|
||||
- **chore(deps):** refresh runtimes and adopt ESLint 10 ([#11259](https://github.com/diegosouzapw/OmniRoute/pull/11259)) — thanks @backryun
|
||||
- **refactor(local-corpus):** implement dynamic root resolution and LRU cache ([#11491](https://github.com/diegosouzapw/OmniRoute/pull/11491)) — thanks @raheemuddin786
|
||||
- **chore(quality):** prune stale eslint-suppressions entries ([#11596](https://github.com/diegosouzapw/OmniRoute/pull/11596))
|
||||
- **chore(ci):** unblock quality gates - env-doc sync + stryker tap.testFiles registration (#9282) ([#11609](https://github.com/diegosouzapw/OmniRoute/pull/11609)) — thanks @Meet6338-X
|
||||
- **chore(ci):** point the circular-deps gate at dpdm's real JS entrypoint ([#11615](https://github.com/diegosouzapw/OmniRoute/pull/11615)) — thanks @benzntech
|
||||
- **chore(quality):** rebaseline file-size for #11448/#11495/#11561 growth ([#11630](https://github.com/diegosouzapw/OmniRoute/pull/11630))
|
||||
- **deps:** realign onnxruntime-node pin to @huggingface/transformers' 1.24.3 lockstep ([#11633](https://github.com/diegosouzapw/OmniRoute/pull/11633)) — thanks @jonlwheat2-gif
|
||||
- **test(autoCombo):** port models_dev_tier #11508 guard from node:test to vitest ([#11635](https://github.com/diegosouzapw/OmniRoute/pull/11635)) — thanks @jonlwheat2-gif
|
||||
- **chore(quality):** rebaseline file-size for #11083/#11631/#11628 growth ([#11636](https://github.com/diegosouzapw/OmniRoute/pull/11636))
|
||||
- **chore(lint):** prune stale ESLint suppressions (base-red #11449) ([#11644](https://github.com/diegosouzapw/OmniRoute/pull/11644)) — thanks @jonlwheat2-gif
|
||||
- **docs(changelog):** aggregate the ten v3.8.50 fragments into CHANGELOG.md ([#11683](https://github.com/diegosouzapw/OmniRoute/pull/11683))
|
||||
- **chore(search):** drop unreachable credential sweep after #11642 ([#11687](https://github.com/diegosouzapw/OmniRoute/pull/11687)) — thanks @HouMinXi
|
||||
- **ci:** add API route TypeScript regression gate ([#11705](https://github.com/diegosouzapw/OmniRoute/pull/11705)) — thanks @Sabeekhann
|
||||
- **docs(dedup):** correct the CodeQL claim on the tenant-namespace comment ([#11712](https://github.com/diegosouzapw/OmniRoute/pull/11712))
|
||||
- **docs(changelog):** add consolidated v3.8.50 stats, top-25 ranking and a .mailmap ([#11715](https://github.com/diegosouzapw/OmniRoute/pull/11715))
|
||||
- **docs:** recommend gstack for AI-assisted workflows ([#11770](https://github.com/diegosouzapw/OmniRoute/pull/11770)) — thanks @santosraju99-hub
|
||||
- **chore(quality):** tighten complexity/cognitive-complexity ratchets to the current tip; land the missed gateways.ts rebaseline ([#11771](https://github.com/diegosouzapw/OmniRoute/pull/11771)) — thanks @Rahulsharma0810 / @Rahulsharma0810
|
||||
- **docs(i18n):** finish Freepik → Magnific rebrand in locale strings and README ([#11772](https://github.com/diegosouzapw/OmniRoute/pull/11772)) — thanks @turbolego
|
||||
- **refactor(auto-combo):** fix divergent scoring in combo health reporting ([#11854](https://github.com/diegosouzapw/OmniRoute/pull/11854)) — thanks @jacobsparts
|
||||
- **chore(changelog):** add missing fragments for #11919/#11918/#11916 ([#11938](https://github.com/diegosouzapw/OmniRoute/pull/11938))
|
||||
- **test(check):** escape the runs-on fixture with JSON.stringify, not a quote-only replace ([#11942](https://github.com/diegosouzapw/OmniRoute/pull/11942)) (direct commit `fb7445eaa3`)
|
||||
- **test(release):** align five suites with the contracts #11933, #11919 and #11876 shipped on release/v3.8.51 ([#11944](https://github.com/diegosouzapw/OmniRoute/pull/11944))
|
||||
- **chore(quality):** remove unreachable code and restore test discovery ([#11950](https://github.com/diegosouzapw/OmniRoute/pull/11950)) — thanks @backryun
|
||||
- **docs(agents):** sync-back landings are fast-forward, never squash ([#11964](https://github.com/diegosouzapw/OmniRoute/pull/11964))
|
||||
- **test(infra):** retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) ([#11968](https://github.com/diegosouzapw/OmniRoute/pull/11968))
|
||||
- **docs(ops):** the .113 heavy-build ceiling is one runner, not two ([#12048](https://github.com/diegosouzapw/OmniRoute/pull/12048))
|
||||
- **test(ui):** correct inactive auto-fetch expectation ([#12098](https://github.com/diegosouzapw/OmniRoute/pull/12098)) — thanks @RaviTharuma
|
||||
- **docs:** document native dependency check escape hatch ([#12101](https://github.com/diegosouzapw/OmniRoute/pull/12101)) — thanks @RaviTharuma
|
||||
- **test(providers):** regenerate the translate-path golden for OrcaRouter (#11923) ([#12118](https://github.com/diegosouzapw/OmniRoute/pull/12118))
|
||||
- **chore(quality):** velocity phase — loosen every numeric baseline by 20% until v4.0, monitor headroom nightly ([#12125](https://github.com/diegosouzapw/OmniRoute/pull/12125))
|
||||
- **chore(lint):** batch 0 of #12146 — type the call-log-cap sqlite rows instead of 45 as-any casts ([#12157](https://github.com/diegosouzapw/OmniRoute/pull/12157))
|
||||
- **chore(lint):** batch 4 of #12146 — shared/components react-hooks violations resolved ([#12159](https://github.com/diegosouzapw/OmniRoute/pull/12159))
|
||||
- **chore(lint):** batch 1 of #12146 — dashboard/cli-code react-hooks violations resolved ([#12160](https://github.com/diegosouzapw/OmniRoute/pull/12160))
|
||||
- **chore(lint):** batch 3 of #12146 — dashboard/settings react-hooks violations resolved ([#12162](https://github.com/diegosouzapw/OmniRoute/pull/12162))
|
||||
- **chore(lint):** batch 2 of #12146 — resolve the react-hooks compiler violations in dashboard/providers ([#12163](https://github.com/diegosouzapw/OmniRoute/pull/12163))
|
||||
- **chore(quality):** register search-432 cooldown test in stryker tap.testFiles ([#12170](https://github.com/diegosouzapw/OmniRoute/pull/12170))
|
||||
- **chore(lint):** batch 5 of #12146 — combos, endpoint, provider-stats, api-manager and costs react-hooks violations resolved ([#12174](https://github.com/diegosouzapw/OmniRoute/pull/12174))
|
||||
- **build:** add contributor fast profile ([#12192](https://github.com/diegosouzapw/OmniRoute/pull/12192)) — thanks @rafacpti23
|
||||
- **docs(readme):** README touch-ups ([#12193](https://github.com/diegosouzapw/OmniRoute/pull/12193)) ([#12194](https://github.com/diegosouzapw/OmniRoute/pull/12194)) ([#12202](https://github.com/diegosouzapw/OmniRoute/pull/12202)) — thanks @rafacpti23
|
||||
- **build:** force the webpack bundler in the contributor build profile ([#12197](https://github.com/diegosouzapw/OmniRoute/pull/12197)) — thanks @rafacpti23
|
||||
- **build:** skip standalone packaging in the contributor profile ([#12198](https://github.com/diegosouzapw/OmniRoute/pull/12198)) — thanks @rafacpti23
|
||||
- **docs(audit):** align every published number with the code and harden check:docs-counts ([#12200](https://github.com/diegosouzapw/OmniRoute/pull/12200))
|
||||
- **build:** stub the instrumentation entrypoints in the contributor profile ([#12203](https://github.com/diegosouzapw/OmniRoute/pull/12203)) — thanks @rafacpti23
|
||||
- **build:** omit the standalone output target for contributor builds ([#12204](https://github.com/diegosouzapw/OmniRoute/pull/12204)) — thanks @rafacpti23
|
||||
- **chore(lint):** batch 6 of #12146 — memory, radar, audit, analytics, cache, usage, activity, home and RequestLoggerV2 react-hooks violations resolved ([#12208](https://github.com/diegosouzapw/OmniRoute/pull/12208))
|
||||
- **docs(diagrams):** rename number-carrying diagram files to stable names ([#12210](https://github.com/diegosouzapw/OmniRoute/pull/12210))
|
||||
- **docs(dashboard):** redraw onboarding tier-flow SVGs for the real 4-tier model ([#12211](https://github.com/diegosouzapw/OmniRoute/pull/12211))
|
||||
- **docs(api):** document every implemented route in openapi.yaml (276 → 692 paths) ([#12212](https://github.com/diegosouzapw/OmniRoute/pull/12212))
|
||||
- **chore(lint):** batch 7 of #12146 — final src tail: 37 react-hooks violations across 33 files resolved ([#12244](https://github.com/diegosouzapw/OmniRoute/pull/12244))
|
||||
- Revert "docs: recommend gstack for AI-assisted workflows (#11770)" ([#12248](https://github.com/diegosouzapw/OmniRoute/pull/12248)) ([#12249](https://github.com/diegosouzapw/OmniRoute/pull/12249))
|
||||
- **docs:** Chaos Mode setup guide + weighted strategy semantics ([#12250](https://github.com/diegosouzapw/OmniRoute/pull/12250))
|
||||
- **docs(agents):** protected-surface merge rule — operator approval for agent-instruction files ([#12253](https://github.com/diegosouzapw/OmniRoute/pull/12253))
|
||||
- **chore(quality):** register native-codex turn-pin tests in stryker tap.testFiles ([#12263](https://github.com/diegosouzapw/OmniRoute/pull/12263))
|
||||
- **chore(quality):** dedupe tap.testFiles entries added by racing base-red fixes ([#12265](https://github.com/diegosouzapw/OmniRoute/pull/12265))
|
||||
- **docs(readme):** introduce OmniRouteTray — the macOS menu-bar companion ([#12276](https://github.com/diegosouzapw/OmniRoute/pull/12276)) — thanks @ggiak
|
||||
- **chore(proxy):** purge the legacy 1proxy residue — sync/rotator modules, dead DB exports, dead settings tab and flag, docs (#12091) ([#12290](https://github.com/diegosouzapw/OmniRoute/pull/12290))
|
||||
- **chore(quality):** baseline-headroom skips generated and vendored files in the fileSize worst-file signal ([#12291](https://github.com/diegosouzapw/OmniRoute/pull/12291))
|
||||
- **refactor(video):** unify the JPEG frame data-URI contract ([#12322](https://github.com/diegosouzapw/OmniRoute/pull/12322))
|
||||
- **chore(deps):** freeze onnxruntime-node and eslint-plugin-react-hooks out of dependabot groups ([#12329](https://github.com/diegosouzapw/OmniRoute/pull/12329))
|
||||
- **test(executors):** restore the #10986 reasoning-only fallback guards ([#12364](https://github.com/diegosouzapw/OmniRoute/pull/12364)) — thanks @pacocartones
|
||||
- **chore(quality):** re-tighten the file-size ratchet to the real LOC (plan 3.8.52 task 0) ([#12411](https://github.com/diegosouzapw/OmniRoute/pull/12411))
|
||||
- **test(grok):** format web executor suite ([#12412](https://github.com/diegosouzapw/OmniRoute/pull/12412))
|
||||
- **chore(quality):** ratchet open-sse typecheck baseline to zero ([#12418](https://github.com/diegosouzapw/OmniRoute/pull/12418))
|
||||
- **test(build):** guard the artifact path policy arrays against duplicates ([#12422](https://github.com/diegosouzapw/OmniRoute/pull/12422))
|
||||
- **chore(lint):** adopt eslint-plugin-react-hooks 7.1.1 ([#12428](https://github.com/diegosouzapw/OmniRoute/pull/12428))
|
||||
- **chore(providers):** remove a keyless provider integration at its operator's request ([#12440](https://github.com/diegosouzapw/OmniRoute/pull/12440))
|
||||
- **docs(providers):** register providers removed at their operator's request and guard against reintroduction ([#12478](https://github.com/diegosouzapw/OmniRoute/pull/12478))
|
||||
- **chore(quality):** base fixes — stryker tap.testFiles + node_modules cache key ([#12482](https://github.com/diegosouzapw/OmniRoute/pull/12482))
|
||||
- **docs(arch):** one-process recipe for tens of long /v1/responses ([#12493](https://github.com/diegosouzapw/OmniRoute/pull/12493)) — thanks @RaviTharuma
|
||||
- **chore(quality):** tighten the CodeQL ratchet baseline from 11 to 6 ([#12530](https://github.com/diegosouzapw/OmniRoute/pull/12530))
|
||||
- **chore(deps):** pin browserslist override to ^4.28.8 ([#12592](https://github.com/diegosouzapw/OmniRoute/pull/12592)) — thanks @KooshaPari
|
||||
- **chore(deps):** bump fast-uri to 3.1.7 in the electron lockfile ([#12601](https://github.com/diegosouzapw/OmniRoute/pull/12601))
|
||||
- **docs(quality):** document how the CodeQL ratchet refreshes and how to tighten it ([#12611](https://github.com/diegosouzapw/OmniRoute/pull/12611))
|
||||
- **test(ci):** pin the openapi-security-tiers two-arm contract with an executing gate test (#12581) ([#12652](https://github.com/diegosouzapw/OmniRoute/pull/12652))
|
||||
- **docs(gamification):** align XP Rewards table with code (#12501) ([#12667](https://github.com/diegosouzapw/OmniRoute/pull/12667)) — thanks @KooshaPari
|
||||
- **refactor(combo):** split executeTarget into gates, attempt, and loop ([#12746](https://github.com/diegosouzapw/OmniRoute/pull/12746)) — thanks @HouMinXi
|
||||
- **docs(security):** document socket.yml scanner config + CI workflow link (#12575) ([#12764](https://github.com/diegosouzapw/OmniRoute/pull/12764)) — thanks @KooshaPari
|
||||
- **chore(ci):** guard commit identity in pre-commit to stop author misattribution ([#12772](https://github.com/diegosouzapw/OmniRoute/pull/12772))
|
||||
- **deps:** 17 Dependabot bumps — bump electron from 43.4.0 to 43.4.1 in /electron ([#11426](https://github.com/diegosouzapw/OmniRoute/pull/11426)); bump the development group across 1 directory with 8 updates ([#11428](https://github.com/diegosouzapw/OmniRoute/pull/11428)); bump the production group across 1 directory with 11 updates ([#11440](https://github.com/diegosouzapw/OmniRoute/pull/11440)); chore(deps): bump github/codeql-action/init from 4.37.7 to 4.37.8 ([#11925](https://github.com/diegosouzapw/OmniRoute/pull/11925)); chore(deps): bump github/codeql-action from 4.37.7 to 4.37.8 ([#11926](https://github.com/diegosouzapw/OmniRoute/pull/11926)); chore(deps): bump github/codeql-action/analyze from 4.37.7 to 4.37.8 ([#11927](https://github.com/diegosouzapw/OmniRoute/pull/11927)); build(deps): bump github/codeql-action/init from 4.37.8 to 4.37.9 ([#12345](https://github.com/diegosouzapw/OmniRoute/pull/12345)); build(deps): bump github/codeql-action/analyze from 4.37.8 to 4.37.9 ([#12346](https://github.com/diegosouzapw/OmniRoute/pull/12346)); bump the development group across 1 directory with 2 updates ([#12347](https://github.com/diegosouzapw/OmniRoute/pull/12347)); build(deps): bump github/codeql-action from 4.37.8 to 4.37.9 ([#12349](https://github.com/diegosouzapw/OmniRoute/pull/12349)); bump browserslist from 4.28.2 to 4.28.8 ([#12396](https://github.com/diegosouzapw/OmniRoute/pull/12396)); bump the production group across 1 directory with 4 updates ([#12399](https://github.com/diegosouzapw/OmniRoute/pull/12399)); bump @xmldom/xmldom ([#12500](https://github.com/diegosouzapw/OmniRoute/pull/12500)); bump qs from 6.15.2 to 6.16.0 ([#12512](https://github.com/diegosouzapw/OmniRoute/pull/12512)); bump @xmldom/xmldom from 0.9.10 to 0.9.12 ([#12513](https://github.com/diegosouzapw/OmniRoute/pull/12513)); bump fast-uri from 3.1.5 to 3.1.7 ([#12514](https://github.com/diegosouzapw/OmniRoute/pull/12514)); bump @humanfs/node from 0.16.7 to 0.16.8 ([#12515](https://github.com/diegosouzapw/OmniRoute/pull/12515))
|
||||
|
||||
### 🙌 Contributors
|
||||
|
||||
Thanks to everyone whose work landed in v3.8.51:
|
||||
|
||||
| Contributor | PRs / Issues |
|
||||
| --- | --- |
|
||||
| [@5dive-bot](https://github.com/5dive-bot) | #11852 |
|
||||
| [@abhisheksharma2411](https://github.com/abhisheksharma2411) | #11547, #11684, #11959, #12209 |
|
||||
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #11464 |
|
||||
| [@adivekar-utexas](https://github.com/adivekar-utexas) | #12015, #12043, #12090, #12138 |
|
||||
| [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #12103 |
|
||||
| [@alltomatos](https://github.com/alltomatos) | #11448 |
|
||||
| [@alvinveroy](https://github.com/alvinveroy) | #11857, #12027, #12165 |
|
||||
| [@amaleta](https://github.com/amaleta) | #11906, #11934 |
|
||||
| [@amartinawi](https://github.com/amartinawi) | #11575 |
|
||||
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #11083, #11830, #11834, #11844 |
|
||||
| [@aniruddhaadak80](https://github.com/aniruddhaadak80) | #11760 |
|
||||
| [@app](https://github.com/app) | #12554 |
|
||||
| [@arminanton](https://github.com/arminanton) | #11461, #11513 |
|
||||
| [@AStupidBear](https://github.com/AStupidBear) | #11584 |
|
||||
| [@atescivitci-cmd](https://github.com/atescivitci-cmd) | #12105 |
|
||||
| [@b3nw](https://github.com/b3nw) | #11970, #11971, #12147 |
|
||||
| [@backryun](https://github.com/backryun) | #11259, #11950, #12075, #12076, #12078, #12079, #12081, #12082, #12181, #12228, #12239, #12255, #12258, #12277, #12367, #12423, #12524, #12538 |
|
||||
| [@benjaminkitt](https://github.com/benjaminkitt) | #11747 |
|
||||
| [@benzntech](https://github.com/benzntech) | #11614, #11615 |
|
||||
| [@Bl0ck154](https://github.com/Bl0ck154) | #11948, #11951, #11952, #11953, #11954 |
|
||||
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #11752, #12242, #12262 |
|
||||
| [@Chewji9875](https://github.com/Chewji9875) | #11455, #11875, #12028 |
|
||||
| [@cheynetom](https://github.com/cheynetom) | #12058 |
|
||||
| [@davidebaraldo](https://github.com/davidebaraldo) | #12222, #12304 |
|
||||
| [@davidlinfr](https://github.com/davidlinfr) | #6390 |
|
||||
| [@ddarkr](https://github.com/ddarkr) | #12124 |
|
||||
| [@Deftera186](https://github.com/Deftera186) | #11809 |
|
||||
| [@dpozimski](https://github.com/dpozimski) | #11945 |
|
||||
| [@DW-MediaLab](https://github.com/DW-MediaLab) | #12229 |
|
||||
| [@echel0nn](https://github.com/echel0nn) | #11923 |
|
||||
| [@f9td56dbgh-hub](https://github.com/f9td56dbgh-hub) | #11660 |
|
||||
| [@fabioluissilva](https://github.com/fabioluissilva) | #11991 |
|
||||
| [@foreveryh](https://github.com/foreveryh) | #12177 |
|
||||
| [@geek007git](https://github.com/geek007git) | #12115, #12116, #12117, #12120, #12122 |
|
||||
| [@geekyNads](https://github.com/geekyNads) | #11784, #12266 |
|
||||
| [@ggiak](https://github.com/ggiak) | #11561, #12276, #12350, #12402 |
|
||||
| [@gonisulaimann](https://github.com/gonisulaimann) | #12368, #12369, #12371 |
|
||||
| [@Gorillaz322](https://github.com/Gorillaz322) | #12207 |
|
||||
| [@groovecityJO](https://github.com/groovecityJO) | #12682 |
|
||||
| [@hartmark](https://github.com/hartmark) | #11434, #11452, #11473, #11499, #11703, #11983, #11984, #11985, #11986, #11988, #11989, #11990, #11994, #12221, #12293, #12445, #12446, #12447, #12448, #12460, #12461, #12623, #12646, #12650, #12680 |
|
||||
| [@hizzt](https://github.com/hizzt) | #11894 |
|
||||
| [@hongnoul](https://github.com/hongnoul) | #11484 |
|
||||
| [@HouMinXi](https://github.com/HouMinXi) | #11411, #11414, #11512, #11518, #11520, #11641, #11642, #11643, #11687, #11779, #11849, #11850, #11851, #11915, #11916, #11918, #11919, #11920, #12013, #12017, #12033, #12042, #12106, #12139, #12166, #12169, #12171, #12205, #12213, #12312, #12325, #12487, #12488, #12495, #12504, #12557, #12566, #12590, #12591, #12624, #12626, #12632, #12637, #12678, #12696, #12697, #12711, #12733, #12746, #12767, #12770, #12789, #12803, #12805, #12811, #12866, #12868, #12899, #12926, #12934 |
|
||||
| [@Hsia97](https://github.com/Hsia97) | #11624 |
|
||||
| [@jacobsparts](https://github.com/jacobsparts) | #11854, #12155, #12167 |
|
||||
| [@joglomedia](https://github.com/joglomedia) | #11980 |
|
||||
| [@jonlwheat2-gif](https://github.com/jonlwheat2-gif) | #11443, #11450, #11549, #11567, #11608, #11633, #11635, #11640, #11644, #11671, #11672, #11673, #11674, #11675, #11676, #11677, #11706, #11783, #12051, #12052, #12053, #12055 |
|
||||
| [@kanade-hoshino](https://github.com/kanade-hoshino) | #12180 |
|
||||
| [@Karan825](https://github.com/Karan825) | #11861 |
|
||||
| [@KaspaPulse](https://github.com/KaspaPulse) | #11389, #11469, #11628, #11666, #11910 |
|
||||
| [@keeltrace](https://github.com/keeltrace) | #12080, #12223 |
|
||||
| [@killer30001000](https://github.com/killer30001000) | #12178 |
|
||||
| [@KooshaPari](https://github.com/KooshaPari) | #12470, #12592, #12667, #12699, #12703, #12706, #12764, #12769, #12771 |
|
||||
| [@kriptoburak](https://github.com/kriptoburak) | #11370 |
|
||||
| [@KrzysiekSko](https://github.com/KrzysiekSko) | #12425, #12432, #12673 |
|
||||
| [@marcelokarval](https://github.com/marcelokarval) | direct commit / report |
|
||||
| [@maxmad64bis](https://github.com/maxmad64bis) | #1622, #11435, #11437, #11441, #11537, #11550, #11553, #11555, #11557, #11812, #11842, #11843, #11903, #12151, #12214, #12215, #12218, #12226, #12314, #12316, #12317, #12318, #12319, #12320, #12321, #12870 |
|
||||
| [@mdigitalbh81](https://github.com/mdigitalbh81) | #12240 |
|
||||
| [@Meet6338-X](https://github.com/Meet6338-X) | #11598, #11609, #12110 |
|
||||
| [@morpheus9393](https://github.com/morpheus9393) | #11943 |
|
||||
| [@MumuTW](https://github.com/MumuTW) | #11492, #11502, #11506, #11507, #11626, #11675, #11685, #11728, #11746, #11888, #11889, #11890, #11892 |
|
||||
| [@Neuron-Mr-White](https://github.com/Neuron-Mr-White) | #11622, #11801, #12256 |
|
||||
| [@NightStalker-87](https://github.com/NightStalker-87) | #12183 |
|
||||
| [@NoxzRCW](https://github.com/NoxzRCW) | #11879, #11880, #11881, #11882, #11883, #11935 |
|
||||
| [@ntdat812](https://github.com/ntdat812) | #11585, #12095 |
|
||||
| [@ntdatt812](https://github.com/ntdatt812) | #11368, #11573, #11574, #11576, #11577, #11580, #11582, #11583, #11588, #11589, #11590, #11591, #11592, #11593, #11672, #12177, #12180 |
|
||||
| [@official-burak](https://github.com/official-burak) | #11542 |
|
||||
| [@opensource-elearning](https://github.com/opensource-elearning) | #12179, #12189, #12278, #12286 |
|
||||
| [@oyi77](https://github.com/oyi77) | #11408, #11409, #11421, #11505, #11677, #12036, #12110 |
|
||||
| [@pacocartones](https://github.com/pacocartones) | #11521, #11522, #11527, #11528, #11529, #11530, #11531, #11532, #11533, #11534, #11595, #11599, #11603, #11604, #11605, #11607, #11610, #11676, #11714, #11716, #11718, #11767, #11838, #11860, #11862, #11869, #11871, #11872, #11873, #11903, #11906, #11921, #11934, #12359, #12360, #12361, #12362, #12364, #12365, #12373, #12374, #12375, #12376, #12377, #12379, #12380, #12381, #12386, #12387, #12389, #12390, #12394, #12395, #12397, #12401, #12403, #12404 |
|
||||
| [@patrykkopycinski](https://github.com/patrykkopycinski) | #11936, #11937, #12224 |
|
||||
| [@PauloFH](https://github.com/PauloFH) | #11509 |
|
||||
| [@PauloHSOliveira](https://github.com/PauloHSOliveira) | #12241 |
|
||||
| [@ponkcore](https://github.com/ponkcore) | #12054 |
|
||||
| [@Prajeeth-12](https://github.com/Prajeeth-12) | #11634, #12046 |
|
||||
| [@quiterunner-commits](https://github.com/quiterunner-commits) | #12131, #12143 |
|
||||
| [@rafacpti23](https://github.com/rafacpti23) | #11554, #11558, #12192, #12197, #12198, #12202, #12203, #12204 |
|
||||
| [@ragnar-claude](https://github.com/ragnar-claude) | #11564 |
|
||||
| [@raheemuddin786](https://github.com/raheemuddin786) | #11491, #11839, #11840, #11841, #12003, #12230, #12231, #12232, #12233, #12234 |
|
||||
| [@rahilmavani](https://github.com/rahilmavani) | #11761 |
|
||||
| [@Rahulsharma0810](https://github.com/Rahulsharma0810) | #11771 |
|
||||
| [@RaviTharuma](https://github.com/RaviTharuma) | #11710, #11727, #11797, #11798, #11802, #11805, #11806, #11811, #12098, #12099, #12101, #12449, #12452, #12472, #12473, #12493, #12533, #12607, #12628, #12631, #12636 |
|
||||
| [@rezjalibd](https://github.com/rezjalibd) | #12186 |
|
||||
| [@RhianB14](https://github.com/RhianB14) | #11692 |
|
||||
| [@rifqiawl](https://github.com/rifqiawl) | #11517, #11519, #11969 |
|
||||
| [@rqzbeh](https://github.com/rqzbeh) | #11390 |
|
||||
| [@Sabeekhann](https://github.com/Sabeekhann) | #11699, #11705 |
|
||||
| [@santosraju99-hub](https://github.com/santosraju99-hub) | #11755, #11770 |
|
||||
| [@Seramicx](https://github.com/Seramicx) | #11568 |
|
||||
| [@Siva010](https://github.com/Siva010) | #12191 |
|
||||
| [@solstxce](https://github.com/solstxce) | #11597 |
|
||||
| [@soroush5](https://github.com/soroush5) | #12691 |
|
||||
| [@tenshiak](https://github.com/tenshiak) | #12279 |
|
||||
| [@TheDemonTuan](https://github.com/TheDemonTuan) | #11468, #11470, #11471, #11482, #11548, #11758, #11775, #11814 |
|
||||
| [@thomasmaerz](https://github.com/thomasmaerz) | #12834 |
|
||||
| [@tuandinh0801](https://github.com/tuandinh0801) | #11454 |
|
||||
| [@turbolego](https://github.com/turbolego) | #11621, #11762, #11772, #11774, #11781, #12216 |
|
||||
| [@Tushar49](https://github.com/Tushar49) | #11392 |
|
||||
| [@ujjawalkaushik1110](https://github.com/ujjawalkaushik1110) | #11863 |
|
||||
| [@vermasomesh835](https://github.com/vermasomesh835) | #11794 |
|
||||
| [@vsd2807](https://github.com/vsd2807) | #11565, #11619 |
|
||||
| [@wahidsadik371-coder](https://github.com/wahidsadik371-coder) | #12126 |
|
||||
| [@watchingdogs](https://github.com/watchingdogs) | #12031 |
|
||||
| [@wildcard](https://github.com/wildcard) | #11620 |
|
||||
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #11460, #11662, #12083 |
|
||||
| [@Xxx91n](https://github.com/Xxx91n) | #11690 |
|
||||
| [@yourspraveen](https://github.com/yourspraveen) | #11146 |
|
||||
| [@yxyxy](https://github.com/yxyxy) | #11671 |
|
||||
| [@ZaimMarzuki](https://github.com/ZaimMarzuki) | #11960 |
|
||||
| [@Zartharas](https://github.com/Zartharas) | #11340 |
|
||||
| [@zero-executioner](https://github.com/zero-executioner) | #11631 |
|
||||
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
|
||||
|
||||
---
|
||||
## [3.8.50] — 2026-08-25
|
||||
|
||||
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._
|
||||
@@ -716,7 +1585,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm
|
||||
- **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun
|
||||
- **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun
|
||||
- **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun
|
||||
- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun
|
||||
- **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun
|
||||
- **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun
|
||||
- **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose
|
||||
@@ -888,7 +1756,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm
|
||||
- **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225))
|
||||
- **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White
|
||||
- **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)).
|
||||
- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `<model>-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White
|
||||
- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `<model>-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White
|
||||
- **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233))
|
||||
- **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234))
|
||||
- **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244)
|
||||
@@ -3000,7 +3868,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
|
||||
- Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn
|
||||
- Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn
|
||||
- Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn
|
||||
- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn
|
||||
- Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn
|
||||
- Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn
|
||||
- Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn
|
||||
@@ -3442,7 +4309,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
|
||||
- **fix(cli):** `omniroute serve` no longer hangs silently on a readiness timeout ([#6321](https://github.com/diegosouzapw/OmniRoute/issues/6321)) — the child server's stdout was piped to `"ignore"` whenever `--log`/`OMNIROUTE_SHOW_LOG` wasn't set (the default), discarding any debug output, and `runWithSupervisor`'s `waitForServer(...).then((up) => { if (up) {...} })` had no `else` branch, so a boot that never became ready produced zero further output after "⏳ Starting server...". Stdout is now buffered alongside stderr (`ServerSupervisor.getRecentLog()`), and a timeout prints a clear diagnostic plus the buffered output instead of staying silent. Does not by itself explain why boot never completes on a given machine — see the issue for further reproduction. Regression guard: `tests/unit/cli-serve-readiness-timeout-6321.test.ts`.
|
||||
- **fix(pricing):** Pricing Sync dashboard no longer stuck on "Next Sync: Never" / "Synced Models: 0" ([#6325](https://github.com/diegosouzapw/OmniRoute/issues/6325)) — `pricingSync.ts` kept sync state (`lastSyncTime`, `lastSyncModelCount`) in module-level vars, but the background periodic sync (`instrumentation-node.ts`) and the dashboard status route (`/api/pricing/sync`) each import the module from separate Next.js standalone webpack chunks, giving each its own independent state; `getSyncStatus()` read the (empty) API-route instance's vars. Sync status is now additionally persisted to a new `pricing_sync_status` `key_value` namespace and `getSyncStatus()` falls back to it when the local module instance never ran a sync itself. Regression guard: `tests/unit/pricing-sync-cross-instance.test.ts`.
|
||||
- **fix(api):** stop spuriously 403-ing "Invalid request origin" on `POST /api/providers/health-autopilot/actions` for Docker/LAN dashboard requests ([#6277](https://github.com/diegosouzapw/OmniRoute/issues/6277)) — the route carried a duplicate per-route `validateBrowserMutationOrigin` check re-added by the v3.8.42 release squash after PR #5278 centralized origin enforcement in the authz pipeline; the pipeline strips `PEER_IP_HEADER` before forwarding, so the stale duplicate check could no longer resolve the LAN "direct-local-host" candidate and rejected legitimate same-origin LAN mutations (e.g. clicking "remove cooldown" when accessed via a LAN IP). Removed the duplicate check — origin validation is now solely enforced by the centralized pipeline check, which already handles this case correctly. Regression guard: `tests/unit/serial/provider-health-autopilot.test.ts`.
|
||||
- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:"none"`) provider like mimocode or theoldllm no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:"banned"` with no cooldown or retry. The exemption now also covers `authType:"none"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`.
|
||||
- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:"none"`) provider like mimocode no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:"banned"` with no cooldown or retry. The exemption now also covers `authType:"none"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`.
|
||||
- **fix(providers):** the **Auggie (Augment CLI)** executor no longer fails on Windows with `spawn EINVAL` ([#6304](https://github.com/diegosouzapw/OmniRoute/issues/6304)) — the global-npm install exposes `auggie` as a `.cmd` shim, which Node's `child_process.spawn` cannot launch on win32 without `shell: true`. Both spawn sites (streaming + the `auggie --version` test) now go through a shared `buildAuggieSpawnOptions()` that sets `shell: process.platform === "win32"`; the argv (built by `buildAuggieArgs()` with a registry-validated `model` and a trailing `--` end-of-options marker) is unchanged, so the argument-injection surface stays closed on non-Windows. Regression guard: `tests/unit/auggie-win32-spawn-6304.test.ts`.
|
||||
- **fix(api):** the dashboard **"Test model"** action is now a clean connection test ([#6240](https://github.com/diegosouzapw/OmniRoute/issues/6240)) — `modelTestRunner` sent its probe request without an explicit compression override, so whenever the operator's global `compression.enabled` flag was on the test call inherited compression (and any Output-Styles system prompt), polluting the result. The internal test requests now send `X-OmniRoute-Compression: off`, and `chatCore` honors an explicit `off` header even when `compression.enabled` is globally true. Regression guards: `tests/unit/model-test-runner-compression-off-6240.test.ts`, `tests/integration/test-model-compression-off-6240.test.ts`.
|
||||
- **fix(startup):** an update/restart could crash the whole server at boot with `TypeError: Cannot create property 'message' on string 'Database closed'`, masking the real failure and 500-ing every request until manually restarted ([#6560](https://github.com/diegosouzapw/OmniRoute/issues/6560), plausibly the root cause of #6594's post-upgrade 500) — `driverFactory.ts::preInitSqlJs()` cached its sql.js WASM adapter per file path in a `globalThis`-backed map for idempotency, but never checked whether the cached adapter had since been closed (e.g. by `gracefulShutdown`/`resetDbInstance` racing a reload); reusing that dead handle made the very next query throw sql.js's own bare string `"Database closed"` (not an `Error`) straight out of `instrumentation-node.ts`'s previously-unguarded `ensureDbInitialized()` call, and Next.js's internal `registerInstrumentation()` wrapper unconditionally does `err.message = ...` on whatever `register()` rejects with — assigning `.message` on a primitive string throws in strict mode, so the secondary `TypeError` is what actually crashed the process. Fixed in two parts: `preInitSqlJs()` now evicts a closed cached adapter and creates a fresh one instead of returning it; a new `ensureDbReadyForBoot()` wraps the DB-init call, normalizes any non-Error throw via `normalizeBootError()`, and retries once specifically for a transient "database closed" message (now succeeding against the fresh adapter) before re-throwing anything else as a real `Error`. Regression guard: `tests/unit/instrumentation-database-closed-6560.test.ts`.
|
||||
@@ -3998,7 +4865,6 @@ Thanks to everyone whose work landed in v3.8.45:
|
||||
- **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari)
|
||||
- **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari)
|
||||
- **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari)
|
||||
- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs)
|
||||
- **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel)
|
||||
- **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127)
|
||||
- **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831))
|
||||
@@ -5644,7 +6510,6 @@ Thanks to everyone whose work landed in v3.8.43:
|
||||
- **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219))
|
||||
- **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern)
|
||||
- **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern)
|
||||
- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa)
|
||||
- **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32)
|
||||
- **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33)
|
||||
- **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228))
|
||||
@@ -6304,7 +7169,6 @@ Thanks to everyone whose work landed in v3.8.43:
|
||||
- **fix(catalog):** Codex CLI model-catalog refresh no longer errors — `GET /v1/models` now returns a top-level `models: []` array for Codex clients (detected via the `originator` / `user-agent` = `codex_*` headers it sends on `GET /v1/models?client_version=...`), so `codex_models_manager` stops failing to decode the OpenAI-standard response and no longer logs `failed to refresh available models` on every startup. The array is intentionally empty: Codex replaces its built-in per-model agent prompt (`base_instructions`, ~21k chars) with whatever a populated entry carries for the selected model, so emitting our catalog would break Codex's agent behaviour — an empty list keeps Codex on its built-in model info (same inference as before, minus the error). Non-Codex OpenAI clients receive the unchanged `{object,data}` response. ([#3481](https://github.com/diegosouzapw/OmniRoute/pull/3481) — thanks @diegosouzapw)
|
||||
- **fix(provider):** Cursor's Responses-API-shaped bodies on `/chat/completions` are detected and handled — a body with `input` but no `messages` is now classified as `openai-responses` (instead of forcing `openai` and building from undefined `messages` → upstream 400); standard OpenAI clients are unaffected by the `messages===undefined` guard. ([#3490](https://github.com/diegosouzapw/OmniRoute/pull/3490) — thanks @borodulin)
|
||||
- **fix(sse):** numeric provider IDs normalized to strings across 4 more surfaces — extends #3427 to the Responses-API SSE passthrough (`response_id`/`item_id`/`call_id`), the buffered/flush path in `stream.ts`, the dedup-key builders, and `sseParser.ts`, preventing `undefined` lookups when IDs arrive as numbers. ([#3451](https://github.com/diegosouzapw/OmniRoute/pull/3451) — thanks @disafronov)
|
||||
- **fix(theoldllm):** `X-Request-Token` generated server-side, dropping the Playwright dependency — replicates the site's client `rie()` token (djb2 hash + `oldllm-client-2026` seed + UA prefix + 8-hex `crypto.randomUUID` suffix) directly, so The Old LLM no longer needs a headless browser to mint tokens. ([#3491](https://github.com/diegosouzapw/OmniRoute/pull/3491) — thanks @borodulin / @diegosouzapw)
|
||||
- **fix(combo):** parallel pre-screen + circuit-breaker fast-exit for priority combos — provider profiles and model availability for all targets are pre-screened concurrently (max 5), and targets whose circuit breaker is OPEN are skipped immediately, reducing first-token latency on multi-target priority combos. ([#3169](https://github.com/diegosouzapw/OmniRoute/pull/3169) — thanks @pizzav-xyz)
|
||||
- **fix(authz):** URL-tokenized client endpoints (`/api/v1/vscode/<key>/...`) authenticate again when the caller sends its own non-OmniRoute `Authorization` header — a non-`Bearer <token>` header (e.g. VS Code Copilot's own, or an empty `Bearer `) no longer short-circuits auth; it falls through to the path-scoped URL token (still validated downstream), instead of 401'ing under `REQUIRE_API_KEY=true`. ([#3504](https://github.com/diegosouzapw/OmniRoute/pull/3504) — thanks @zhiru / @diegosouzapw)
|
||||
- **fix(playground):** the dashboard provider Test playground works under `REQUIRE_API_KEY=true` — it previously sent the **masked** key (`sk-xxxx****yyyy`) as a bearer (always invalid → 401). It now authenticates via the dashboard session and sends only the key **id** (`x-omniroute-playground-key-id`); the gateway resolves the secret server-side, honored **only** for an authenticated session and never putting the key secret on the wire. ([#3503](https://github.com/diegosouzapw/OmniRoute/pull/3503) — thanks @zhiru / @diegosouzapw)
|
||||
@@ -6337,7 +7201,7 @@ Thanks to everyone whose work landed in v3.8.43:
|
||||
|
||||
- **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id"` — the OpenAI-style `id` field is stripped from `functionCall`/`functionResponse` parts for `vertex`/`vertex-partner`; the public Gemini API still receives `id` as required for Gemini 3+ signature matching. ([#3457](https://github.com/diegosouzapw/OmniRoute/pull/3457) — thanks @nullbytef0x / @diegosouzapw)
|
||||
- **fix(claude):** Claude Code `claude-opus-4-8` tool calls no longer break with `tool call could not be parsed` — OmniRoute no longer force-injects `interleaved-thinking` / `advanced-tool-use` / `effort` beta flags the client never negotiated; clients sending their own `anthropic-beta` header control those betas themselves. ([#3458](https://github.com/diegosouzapw/OmniRoute/pull/3458) — thanks @Forcerecon / @diegosouzapw)
|
||||
- **fix(catalog):** imported/custom models on no-auth providers (e.g. The Old LLM) now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw)
|
||||
- **fix(catalog):** imported/custom models on no-auth providers now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw)
|
||||
- **fix(browser):** optional `cloakbrowser` import no longer causes bundle errors when the package is absent — the import is now wrapped in a dynamic require so the build succeeds on environments that don't install the optional dep. ([#3460](https://github.com/diegosouzapw/OmniRoute/pull/3460) — thanks @rdself)
|
||||
- **fix(claude-web):** claude-web session handling cleanup — corrects an edge case where session cookies were not properly refreshed after a Turnstile challenge, and removes stale wrapper code left over from the provider split. ([#3449](https://github.com/diegosouzapw/OmniRoute/pull/3449) — thanks @androw)
|
||||
- **fix(analytics):** SQL named params are now scoped per query context — a shared params object was being mutated across concurrent analytics queries, causing `SQLITE_MISUSE: named parameter not found` errors under load. ([#3447](https://github.com/diegosouzapw/OmniRoute/pull/3447) — thanks @ReqX)
|
||||
@@ -6513,8 +7377,7 @@ Thanks to everyone whose work landed in v3.8.14:
|
||||
|
||||
- **fix(dashboard):** Agent Bridge page (`/dashboard/tools/agent-bridge`) no longer crashes with "Internal Server Error" — the page replaced its well-shaped state with the raw `/api/tools/agent-bridge/state` response (`{ server, agents }`), leaving `serverState` undefined and throwing `Cannot read properties of undefined (reading 'running')`. A shared `normalizeAgentBridgeState()` now maps the route shape into the page contract (incl. `server.certExists → certTrusted`) and always returns safe defaults, used by both the SSR loader and the polling hook. (#3318 — thanks @tycronk20)
|
||||
- **fix(codex):** strip client-only params (`prompt_cache_retention`, `safety_identifier`, `user`) on the native `codex/` `/v1/responses` passthrough — Codex upstream rejects them with `400 Unsupported parameter`, which broke Factory Droid and any client injecting those fields. The chat-completions path already stripped them; the responses→responses passthrough now does too. (#3317 — thanks @tycronk20)
|
||||
- **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path — the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 — thanks @onizukashonan14-png)
|
||||
- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta)
|
||||
- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta)
|
||||
- **fix(dashboard):** refresh the connection list after a Codex/Claude/Gemini auth import — the import modals called `fetchData()` (which only reloads provider metadata), so a freshly-imported connection stayed invisible until a manual reload; they now call `fetchConnections()`. ([#3320](https://github.com/diegosouzapw/OmniRoute/pull/3320) — thanks @zhiru)
|
||||
- **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → _"Could not determine current version"_), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → _"Failed to create backup. Aborting"_. (#3295 — thanks @uniQta)
|
||||
- **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev)
|
||||
@@ -6606,7 +7469,6 @@ Thanks to everyone whose work landed in v3.8.12:
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **theoldllm:** add The Old LLM — a free, Playwright-backed provider with dual-mode operation (cached browser token + direct fetch) bridged through a Vercel relay (#3217 — thanks @oyi77)
|
||||
- **codex:** add Codex login via OpenAI's browser-driven device authorization flow, exposed as a shareable "Adicionar Externo" public link (`/connect/codex/{token}`) so a third party can complete the OpenAI device login without dashboard access (#3195 — thanks @zhiru)
|
||||
- **proxy:** per-connection proxy distribution — `proxy_enabled` DB schema + Zod-validated resolution backend, automatic proxy-fallback selection when provider validation hits a network error, and a dashboard UI with per-connection toggles and a tag-filtered "Distribute Proxies" button (#3170, #3171, #3172 — thanks @pizzav-xyz)
|
||||
- **api:** `/v1/images/generations` and `/v1/images/edits` now resolve a bare combo/alias model name (e.g. `image`) to its single image target, and `/v1/images/edits` forwards multipart edits to custom OpenAI-compatible providers' `{base_url}/images/edits` (also accepting JSON/data-URL edit input) instead of rejecting everything but chatgpt-web (#3214, #3215 — thanks @ngocquynh85)
|
||||
|
||||
28
Dockerfile
28
Dockerfile
@@ -103,25 +103,12 @@ RUN test -f package-lock.json \
|
||||
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
|
||||
# instead of `npx --yes`, which would install an arbitrary registry version
|
||||
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
|
||||
#
|
||||
# tls-client-node (claude-web/grok-web/lmarena/perplexity-web TLS
|
||||
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
|
||||
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
|
||||
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
|
||||
# better-sqlite3 above, that script never throws on failure — it only
|
||||
# `console.warn`s and exits 0 — so a rate-limited or offline build would
|
||||
# otherwise succeed silently with an empty bin/ and only fail at first request
|
||||
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
|
||||
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
|
||||
# broken image.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& (cd node_modules/better-sqlite3 \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()" \
|
||||
&& node node_modules/tls-client-node/scripts/postinstall.js \
|
||||
&& (test -n "$(find node_modules/tls-client-node/bin -mindepth 1 -print -quit 2>/dev/null)" \
|
||||
|| (echo "tls-client-node native binary missing after postinstall — GitHub API fetch likely rate-limited or failed (#7802)" >&2 && exit 1))
|
||||
&& node -e "const wreq=require('wreq-js'); if(typeof wreq.createTransport!=='function') process.exit(1)"
|
||||
|
||||
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
|
||||
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a
|
||||
@@ -344,7 +331,18 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
|
||||
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
# Pinned to exact versions per Diego's diagnosis in #12576 — floating
|
||||
# `@latest` causes two CI failures:
|
||||
# 1. `openclaw` ships a breaking major ~weekly; overnight builds silently
|
||||
# advance to a version that no longer matches the tested combo stack.
|
||||
# 2. `codex` / `claude-code` dev pre-releases (`@next`, dist-tags) mutate
|
||||
# API surface without notice; reproducible builds need a SHA-pinned dev
|
||||
# build, not the floating `@latest`.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
npm install -g --no-audit --no-fund \
|
||||
@openai/codex@0.153.2 \
|
||||
@anthropic-ai/claude-code@2.1.260 \
|
||||
droid@0.212.0 \
|
||||
openclaw@2026.9.1
|
||||
|
||||
USER node
|
||||
|
||||
@@ -31,10 +31,8 @@ COPY scripts/dev/sync-env.mjs ./scripts/dev/sync-env.mjs
|
||||
# Fast Bun native package install
|
||||
RUN bun install --include=optional --quiet
|
||||
|
||||
# Fetch tls-client-node native binary if script exists
|
||||
RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ] && [ ! -d "node_modules/tls-client-node/bin" ]; then \
|
||||
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
|
||||
fi
|
||||
# Fail the build if wreq-js cannot resolve its current platform binding.
|
||||
RUN bun -e "const wreq = require('wreq-js'); if (typeof wreq.createTransport !== 'function') process.exit(1)"
|
||||
|
||||
# Smoke check native database driver used by Bun (bun:sqlite)
|
||||
RUN bun -e "import { Database } from 'bun:sqlite'; const db = new Database(':memory:'); db.query('SELECT 1 AS ok').get(); db.close(); console.log('bun:sqlite smoke: OK');"
|
||||
|
||||
68
README.md
68
README.md
@@ -7,19 +7,19 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 352 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. 352 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 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."/>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 💰 ~1.51B Free Tokens / Month
|
||||
## 💰 ~1.47B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **446 free-tier entries across 38 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. 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 **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`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 38 documented recurring pool keys covering 446 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M 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.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."/>
|
||||
|
||||
> 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 43 languages</b>
|
||||
<b>🌐 In 42 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>
|
||||
@@ -164,7 +164,6 @@
|
||||
<a href="docs/i18n/id/README.md"><img src="docs/assets/flags/id.svg" width="30" alt="Bahasa Indonesia (id)" title="Bahasa Indonesia (id)"></a>
|
||||
<a href="docs/i18n/ms/README.md"><img src="docs/assets/flags/my.svg" width="30" alt="Bahasa Melayu (ms)" title="Bahasa Melayu (ms)"></a>
|
||||
<a href="docs/i18n/phi/README.md"><img src="docs/assets/flags/ph.svg" width="30" alt="Filipino (phi)" title="Filipino (phi)"></a>
|
||||
<a href="docs/i18n/in/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="हिन्दी (in)" title="हिन्दी (in)"></a>
|
||||
<a href="docs/i18n/hi/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="हिन्दी (hi)" title="हिन्दी (hi)"></a>
|
||||
<a href="docs/i18n/gu/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ગુજરાતી (gu)" title="ગુજરાતી (gu)"></a>
|
||||
<a href="docs/i18n/mr/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="मराठी (mr)" title="मराठी (mr)"></a>
|
||||
@@ -210,7 +209,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 352 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 352 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 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 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."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -463,7 +462,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: 352 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 43 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: 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."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -519,9 +518,9 @@ Pix copia-e-cola:
|
||||
|
||||
## 📡 OmniRoute Radar
|
||||
|
||||
The main free-tier headline remains **~1.51B tokens/month** from the documented,
|
||||
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.13B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
month to **~2.10B**. 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.
|
||||
|
||||
@@ -649,7 +648,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 **446 per-model rows**, **38 recurring pools** and **53 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 **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).
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -725,6 +724,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
<tr><td align="left" nowrap>📦 <b>npm (global)</b></td><td align="left" nowrap><code>npm install -g omniroute</code></td><td align="left">One command, any OS</td></tr>
|
||||
<tr><td align="left" nowrap>🐳 <b>Docker</b></td><td align="left" nowrap><code>docker run … diegosouzapw/omniroute</code></td><td align="left">Multi-arch <b>AMD64 + ARM64</b></td></tr>
|
||||
<tr><td align="left" nowrap>🖥️ <b>Desktop (Electron)</b></td><td align="left" nowrap><code>npm run electron:build</code></td><td align="left">Native window + system tray — <b>Windows / macOS / Linux</b></td></tr>
|
||||
<tr><td align="left" nowrap>🎩 <b>Menu-bar (OmniRouteTray)</b></td><td align="left" nowrap><code>brew install --cask zoispag/tap/omniroute-tray</code></td><td align="left">Supervises & auto-updates the server — <b>macOS</b></td></tr>
|
||||
<tr><td align="left" nowrap>💪 <b>ARM</b></td><td align="left" nowrap>native <code>arm64</code></td><td align="left">Raspberry Pi, ARM servers, Apple Silicon</td></tr>
|
||||
<tr><td align="left" nowrap>📱 <b>Android (Termux)</b></td><td align="left" nowrap><code>pkg install nodejs && npx -y omniroute</code></td><td align="left">Runs <b>on your phone</b>, 24/7, no root</td></tr>
|
||||
<tr><td align="left" nowrap>📲 <b>PWA</b></td><td align="left" nowrap>"Add to Home Screen"</td><td align="left">Fullscreen, offline, installable from browser</td></tr>
|
||||
@@ -733,7 +733,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
<tr><td align="left" nowrap>🛠️ <b>From source</b></td><td align="left" nowrap><code>npm install && npm run dev</code></td><td align="left">Hack on it, contribute</td></tr>
|
||||
</table>
|
||||
|
||||
<sub>📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
|
||||
<sub>📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Menu-bar tray](https://github.com/zoispag/omniroute-tray) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -768,6 +768,42 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 🎩 New: OmniRouteTray — your gateway, living in the menu bar
|
||||
|
||||
</div>
|
||||
|
||||
> `omniroute serve` is happiest when it's always on. **[OmniRouteTray](https://github.com/zoispag/omniroute-tray)**
|
||||
> turns that into a set-and-forget menu-bar app for macOS: it starts the server, keeps it alive
|
||||
> across reboots, updates it in place, and puts your live token budget one click away — **no
|
||||
> terminal window left open, no `npm install -g omniroute` to babysit.**
|
||||
|
||||
Built with [Tauri v2](https://v2.tauri.app/) (a Rust core the size of a rounding error), it ships
|
||||
its own signed Node 24 runtime and manages an app-owned OmniRoute install, so it never fights your
|
||||
global `node`/`bun`. It **shares your existing `~/.omniroute/` config and database** — so it's the
|
||||
same OmniRoute you already run, just with a hat on. 🎩
|
||||
|
||||
<table>
|
||||
<tr><th align="left">What it does</th><th align="left">How</th></tr>
|
||||
<tr><td align="left" nowrap>🟢 <b>Supervises the server</b></td><td align="left">Spawns <code>omniroute serve</code>, adopts an already-running instance instead of duplicating it</td></tr>
|
||||
<tr><td align="left" nowrap>📊 <b>Live usage at a glance</b></td><td align="left">Provider quota bars, Claude session/weekly limits with reset countdowns, 30-day cost breakdown</td></tr>
|
||||
<tr><td align="left" nowrap>🔄 <b>Auto-updates in place</b></td><td align="left">Staged install, atomic swap, rollback on failure — always on the newest release</td></tr>
|
||||
<tr><td align="left" nowrap>🚀 <b>Start on login</b></td><td align="left">Optional launch at login; tray-only, no dock icon</td></tr>
|
||||
<tr><td align="left" nowrap>🩺 <b>Doctor & logs</b></td><td align="left">One-click diagnostics and server log access</td></tr>
|
||||
</table>
|
||||
|
||||
```sh
|
||||
brew install --cask zoispag/tap/omniroute-tray
|
||||
```
|
||||
|
||||
<sub>Prefer a download? Grab the latest <code>.dmg</code> from
|
||||
<a href="https://github.com/zoispag/omniroute-tray/releases">Releases</a>. Source, issues and build
|
||||
docs live at <a href="https://github.com/zoispag/omniroute-tray">zoispag/omniroute-tray</a>.
|
||||
<br/>💛 A community project by <a href="https://github.com/zoispag">@zoispag</a> — not an official OmniRoute release.</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🔒 Private & Local-First
|
||||
|
||||
</div>
|
||||
@@ -1020,7 +1056,7 @@ Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-r
|
||||
Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
|
||||
|
||||
- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
|
||||
- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
|
||||
- **Automatic Webpack bundler selection in dev**: Development (`bun run dev`) automatically detects Bun and disables Turbopack in favor of Webpack to prevent native V8 binding incompatibilities. Production builds (`bun run build`) follow `OMNIROUTE_USE_TURBOPACK` exactly as on Node: Turbopack by default, `OMNIROUTE_USE_TURBOPACK=0` to build with Webpack (`Dockerfile.bun` exposes it as a `--build-arg`).
|
||||
- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
|
||||
|
||||
```bash
|
||||
@@ -1208,7 +1244,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, 167 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 169 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>
|
||||
@@ -1271,7 +1307,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: 38 documented recurring pools / 446 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 / 444 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>
|
||||
@@ -1296,7 +1332,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/ops/BRANCHING_MODEL.md">Branching & Release Model</a></b></td><td>Where PRs target (<code>release/*</code>), what <code>main</code> and tags mean</td></tr>
|
||||
<tr><td nowrap><b><a href="CHANGELOG.md">Changelog</a></b></td><td>Full per-version release history</td></tr>
|
||||
<tr><td nowrap><b><a href="SECURITY.md">Security Policy</a></b></td><td>Vulnerability reporting and security practices</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>43-language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>42-language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/RELEASE_CHECKLIST.md">Release Checklist</a></b></td><td>Pre-release validation steps</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy for 39,000+ static test declarations across 5,100+ tracked test files</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -224,6 +224,14 @@ features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
|
||||
up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners
|
||||
frequently pattern-match those chunks against malware signatures.
|
||||
|
||||
The scanner configuration we use lives at [`socket.yml`](socket.yml) in the
|
||||
repo root (Socket.dev GitHub App format v2 — see
|
||||
<https://docs.socket.dev/docs/socket-yml>). It explicitly excludes
|
||||
non-shipped directories (`tests/`, `_tasks/`, `_references/`, `_ideia/`,
|
||||
`_mono_repo/`, `docs/`, etc.) so the scanner only reports on code paths that
|
||||
actually reach published users — the scan itself is driven by the Socket
|
||||
GitHub App reading that file, not by a workflow in this repository.
|
||||
|
||||
For each finding category we maintain a per-finding maintainer attestation:
|
||||
|
||||
- **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** —
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## wreq-js 3.2.0 native transport
|
||||
|
||||
OmniRoute ships `wreq-js@3.2.0` and its platform-specific native bindings for browser-
|
||||
fingerprinted HTTP transport. The npm package and all nine binding tarballs are tied by npm SLSA
|
||||
attestations to signed tag `v3.2.0` and immutable source commit
|
||||
[`0d52d5fa252841aeef34d4d063b1766a59612bf7`](https://github.com/sqdshguy/wreq-js/commit/0d52d5fa252841aeef34d4d063b1766a59612bf7).
|
||||
|
||||
- Root tarball:
|
||||
<https://registry.npmjs.org/wreq-js/-/wreq-js-3.2.0.tgz>
|
||||
- npm integrity:
|
||||
`sha512-dawhEbhvd5hxivKZSvv/mAQGO3mwZYESyctOvIIZ/H3DvQJzUM2UoFQsij0fg7hIClQ/GEQgg+2259UcFwhpMQ==`
|
||||
- Exact platform, integrity, size, and SHA-256 receipts for all nine native addons:
|
||||
[`config/release/wreq-js-native-manifest.json`](config/release/wreq-js-native-manifest.json)
|
||||
- Locked per-target Cargo closure, with runtime and compile-only packages kept separate:
|
||||
[`config/release/wreq-js-rust-license-inventory.json`](config/release/wreq-js-rust-license-inventory.json)
|
||||
- Deduplicated license texts and attribution notices for the conservative native runtime closure,
|
||||
including patched BoringSSL, Unicode ICU4X components, and Mozilla root-certificate data:
|
||||
[`config/release/wreq-js-rust-notices.md`](config/release/wreq-js-rust-notices.md)
|
||||
|
||||
The native tarballs themselves contain no LICENSE/NOTICE file. The bundled inventory is therefore
|
||||
shipped beside them. It intentionally over-approximates the locked link-eligible Cargo closure;
|
||||
exact post-LTO membership cannot be claimed without an upstream artifact SBOM/link map or a
|
||||
reproducible-build receipt. The Android addon also dynamically requires `libc++_shared.so`, which
|
||||
is not included in its npm tarball; any artifact that supplies that library needs its separate
|
||||
LLVM/Apache-with-LLVM-exception notice.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 will-work-for-meal
|
||||
Copyright (c) 2025 Oleksandr Herasymov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## codex-chatgpt-web
|
||||
|
||||
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from
|
||||
|
||||
@@ -22,9 +22,9 @@ bin/cli/
|
||||
├── provider-test.mjs ← testProviderApiKey()
|
||||
├── settings-store.mjs ← DB CRUD for key_value settings
|
||||
├── locales/
|
||||
│ ├── en.json ← English strings (source of truth, 43 locales)
|
||||
│ ├── en.json ← English strings (source of truth, 42 locales)
|
||||
│ ├── pt-BR.json ← Portuguese (Brazil) — fully translated
|
||||
│ └── {locale}.json ← 42 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
│ └── {locale}.json ← 41 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
├── scripts/
|
||||
│ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
|
||||
└── commands/
|
||||
|
||||
@@ -16,6 +16,29 @@ function ensureBackup(configPath) {
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function mergeClaudeSettings(existingContent, generatedContent) {
|
||||
const generated = JSON.parse(generatedContent);
|
||||
let current = {};
|
||||
if (existingContent && existingContent.trim()) {
|
||||
current = JSON.parse(existingContent);
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) current = {};
|
||||
}
|
||||
return JSON.stringify(
|
||||
{
|
||||
...current,
|
||||
...generated,
|
||||
env: {
|
||||
...(current.env && typeof current.env === "object" && !Array.isArray(current.env)
|
||||
? current.env
|
||||
: {}),
|
||||
...(generated.env || {}),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
async function runConfigListCommand(opts = {}) {
|
||||
const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.ts");
|
||||
const tools = await detectAllTools();
|
||||
@@ -120,7 +143,12 @@ async function runConfigSetCommand(toolId, opts = {}) {
|
||||
const backupPath = ensureBackup(result.configPath);
|
||||
if (backupPath) printInfo(`Backup saved to: ${backupPath}`);
|
||||
|
||||
fs.writeFileSync(result.configPath, result.content, "utf-8");
|
||||
let content = result.content;
|
||||
if (toolId === "claude" && fs.existsSync(result.configPath)) {
|
||||
content = mergeClaudeSettings(fs.readFileSync(result.configPath, "utf-8"), result.content);
|
||||
}
|
||||
|
||||
fs.writeFileSync(result.configPath, content, "utf-8");
|
||||
printSuccess(`Config written to ${result.configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ 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 { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
|
||||
import {
|
||||
ServerSupervisor,
|
||||
detectMitmCrash,
|
||||
BUN_PRELOAD_PATH,
|
||||
} from "../runtime/processSupervisor.mjs";
|
||||
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
|
||||
import {
|
||||
ensureAndroidCacheDir,
|
||||
@@ -306,7 +310,7 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
? ["--preload", BUN_PRELOAD_PATH]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
@@ -331,7 +335,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
? ["--preload", BUN_PRELOAD_PATH]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
@@ -423,7 +427,9 @@ async function runWithSupervisor(
|
||||
if (detectMitmCrash(crashLog)) {
|
||||
try {
|
||||
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const { updateSettings } = await import(pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href);
|
||||
const { updateSettings } = await import(
|
||||
pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href
|
||||
);
|
||||
updateSettings({ mitmEnabled: false });
|
||||
} catch {}
|
||||
return "disable-mitm-and-retry";
|
||||
|
||||
@@ -18,7 +18,7 @@ export function registerTunnel(program) {
|
||||
});
|
||||
|
||||
tunnel
|
||||
.command("create [type]")
|
||||
.command("create")
|
||||
.description(t("tunnel.createDescription"))
|
||||
.addArgument(
|
||||
new Argument("[type]", "Tunnel type").choices(VALID_TUNNEL_TYPES).default("cloudflare")
|
||||
|
||||
@@ -5,6 +5,25 @@ import { fileURLToPath } from "node:url";
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const LOCALES_DIR = join(__dirname, "locales");
|
||||
const FALLBACK_LOCALE = "en";
|
||||
const I18N_CONFIG_PATH = join(__dirname, "..", "..", "config", "i18n.json");
|
||||
let aliasMap = null; // lower-case tag → canonical locale code
|
||||
|
||||
function loadAliasMap() {
|
||||
if (aliasMap) return aliasMap;
|
||||
aliasMap = new Map();
|
||||
try {
|
||||
const { locales } = JSON.parse(readFileSync(I18N_CONFIG_PATH, "utf8"));
|
||||
for (const entry of locales) {
|
||||
aliasMap.set(entry.code.toLowerCase(), entry.code);
|
||||
for (const alias of entry.aliases || []) {
|
||||
aliasMap.set(String(alias).toLowerCase(), entry.code);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// config absent (trimmed package): keep file-based detection only
|
||||
}
|
||||
return aliasMap;
|
||||
}
|
||||
|
||||
const cache = new Map();
|
||||
let activeLocale = null;
|
||||
@@ -24,8 +43,16 @@ function normalize(raw) {
|
||||
const stripped = String(raw).split(".")[0].replaceAll("_", "-");
|
||||
if (!stripped || !/^[a-zA-Z0-9-]+$/.test(stripped)) return FALLBACK_LOCALE;
|
||||
if (hasCatalog(stripped)) return stripped;
|
||||
const base = stripped.split("-")[0];
|
||||
const lower = stripped.toLowerCase();
|
||||
const base = lower.split("-")[0];
|
||||
const aliases = loadAliasMap();
|
||||
const viaAlias = aliases.get(lower) ?? aliases.get(base);
|
||||
if (viaAlias && hasCatalog(viaAlias)) return viaAlias;
|
||||
if (hasCatalog(base)) return base;
|
||||
const regional = [...new Set(aliases.values())].find(
|
||||
(code) => code.includes("-") && code.toLowerCase().split("-")[0] === base
|
||||
);
|
||||
if (regional && hasCatalog(regional)) return regional;
|
||||
return FALLBACK_LOCALE;
|
||||
}
|
||||
|
||||
@@ -103,4 +130,5 @@ export function resetForTests() {
|
||||
cache.clear();
|
||||
activeLocale = null;
|
||||
fallbackCatalog = null;
|
||||
aliasMap = null;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"redis": {
|
||||
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
|
||||
import {
|
||||
RESTART_RESET_MS,
|
||||
@@ -17,6 +18,24 @@ import {
|
||||
|
||||
const CRASH_LOG_LINES = 50;
|
||||
|
||||
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
// Bun needs the Node-compat polyfill preloaded (#9761). The file ships at the
|
||||
// package root via package.json "files" (see scripts/build/pack-artifact-policy.ts)
|
||||
// and is never copied into dist/, so the path must resolve against the package
|
||||
// root — resolving it next to the server bundle fails with "preload not found" (#11980).
|
||||
export const BUN_PRELOAD_PATH = join(PACKAGE_ROOT, "open-sse", "utils", "setupPolyfill.ts");
|
||||
|
||||
/**
|
||||
* Argument vector for the server child. Kept pure so tests can assert on it
|
||||
* directly: the bare `import { spawn }` above cannot be intercepted without
|
||||
* --experimental-test-module-mocks (same seam as #8131).
|
||||
*/
|
||||
export function buildServerSpawnArgs(serverPath, memoryLimit, env = process.env) {
|
||||
return process.versions.bun
|
||||
? ["--preload", BUN_PRELOAD_PATH, serverPath]
|
||||
: buildNodeRuntimeArgs(env, memoryLimit, serverPath);
|
||||
}
|
||||
|
||||
export class ServerSupervisor {
|
||||
constructor({
|
||||
serverPath,
|
||||
@@ -55,21 +74,11 @@ export class ServerSupervisor {
|
||||
// Node args come from buildNodeRuntimeArgs (#9209 IPv4-first DNS + #5238
|
||||
// heap flag handling); the Bun branch keeps #9761's polyfill preload —
|
||||
// Bun does not accept the Node-only flags.
|
||||
this.child = spawn(
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [
|
||||
"--preload",
|
||||
join(dirname(this.serverPath), "open-sse/utils/setupPolyfill.ts"),
|
||||
this.serverPath,
|
||||
]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
{
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
this.child = spawn(process.execPath, buildServerSpawnArgs(this.serverPath, this.memoryLimit), {
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
writePidFile("server", this.child.pid);
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* For top-tier languages, a translated `common` + `program` section is included.
|
||||
* All other keys fall back to `en` via i18n.mjs's existing fallback mechanism.
|
||||
*
|
||||
* Run: node bin/cli/scripts/generate-locales.mjs [--force]
|
||||
* Run: node bin/cli/scripts/generate-locales.mjs [--force] [--code=<locale>]
|
||||
* --code=<locale> generate only that locale (scripts/i18n/add-locale.mjs uses it
|
||||
* to scaffold the catalog of the locale it is adding)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
@@ -17,8 +19,13 @@ const ROOT = join(__dirname, "..", "..", "..");
|
||||
const LOCALES_DIR = join(__dirname, "..", "locales");
|
||||
const I18N_CFG = join(ROOT, "config", "i18n.json");
|
||||
const FORCE = process.argv.includes("--force");
|
||||
const ONLY = process.argv.find((arg) => arg.startsWith("--code="))?.slice("--code=".length) || null;
|
||||
|
||||
const { locales } = JSON.parse(readFileSync(I18N_CFG, "utf8"));
|
||||
if (ONLY && !locales.some((locale) => locale.code === ONLY)) {
|
||||
console.error(`--code=${ONLY} is not listed in config/i18n.json`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// common + program translations for each language code.
|
||||
// Keys that are absent fall back to en automatically.
|
||||
@@ -883,12 +890,12 @@ const TRANSLATIONS = {
|
||||
|
||||
// Languages with no translation in this script — will be created as empty objects
|
||||
// All keys fall back to `en` via i18n.mjs's fallback mechanism.
|
||||
const SCAFFOLD_ONLY = ["bn", "gu", "he", "in", "mr", "ms", "phi", "sw", "ta", "te", "ur"];
|
||||
const SCAFFOLD_ONLY = ["bn", "gu", "he", "mr", "ms", "phi", "sw", "ta", "te", "ur"];
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const locale of locales) {
|
||||
for (const locale of locales.filter((candidate) => !ONLY || candidate.code === ONLY)) {
|
||||
const { code } = locale;
|
||||
if (code === "en" || code === "pt-BR") {
|
||||
skipped++;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(audio):** proxy native ElevenLabs voices, text-to-speech, and speech-to-text HTTP routes through stored OmniRoute credentials, preserving query strings, multipart uploads, binary responses, and upstream errors (#10556).
|
||||
@@ -1 +0,0 @@
|
||||
- Added Google AI Studio Gemini batch text-to-speech support through `POST /v1/audio/speech`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user