mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-07 15:32:10 +03:00
Compare commits
277 Commits
docs/opena
...
chore/git-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc19f56b0d | ||
|
|
c7b7d79019 | ||
|
|
b345c7f6cd | ||
|
|
f9a1cc8a9b | ||
|
|
2b2d34eb53 | ||
|
|
92a617c23f | ||
|
|
9d1a896c60 | ||
|
|
a9f7598c60 | ||
|
|
d345520d72 | ||
|
|
7b2c9b5548 | ||
|
|
ec4f951e39 | ||
|
|
3858923f68 | ||
|
|
8c4fb8faf2 | ||
|
|
7da6e10c4e | ||
|
|
0df5be5b09 | ||
|
|
f40c77e837 | ||
|
|
c5d47dad8a | ||
|
|
366099a08c | ||
|
|
82f78b3b3b | ||
|
|
891cb26b2c | ||
|
|
12a3dbdc76 | ||
|
|
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 | ||
|
|
713440be0a | ||
|
|
a298dc6b73 | ||
|
|
451dd73870 | ||
|
|
9327990be6 | ||
|
|
af0a9609f4 | ||
|
|
24b784e9bb | ||
|
|
96824288f5 | ||
|
|
678e6077e4 | ||
|
|
5a0a131bc7 | ||
|
|
c702a27eda | ||
|
|
cf53b9220f | ||
|
|
d2a027a156 | ||
|
|
17a53d2eb9 | ||
|
|
7f25d67d07 | ||
|
|
1586476183 | ||
|
|
c9f9b6274e | ||
|
|
8ef3447950 | ||
|
|
a784b42060 | ||
|
|
a86b9019a8 | ||
|
|
438db55c46 | ||
|
|
6dd82b77de | ||
|
|
17792ce0ad | ||
|
|
5ff6513ca5 | ||
|
|
8fc6834372 | ||
|
|
ad4b67d631 | ||
|
|
5253b93b89 | ||
|
|
51587084ca | ||
|
|
bdf218387b | ||
|
|
3b82d85081 | ||
|
|
d19572fb95 | ||
|
|
5e6c9a92dc | ||
|
|
2f33f2c20d | ||
|
|
33bdc386bc | ||
|
|
78a0e4b109 | ||
|
|
accdfa9f33 | ||
|
|
9d04995950 | ||
|
|
abbbca216d | ||
|
|
fe5f4b0ef9 | ||
|
|
c818655b5a | ||
|
|
9629d78ece | ||
|
|
0ff164701d | ||
|
|
f301d34ce5 | ||
|
|
d920e6495a | ||
|
|
7f008ed09a | ||
|
|
e3c440e804 | ||
|
|
d898d1a913 | ||
|
|
ad54249c54 | ||
|
|
eeba382049 | ||
|
|
412298b624 | ||
|
|
f5e70950d3 | ||
|
|
073b98462d | ||
|
|
30a26d9dcb | ||
|
|
d53da4fdc8 | ||
|
|
3c8b553811 | ||
|
|
aa2aec5e59 | ||
|
|
6c93e74f26 | ||
|
|
2e17161ea2 | ||
|
|
5eaafe8e17 | ||
|
|
4bcd8cee99 | ||
|
|
7f9195cd29 | ||
|
|
06e7a6d50c | ||
|
|
9058e39b61 | ||
|
|
a249a9dc28 | ||
|
|
e7a65d28db | ||
|
|
05490304bf | ||
|
|
dc6daf27b6 | ||
|
|
903d1e0c52 | ||
|
|
8d388912a7 | ||
|
|
debb82bdd7 | ||
|
|
c49ee53bc1 | ||
|
|
0e5e195519 | ||
|
|
e0b9eb08e1 | ||
|
|
4d92ea9969 | ||
|
|
3383adbbd1 | ||
|
|
a4b4bca2ee | ||
|
|
18dd83cd87 | ||
|
|
ae37413aff | ||
|
|
26eeead268 | ||
|
|
1b64372316 | ||
|
|
ba200b8d2b | ||
|
|
978f32984c | ||
|
|
7ba5b7a74e | ||
|
|
2bd3023e09 | ||
|
|
d0529c0365 | ||
|
|
50a6f7e325 | ||
|
|
8acdd53025 | ||
|
|
ede327a613 | ||
|
|
ce1b142975 | ||
|
|
63e4afa321 | ||
|
|
7ca5e1c671 | ||
|
|
73db936f98 | ||
|
|
4b5266d3f8 | ||
|
|
f8b01c966e | ||
|
|
e12fb110f9 | ||
|
|
2fbd0f5c25 | ||
|
|
18c71b91dc | ||
|
|
9392bd55c2 | ||
|
|
90366903c4 | ||
|
|
668beed5b8 | ||
|
|
298ad0fd64 |
156
.env.example
156
.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
|
||||
@@ -812,6 +825,7 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# CLI_CRUSH_BIN=crush
|
||||
# CLI_OMP_BIN=omp
|
||||
# CLI_LETTA_BIN=letta
|
||||
# CLI_PRIME_AGENT_BIN=prime-agent
|
||||
# Windsurf has no default binary — set this to enable binary detection for it.
|
||||
# CLI_WINDSURF_BIN=windsurf
|
||||
# CLI_AUGGIE_BIN=auggie
|
||||
@@ -862,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
|
||||
@@ -1150,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
|
||||
@@ -1282,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) —
|
||||
@@ -1312,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
|
||||
@@ -1469,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
|
||||
|
||||
@@ -1491,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
|
||||
|
||||
@@ -1520,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
|
||||
@@ -1828,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
|
||||
@@ -1898,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
|
||||
@@ -2266,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
|
||||
|
||||
@@ -2397,6 +2447,9 @@ APP_LOG_TO_FILE=true
|
||||
# Bundled Codeium/language-server extension_version, distinct from Desktop.
|
||||
# Must use x.y.z format; invalid/unset values use the bundled default 1.48.2.
|
||||
# DEVIN_DESKTOP_EXTENSION_VERSION=1.48.2
|
||||
# Optional override for the Codeium seat-management API used by Devin CLI quota.
|
||||
# Used by: open-sse/services/usage/devinCli.ts. Default: https://server.codeium.com
|
||||
# DEVIN_SEAT_API_URL=https://server.codeium.com
|
||||
|
||||
# ── Command Code (custom CLI) callback ──
|
||||
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
|
||||
@@ -2466,14 +2519,6 @@ APP_LOG_TO_FILE=true
|
||||
# When enabled, the node authenticates with the API key stored on its connection.
|
||||
# AUDIO_REMOTE_PROVIDER_NODES=false
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
# CrofAI 1Proxy service. Disable, override URL, or tune the import quality.
|
||||
# ONEPROXY_ENABLED=true
|
||||
# ONEPROXY_API_URL=https://1proxy-api.aitradepulse.com
|
||||
# ONEPROXY_MAX_PROXIES=500
|
||||
# ONEPROXY_MIN_QUALITY_THRESHOLD=50
|
||||
|
||||
# ── Free Proxy Pool (auto-sync scheduler) ──
|
||||
# Background refresh of the free-proxy pool. Opt-in, OFF by default (parallels
|
||||
# Hard Rule #20's default-off posture for data-mutating background features).
|
||||
@@ -2892,6 +2937,14 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token
|
||||
# PROMPTQL_POLL_TIMEOUT_MS=180000
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Kilo Code usage quotas (src/shared/constants/providers/kilocode.ts)
|
||||
# Personal USD balance and Kilo Pass usage lookup. Optional — the default
|
||||
# points at the public Kilo API; override only for a relay/test fixture.
|
||||
# Authentication uses the connection's existing OAuth access token.
|
||||
# Used by: open-sse/services/usage/kilocode.ts
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# KILO_API_URL=https://api.kilo.ai
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HyperAgent web provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts)
|
||||
# Reverse-engineered session bridge for hyperagent.com. Optional — defaults
|
||||
@@ -2911,7 +2964,12 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
# CHATGPT_WEB_CODEX_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef
|
||||
# CHATGPT_WEB_CODEX_RUNTIME_KEY=
|
||||
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex
|
||||
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex v2
|
||||
# CODEX_CHATGPT_WEB_HOME=/var/lib/omniroute/chatgpt-web-codex
|
||||
# CODEX_CHATGPT_WEB_BROWSER_DIAGNOSTICS=0
|
||||
# CODEX_CHATGPT_WEB_LAUNCHER=/absolute/path/to/codex-chatgpt-web
|
||||
# CODEX_CHATGPT_WEB_BUN=/absolute/path/to/bun
|
||||
# CODEX_WEB_GPT_BUN=/absolute/path/to/bun
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts)
|
||||
|
||||
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"
|
||||
|
||||
16
.github/dependabot.yml
vendored
16
.github/dependabot.yml
vendored
@@ -58,6 +58,22 @@ updates:
|
||||
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
|
||||
# Migrate it intentionally, not via dependabot (#4050).
|
||||
- dependency-name: "@huggingface/transformers"
|
||||
# onnxruntime-node is the OTHER HALF of the @huggingface/transformers pair frozen
|
||||
# above: the hoisted copy must equal the exact version transformers pins, or npm
|
||||
# nests a second ABI-incompatible native copy (contract test
|
||||
# tests/unit/onnxruntime-single-copy.test.ts, pair established in #9962). A solo
|
||||
# bump can never be correct — it only ever moves together with transformers, in
|
||||
# the same deliberate migration PR. Freezing it keeps the production group PRs
|
||||
# (e.g. #12219) from being born red on the pair contract.
|
||||
- dependency-name: "onnxruntime-node"
|
||||
# eslint-plugin-react-hooks is pinned to 7.0.1 by a contract test
|
||||
# (tests/unit/eslint-react-hooks-version-pinned.test.ts) until the 7.1.1 rule set
|
||||
# is adopted deliberately — that adoption needs a full cold lint run and its own
|
||||
# PR (the #12146 react-hooks migration finished on 2026-09-01, so the path is
|
||||
# open; the bump still must not ride a dependabot group, where it reds the
|
||||
# development group PRs, e.g. #12220). Remove this ignore in the adoption PR
|
||||
# together with the pin test.
|
||||
- dependency-name: "eslint-plugin-react-hooks"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
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);
|
||||
});
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -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 |
|
||||
@@ -83,7 +83,7 @@ Client → /v1/chat/completions (Next.js route)
|
||||
|
||||
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
|
||||
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 15-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 16-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
|
||||
---
|
||||
|
||||
@@ -131,10 +131,14 @@ breaker runs on `circuitBreakerThreshold` / `circuitBreakerReset`:
|
||||
| API key | `7` | `12` | `30s` |
|
||||
| Local | (derived) | `2` | `15s` |
|
||||
|
||||
`PROVIDER_PROFILES` also defines `providerFailureThreshold` (10/15/2) and `providerCooldownMs`
|
||||
(5min/10min/1min); those fields are loaded into the profile but have **no runtime consumer
|
||||
today** — do not tune or document them as the live breaker. Every default is overridable
|
||||
through the `OMNIROUTE_PROVIDER_BREAKER_*` and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars; the
|
||||
`PROVIDER_PROFILES` also defines `providerFailureThreshold` (10/15/2),
|
||||
`providerFailureWindowMs` (15/30/5 min) and `providerCooldownMs` (5/10/1 min): these power the
|
||||
**window gate of the opt-in global Provider Cooldown** (`PROVIDER_COOLDOWN_ENABLED`, default
|
||||
off) — a provider-level entry in `open-sse/services/providerCooldownTracker.ts` only counts as
|
||||
cooling after `providerFailureThreshold` failures inside `providerFailureWindowMs`, and then
|
||||
cools for `providerCooldownMs`. They are NOT the live breaker's thresholds — do not tune them
|
||||
expecting breaker behavior. Every default is overridable through the
|
||||
`OMNIROUTE_PROVIDER_BREAKER_*` and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars; the
|
||||
runtime-accurate reference table lives in `docs/architecture/RESILIENCE_GUIDE.md`.
|
||||
|
||||
Only provider-level failure statuses should trip the provider breaker:
|
||||
@@ -339,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
|
||||
@@ -418,7 +423,7 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (15-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Auto-Combo (16-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||
@@ -490,6 +495,12 @@ Why this matters: fixing bug A while opening bug B is worse than not fixing at a
|
||||
pipeline, and A2A skills.
|
||||
- Do not close a contributor pull request after using its code; merge it through GitHub so
|
||||
the contributor receives credit.
|
||||
- **Never merge a PR that touches an agent-instruction surface without explicit operator
|
||||
approval** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `llm.txt` (+ mirrors) and
|
||||
`skills/**/SKILL.md` are executed as authority by every AI session; a merged instruction
|
||||
compromises every future agent run. Check with `gh pr diff <N> --name-only` before any
|
||||
merge. Incident record: PR #11770 (2026-09-01) told agents to execute a third-party
|
||||
setup script and was swept in by a merge campaign; reverted in #12249.
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
CHANGELOG.md
20
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
|
||||
@@ -97,6 +98,10 @@ _Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). B
|
||||
|
||||
### 🐛 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.
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
---
|
||||
@@ -716,7 +721,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 +892,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 +3004,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 +3445,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 +4001,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 +5646,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 +6305,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 +6337,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 +6513,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 +6605,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)
|
||||
|
||||
@@ -73,6 +73,9 @@ npm run dev
|
||||
npm run build # next build → .build/next/ then assembleStandalone → dist/
|
||||
npm run start
|
||||
|
||||
# Fast backend/API-only compile for contributor changes
|
||||
npm run build:contributor
|
||||
|
||||
# Release build (clean rebuild + HEAD sentinel — required for deploy)
|
||||
npm run build:release # rm -rf .build dist && build + writes dist/BUILD_SHA
|
||||
|
||||
@@ -80,6 +83,10 @@ npm run build:release # rm -rf .build dist && build + writes dist/BUILD_SHA
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
```
|
||||
|
||||
The contributor build performs compile-only validation: it does not assemble the standalone
|
||||
distribution or build optional native packaging assets. Use the regular production build when
|
||||
you need to validate the shippable bundle.
|
||||
|
||||
### Build Output Layout
|
||||
|
||||
| Directory | Contents | Tracked |
|
||||
@@ -100,6 +107,11 @@ npm run build
|
||||
`npm run build:release` additionally cleans both directories first and writes
|
||||
`dist/BUILD_SHA` (= `git rev-parse --short HEAD`) as a deploy integrity sentinel.
|
||||
|
||||
`npm run build:contributor` uses the backend-only build profile. It temporarily stubs
|
||||
dashboard UI files while building, keeps API route handlers, and restores the original files
|
||||
after the build. Use `npm run build` for changes that affect the dashboard UI or for full
|
||||
release validation; the contributor profile is not a replacement for the release build.
|
||||
|
||||
> **VPS deploy note:** the remote image directory `/usr/lib/node_modules/omniroute/app/`
|
||||
> is unchanged. The deploy skills rsync the contents of `dist/` into it.
|
||||
> Only the in-repo build output path moved (`app/` → `dist/`).
|
||||
@@ -165,6 +177,13 @@ npm run test:all
|
||||
# Single test file (Node.js native test runner — most tests use this)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
|
||||
# Only the unit tests impacted by your change (same TIA selector as the CI gate, #8084)
|
||||
npm run test:scoped # changes in the last commit (or the working tree)
|
||||
npm run test:scoped:staged # staged changes only — pairs well with a pre-commit run
|
||||
npm run test:scoped:full # rebuild the import-graph map first (after adding/moving files)
|
||||
# Exit 1 + "run the full suite" means a hub file (tsconfig, package.json, …) or an
|
||||
# unmapped source changed — the selector fails safe, it never silently skips.
|
||||
|
||||
# Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
|
||||
FROM oven/bun:1.3.14-slim AS base
|
||||
FROM oven/bun:1.4.0-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -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');"
|
||||
@@ -58,7 +56,7 @@ ENV NODE_ENV=production
|
||||
RUN bun run --quiet build
|
||||
|
||||
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
|
||||
FROM oven/bun:1.3.14-slim AS runner-base
|
||||
FROM oven/bun:1.4.0-slim AS runner-base
|
||||
|
||||
LABEL org.opencontainers.image.title="omniroute" \
|
||||
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
|
||||
|
||||
85
README.md
85
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)**.
|
||||
>
|
||||
@@ -49,11 +49,11 @@
|
||||
|
||||
[](https://discord.gg/U47eFqAXCn)
|
||||
[](https://t.me/omnirouteOficial)
|
||||
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
|
||||
[](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)
|
||||
[](https://chat.whatsapp.com/FvuCbrpZmQ6I85n2vW5QIC?s=cl&p=a&mlu=4)
|
||||
[](https://chat.whatsapp.com/KWgatljAjmbELQory59Oti?s=cl&p=a&mlu=4)
|
||||
[](https://omniroute.online)
|
||||
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)**
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/FvuCbrpZmQ6I85n2vW5QIC?s=cl&p=a&mlu=4) / [🇧🇷 Brasil](https://chat.whatsapp.com/KWgatljAjmbELQory59Oti?s=cl&p=a&mlu=4) / [Portal](https://portal.sthub.com.br/communities/groups/st-hub/channels/Omniroute-World-8kRjmK)**
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -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/>
|
||||
@@ -431,7 +430,7 @@ All **19** strategies — mix & match per combo step:
|
||||
<tr>
|
||||
<td align="center">17</td>
|
||||
<td nowrap><code>auto</code></td>
|
||||
<td>15-factor live scoring across every connection 🤖</td>
|
||||
<td>16-factor live scoring across every connection 🤖</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">18</td>
|
||||
@@ -445,7 +444,7 @@ All **19** strategies — mix & match per combo step:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>The Auto-Combo engine scores every candidate on **15 factors** (health, quota, cost, latency, task fit, quality, session availability…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
<sub>The Auto-Combo engine scores every candidate on **16 factors** (health, quota, cost, latency, task fit, quality, session availability…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
|
||||
##
|
||||
|
||||
@@ -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
|
||||
@@ -1183,9 +1219,10 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
| 🐙 **GitHub** — follow for releases & tips | [@diegosouzapw](https://github.com/diegosouzapw) |
|
||||
| 💬 **Discord** | [discord.gg/U47eFqAXCn](https://discord.gg/U47eFqAXCn) |
|
||||
| ✈️ **Telegram** | [t.me/omnirouteOficial](https://t.me/omnirouteOficial) |
|
||||
| 🟢 **WhatsApp — 🌍 Global** | [join the group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) |
|
||||
| 🟢 **WhatsApp — 🇧🇷 Brasil** | [entrar no grupo](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz) |
|
||||
| 🟢 **WhatsApp — 🌍 Global** | [join the group](https://chat.whatsapp.com/FvuCbrpZmQ6I85n2vW5QIC?s=cl&p=a&mlu=4) |
|
||||
| 🟢 **WhatsApp — 🇧🇷 Brasil** | [entrar no grupo](https://chat.whatsapp.com/KWgatljAjmbELQory59Oti?s=cl&p=a&mlu=4) |
|
||||
| 🌍 **Website** | [omniroute.online](https://omniroute.online) |
|
||||
| 🌍 **🌍StHub OmniRoute Community (free)** | [portal sthub](https://portal.sthub.com.br/communities/groups/st-hub/channels/Omniroute-World-8kRjmK) |
|
||||
| 📦 **Source code** | [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute) |
|
||||
| 🐛 **Report a bug** | [open an issue](https://github.com/diegosouzapw/OmniRoute/issues) — attach `npm run system-info` output |
|
||||
| 🤝 **Contribute** | [CONTRIBUTING.md](CONTRIBUTING.md) · [Branching & Release Model](docs/ops/BRANCHING_MODEL.md) · pick a `good first issue` |
|
||||
@@ -1207,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>
|
||||
@@ -1268,9 +1305,9 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_RULES_FORMAT.md">Compression Rules Format</a></b></td><td>JSON rule-pack schemas for Caveman and RTK filters</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_LANGUAGE_PACKS.md">Compression Language Packs</a></b></td><td>Language detection and Caveman rule-pack authoring</td></tr>
|
||||
<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>15-factor scoring, mode packs, self-healing</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>
|
||||
@@ -1295,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,10 +1,56 @@
|
||||
# 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
|
||||
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), commit
|
||||
`55592fca0ba19a27f1b769cec8fff61ff340a785`.
|
||||
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), v4.0.7 commit
|
||||
`b59d7dc51b84fb1f465ff1d00f5207f3b2b4a494`.
|
||||
|
||||
MIT License
|
||||
|
||||
|
||||
@@ -32,17 +32,20 @@ export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSy
|
||||
return candidates.find((candidate) => exists(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export async function loadChatGptWebCodexMcpModule(entry) {
|
||||
if (entry.endsWith(".ts")) {
|
||||
await import("tsx/esm");
|
||||
}
|
||||
return import(pathToFileURL(entry).href);
|
||||
}
|
||||
|
||||
export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) {
|
||||
const socketIndex = args.indexOf("--broker-socket");
|
||||
const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
|
||||
if (!brokerSocketPath) throw new Error("--broker-socket is required");
|
||||
const entry = resolveChatGptWebCodexMcpEntry(rootDir);
|
||||
if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found");
|
||||
if (entry.endsWith(".ts")) {
|
||||
const { register } = await import("node:module");
|
||||
register("tsx/esm", pathToFileURL(`${rootDir}/`));
|
||||
}
|
||||
const module = await import(pathToFileURL(entry).href);
|
||||
const module = await loadChatGptWebCodexMcpModule(entry);
|
||||
await module.runChatGptMcpServer({ brokerSocketPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user