Compare commits

..

74 Commits

Author SHA1 Message Date
diegosouzapw
ab1dfddcc8 chore(release): finalize v3.8.30 — complete CHANGELOG + CodeQL freeWebSearch hardening + docs reconcile
- CHANGELOG: complete the [3.8.30] section to 1:1 coverage of all 70 commits
  since v3.8.29 (Features/Changed/Fixed/Tests/Maintenance/Security/Dependencies).
- security: cherry-pick the DDG-lite scraper sanitization hardening from #4356
  (closes 4 HIGH CodeQL alerts: js/double-escaping, js/incomplete-multi-character-
  sanitization, js/incomplete-url-substring-sanitization x2).
- docs: ANTHROPIC_AUTH_TOKEN (not the bare AUTH_TOKEN shorthand) in the Claude Code
  guide; allowlist GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL (Gemini CLI vars) in the
  fabricated-docs checker — fixes the docs-sync-strict gate.
2026-06-20 06:44:27 -03:00
Diego Rodrigues de Sa e Souza
1a1ef10009 fix(cli): wire the contexts command into the CLI program (#4369)
Found by end-to-end testing of remote mode: `omniroute contexts list/current/use`
fell through to `serve` ("too many arguments for 'serve'"). `connect` even tells
users "Switch back to local with: omniroute contexts use default" — a dead command.

Root cause: `bin/cli/commands/contexts.mjs` implements `registerContexts` (with
list/add/use/current/show/remove/rename/export/import), and it had an isolated unit
test using a FAKE program — but `registry.mjs` never imported or called it, so the
command was never wired into the real CLI. Add the import + registration alongside
the other remote-mode commands (connect/tokens/configure).

Regression test: build the REAL program via createProgram() and assert the
top-level contexts/connect/tokens/configure commands exist and that `contexts`
exposes its list/use/current subcommands (RED before, GREEN after). The previous
isolated fake-program test could not catch the missing wiring.
2026-06-20 06:37:49 -03:00
Diego Rodrigues de Sa e Souza
c255eee82d chore(quality): reconcile complexity baseline 1895->1896 (concurrent post-lote drift) (#4370)
Concurrent-session PRs (#4355/#4364/#4363/#4358/#4332) added a new conditional after
#4338 ratcheted to 1895; release fast-path doesn't run check:complexity. Measured 1896.
2026-06-20 06:10:51 -03:00
Diego Rodrigues de Sa e Souza
cdfd71c173 fix(cli): active-context credential must win over the ambient OMNIROUTE_API_KEY (#4364)
Found by end-to-end testing of remote mode against a live VPS: after
`omniroute connect <remote>` saved the scoped admin token as the active context,
every remote *management* command (`tokens list/create/revoke`, etc.) failed with
"Invalid management token".

Root cause: the global `--api-key` option is bound to the env var
(.env("OMNIROUTE_API_KEY")), and users keep OMNIROUTE_API_KEY (their inference
key) in the shell. Commands that spread `optsWithGlobals()` into apiFetch
therefore carry `opts.apiKey` === the env value, which buildHeaders treated as an
explicit override that outranked the active context — so the local inference key
was sent to the remote instead of the scoped token, defeating remote mode.

Fix (single chokepoint in buildHeaders): an `opts.apiKey` that merely mirrors the
ambient OMNIROUTE_API_KEY is treated as ambient (a fallback), not as an explicit
override; only a DISTINCT key — a real `--api-key <x>` flag or a command-supplied
token like `connect --key` — counts as explicit and wins. Precedence becomes:
explicit distinct key -> active-context credential -> ambient env key. This keeps
`connect --key`, local/default usage, and explicit overrides working while making
`connect` actually route management commands to the remote.

Regression tests added to cli-remote-mode.test.ts (RED before, GREEN after):
context token wins over an opts.apiKey echoing the env; a distinct explicit key
still wins; ambient env remains the no-context fallback.
2026-06-20 06:05:33 -03:00
Diego Rodrigues de Sa e Souza
708d77616c fix(compliance): startup cleanup honors dashboard data-retention, not just env 7d (#4354) (#4363)
cleanupExpiredLogs() ran on every startup and read retention only from the
CALL_LOG_RETENTION_DAYS / APP_LOG_RETENTION_DAYS env vars (default 7d when unset),
trimming usage_history before the dashboard-based runAutoCleanup() — which respects
the configured retention — ever ran. A dashboard 'Data Retention' of 90d was silently
overridden, so the Usage Analysis page only showed 7 days after a restart.

Retention precedence is now: explicit env var > dashboard DB setting > 7-day default,
applied per table (usage_history->usageHistory, call/proxy/detail->callLogs,
mcp_tool_audit->mcpAudit). An explicit env var still wins (operator override) and
non-DB deployments still fall back to it. Adds getCallLogRetentionDaysOverride /
getAppLogRetentionDaysOverride (null when env unset).

TDD: log-retention.test.ts gains a case where the env is unset and the dashboard
configures 90d — a 30-day usage_history row must survive (was deleted at the 7d
default). RED before, GREEN after; the existing env-explicit cases are unchanged.

Co-authored-by: akbardwi <akbardwi@users.noreply.github.com>
2026-06-20 06:01:52 -03:00
Diego Rodrigues de Sa e Souza
b710f1a7e3 fix(mitm): mask bare "Bearer <token>" header values in the inspector (#4358)
sanitizeHeaders() masks header *values* — it calls maskSecret("Bearer
<token>") with the "authorization:" key already stripped. The BEARER regex
was anchored to a literal "authorization:" prefix, so it never fired on those
values; tokens shorter than the sk-(16+)/opaque-(40+) thresholds then leaked
verbatim into the Traffic Inspector buffer (Hard Rule #12).

Found by the AgentBridge live capture: a 'Bearer sk-secret-TESTE' request
header showed up unmasked in /api/tools/traffic-inspector/requests. Real Google
OAuth tokens are long enough to be caught by LONG_TOKEN, but the Bearer pattern
must mask regardless of length.

Re-anchor BEARER to a standalone \bBearer\s+<token> (still ReDoS-safe:
bounded char class, no nested quantifiers). Masks both bare 'Bearer <token>'
header values and 'authorization: Bearer <token>' raw lines; existing cases
(sk-/ak-/pk- keys, short keys, no-secret strings) unchanged.

Tests: bare Bearer value, short opaque Bearer, realistic Google OAuth Bearer,
plus an authorization:-prefixed regression.
2026-06-20 05:51:30 -03:00
Diego Rodrigues de Sa e Souza
4038e4de4a fix(pricing): price gpt-5.x-pro openai models + align opencode-go discovery test (#4355)
* fix(pricing+test): price gpt-5.x-pro openai models + align opencode-go discovery test

Two pre-existing reds on the release's full unit suite (only surface under __RUN_ALL__):

1. catalog-updates-v3x: the provider sweep added gpt-5.5-pro / gpt-5.4-pro to the
   openai registry but no pricing rows, so they resolved to $0 and tripped the
   'Every OpenAI registry model resolves a non-zero pricing row' gate. Add both
   under the openai pricing block (mirroring their base family tier until OpenAI
   publishes a distinct pro rate).

2. provider-models-route: opencode-go discovery now (a) stamps owned_by on each
   discovered model (fallback = provider id) and (b) probes ${base}/v1/models then
   ${base}/models (T39 multi-endpoint). The test fixtures predated both — add
   owned_by to the expected model and bump the fail-path fetchCalls 1 -> 2. Test-only
   alignment to the intentional route behavior.

* chore(quality): rebaseline file-size for #4355 (pricing +11, route test +2)
2026-06-20 01:21:08 -03:00
Diego Rodrigues de Sa e Souza
832fde52b4 test: align two tests left red by merged PRs (#4335 db-rules count + #4271 arena metadata) (#4346)
* test(db-rules): align audited-module count to 28 (apiKey db helpers)

#4335 added apiKeyColumnFallbacks + apiKeyUsageLimitFields to INTENTIONALLY_INTERNAL
(both db-internal, consumed only by db/apiKeys.ts) but did not update the parity test
in check-db-rules-classification.test.ts, which still expected exactly 26 audited
modules — leaving it red on release/v3.8.30 (28 != 26). Add the two modules to the
expected list and bump the count to 28. Test-only alignment; no production change.

* test(web-session): align lmarena metadata to the split-cookie source (#4271)

#4271/#4331 updated webSessionCredentials.ts for lmarena's split auth cookie —
new placeholder text and storageKeys arena-auth-prod-v1.0/.1 — but did not update
web-session-credentials.test.ts, which still asserted the old placeholder + 3-key
storageKeys. Align the expected metadata to the merged source; test-only, no prod change.
2026-06-20 00:47:19 -03:00
Diego Rodrigues de Sa e Souza
68e200a2d5 fix(combo): phaseComboSetup under the complexity ceiling + reconcile baseline (#4338)
#4326 (ComboContext) introduziu uma violação de complexity: phaseComboSetup media
17 (>15) — a extração moveu os condicionais de pinning/ternários para uma função
própria que estourava o teto (irônico para uma decomposição). Extrai o pinning para
resolveContextCachePin; phaseComboSetup volta a <15.

- comboSetup.ts: helper resolveContextCachePin (server-side context-cache pinning),
  phaseComboSetup chama-o; remove 'log' órfão do destructure.
- complexity-baseline 1890->1895: -1 do fix + drift de features mergeadas após a
  reconciliação #4330 (#4327 per-key USD quota + outros). Medido no tip 70d89d2f6.

Comportamento preservado: 369/369 testes combo (incl. combo-context), typecheck 0.
2026-06-19 23:28:55 -03:00
Diego Rodrigues de Sa e Souza
70a3330815 fix(translator): keep a tool property named 'pattern' through Gemini schema sanitization (#4339)
The Gemini/Antigravity schema sanitizer strips JSON-Schema constraint
keywords Gemini rejects (pattern, minLength, ...) at every nesting level,
but it also deleted any tool property literally NAMED one of those keywords.
glob/grep tools declare a property called `pattern`, so on ag/* backends
that argument (and its `required` entry) was silently dropped, breaking the
tools. Keyword stripping is now position-aware: constraint keywords are only
removed at the schema-node level, never against the user-defined names inside
a `properties` map. A genuine string-level `pattern` constraint is still
stripped.

Reported-by: youthanh (https://github.com/decolua/9router/issues/1368)

Co-authored-by: youthanh <74104625+youthanh@users.noreply.github.com>
2026-06-19 23:24:23 -03:00
Diego Rodrigues de Sa e Souza
8c96cdeade fix(translator): flatten MCP namespace tools to functions on Responses->Chat path (#4340)
When a Codex CLI client routes a Responses-API request to a non-Codex
backend (e.g. kr/claude-opus-4.7), each MCP server is declared as a
`namespace` tool: { type:"namespace", name, tools:[{name, description,
parameters}] }. The Responses->Chat translator had no namespace branch, so
the whole group collapsed into one empty-schema function named
`mcp__<server>__` and every MCP call failed with
`unsupported call: mcp__<server>__`, breaking all MCP workflows for that
combination. The translator now expands a namespace into one Chat function
per sub-tool (name + parameters preserved); an empty namespace yields no
tools. The native Codex passthrough path was already correct.

Reported-by: V13t4nh (https://github.com/decolua/9router/issues/1534)

Co-authored-by: V13t4nh <201110185+V13t4nh@users.noreply.github.com>
2026-06-19 23:23:41 -03:00
Diego Rodrigues de Sa e Souza
7a7b437b61 fix(quality): restore release/v3.8.30 green — 3 latent reds from concurrent merges (#4335)
* fix(providers): bailian-coding-plan static catalog matches registry (10 models)

The provider-model sweep (#4324) added qwen3.7-plus, qwen3-coder-plus,
qwen3-coder-next and glm-4.7 to the bailian-coding-plan registry entry but
left the static fallback mirror in staticModels.ts at the older six, so the
static↔registry parity test (bailian-coding-plan-provider.test.ts) went red on
release/v3.8.30 whenever TIA selected it. Restore the mirror to all ten models
in registry order and align the two legacy count/ID assertions.

* chore(test): collect tests/unit/combo/ in the unit runner glob

PR #4326 (ComboContext god-file split) added tests/unit/combo/combo-context.test.ts
but the unit-runner brace glob had no 'combo' entry, so its 4 tests were orphaned —
check:test-discovery flagged a NEW orphan, a second latent red on release/v3.8.30.
Add 'combo' to the glob across all lock-step collectors: the 7 package.json test
scripts, build-test-impact-map.mjs, check-test-discovery.mjs and the 4 ci.yml run
lines. Folded here (rather than a separate PR) because the two release reds are
interdependent for Fast-QG: a package.json change triggers the full suite, so a
combo-only PR would still trip the bailian red and vice-versa — fixing both in one
PR is the only way to land a genuinely green Fast-QG.

* chore(db): register apiKeyColumnFallbacks + apiKeyUsageLimitFields as db-internal

The api-key usage-limits feature (migration 101) split two helper modules out of
src/lib/db/apiKeys.ts — apiKeyColumnFallbacks.ts and apiKeyUsageLimitFields.ts — but
did not register them with check:db-rules, so both were flagged as new db/ modules
not re-exported by localDb.ts (Hard Rule #2), a third latent red on release/v3.8.30.
Both are imported only by db/apiKeys.ts (within src/lib/db/), so they are db-internal:
add them to INTENTIONALLY_INTERNAL with that classification (mirrors healthCheck /
stateReset) rather than re-exporting internal helpers onto the public localDb surface.
2026-06-19 23:23:20 -03:00
Diego Rodrigues de Sa e Souza
7ad2bef38d chore(quality): reconcile complexity baseline 1890->1896 (post-deploy lote drift) (#4336)
#4327 (per-key USD usage quotas), #4334 (cache-aware compression guard) and #4326
(phaseComboSetup) added new conditional branches landing after #4330 measured 1890;
the release fast-path doesn't run check:complexity. Measured 1896 on the merged tip.
2026-06-19 22:47:38 -03:00
Witroch4
70d89d2f68 feat(keys): add per-key USD usage quota controls (#4327)
* feat(keys): add per-key USD usage quotas

Adds daily and weekly API key USD caps with reset-aware weekly windows, exposes quota controls in API key permissions and costs views, and returns Claude Code-safe 400 responses when caps are exceeded.

Validations:

- node --import tsx/esm --test tests/unit/api-key-usage-limits.test.ts tests/unit/internal-usage-command.test.ts

- npm run typecheck:core

- npm run check:file-size

- npm run check:migration-numbering

- npm run lint

- Docker image build/deploy smoke test on 100.64.0.1:20128

* fix(db): renumber api_key_usage_limits migration 100->101 (avoid cli_access_tokens collision)

Migration version 100 is taken by 100_cli_access_tokens.sql on release; the
migrationRunner version-collision guard would otherwise skip one. Renumber to 101.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* chore(quality): bump apiKeys.ts file-size baseline 1661->1662 (USD quota fields)

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: Wital <wital@example.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-06-19 22:42:54 -03:00
Diego Rodrigues de Sa e Souza
fbb423f32b refactor(combo): ComboContext + extract phaseComboSetup (god-file split fase 1) (#4326)
Primeiro incremento da decomposição do god-file combo.ts (2604 LOC). Extrai o
bloco de setup do handleComboChat (strategy/relay/resilience/universal-handoff/
context-cache pinning/agent middleware/config cascade/timeout) para
phaseComboSetup(ctx), com ComboContext carregando o body mutável compartilhado.

- combo/context.ts: ComboContext (carrier do body mutável) + createComboContext.
- combo/comboSetup.ts: phaseComboSetup(ctx): ComboSetup (byte-equivalente ao bloco
  inline; reescreve ctx.body no pin + middleware, retorna os locals derivados).
- comboConfig.ts: resolveComboSetupConfig (DRY — encapsula o ternário do config,
  tipo único p/ ComboContext.config sem cast).
- combo.ts: -47 linhas; rebinda os locals do ctx, resto de handleComboChat intacto;
  remove 6 imports que ficaram órfãos.

Comportamento preservado: 357 testes de caracterização combo seguem verdes (+4 novos),
integração sse-correctness 5/5, typecheck 0, file-size encolhe (sem _rebaseline).
Fases 2-N = sub-planos follow-up. Ver _tasks/quality/2026-06-19-DESIGN-godfiles-decomposition.md.
2026-06-19 22:39:49 -03:00
Diego Rodrigues de Sa e Souza
5cca4ff90c fix(executors): reconstruct LMArena split auth cookie (#4271) (#4331)
LMArena migrated to @supabase/ssr chunked auth cookies: the single
arena-auth-prod-v1 cookie is now empty and the session is split across
arena-auth-prod-v1.0, .1, … (ascending). Pasting the now-empty single
cookie sent an empty session, which upstream rejected as "invalid cookie".

reconstructLMArenaCookie() rebuilds the single cookie from its chunks
(ascending join, no decode/parse — combineChunks semantics), preserving
the rest of the pasted jar; a non-empty single cookie is forwarded
unchanged (back-compat). The credential UX now instructs pasting the full
Cookie header and tracks the .0/.1 storage keys.

Closes #4271
2026-06-19 22:32:37 -03:00
Diego Rodrigues de Sa e Souza
62e0920e5e fix(compression): preserve cacheable prefix for automatic-cache providers (#3955) (#4334)
OpenAI / Codex / Azure-OpenAI use automatic prefix caching: the upstream
caches the longest matching prefix of a request (system prompt + earliest
messages) WITHOUT any explicit cache_control markers. The cache-aware
compression guard only protected that prefix when the body carried explicit
cache_control, so for automatic-cache providers the guard was skipped — and
with compression active + preserveSystemPrompt:false (or a prefix-compressing
mode) it rewrote the prefix, guaranteeing a cache miss and higher token spend
through OmniRoute than going direct.

getCacheAwareStrategy now treats isCachingProvider alone as sufficient to skip
the system prompt and downgrade aggressive/ultra (the explicit cache_control
path is a subset). openai/codex/azure are added to CACHING_PROVIDERS so they
are recognized as automatic-cache providers (this also activates the intended
prompt_cache_key cache-routing hint for OpenAI in chatCore).

Compression remains off by default — this only affects operators who enabled
it with prefix preservation turned off.

TDD: tests/unit/compression-cache-guard-3955.test.ts (RED 5/7 fail → GREEN
7/7). Aligned the existing cachingAware / strategySelector-cache-aware /
cache-control-policy / cache-control-claude-providers tests that encoded the
old (buggy) "openai is non-caching" behavior.

Refs #3955
2026-06-19 22:31:42 -03:00
Diego Rodrigues de Sa e Souza
24cee53c2f fix(executors): DuckDuckGo AI Chat uses duckduckgo.com (fixes 400) (#4037) (#4333)
The DuckDuckGo AI Chat executor fetched status/chat and set Origin/Referer
against https://duck.ai while sending Sec-Fetch-Site: same-origin, making the
same-origin triplet (host + Origin + Referer) inconsistent so the backend
rejected the request with HTTP 400. Repoint the executor's status URL, chat
URL, Origin, Referer, and warm fetch to https://duckduckgo.com (matching the
provider registry baseUrl and current DDG reverse-engineering references); the
same-origin header is now coherent.

Also relax FE_VERSION_PATTERN from a 40-hex tail to a bounded {20,40} tail so it
matches the real served x-fe-version token (20-hex, e.g.
serp_20250401_100419_ET-19d438eb199b2bf7c300) instead of silently falling back
to the hardcoded default. The bound keeps the pattern ReDoS-safe.

This is the DuckDuckGo half of the report; the separate Chipotle upstream
breakage is tracked independently.

TDD: tests/unit/duckduckgo-domain-4037.test.ts (8 assertions, RED before the
fix, GREEN after). Baseline bump 917->925 for the added comments.

Refs #4037
2026-06-19 22:30:48 -03:00
Diego Rodrigues de Sa e Souza
8f21fcee99 fix(security): bound prompt-injection regex scan to first 16KB (#3932) (#4332)
The prompt-injection guard joined every message/system string into one
buffer and ran several regexes over the whole thing on every chat
request, with no size cap. At high concurrency with large bodies (300 KB
of pasted code / RAG context) that is O(body) CPU scanning on the hot
path — a self-inflicted latency/GC source under load.

Bound both detection call sites — detectInjection() in inputSanitizer.ts
and the custom-pattern scan in promptInjection.ts — to the first 16 KB
via a named MAX_INJECTION_SCAN_BYTES constant, slicing the joined text
before the regex loop. Injection directives sit near the top of a
prompt, so the generous cap preserves real detection while scanning only
a bounded prefix. No call site removed and opt-out behavior unchanged;
this only bounds the scan length. The existing 10 MB body-size cap that
protects ingestion is separate and untouched.

TDD: tests/unit/injection-guard-scan-bound-3932.test.ts proves a
directive at the top of a >16 KB body is still detected (case 1) while
the same marker placed beyond the 16 KB cap is no longer scanned
(case 2, RED before the fix), at both call sites.

Refs #3932
2026-06-19 22:18:05 -03:00
Diego Rodrigues de Sa e Souza
2bd7fa6691 docs: ban AI-generation footers in commits/PRs (Hard Rule #16) (#4328)
Extend Hard Rule #16 beyond Co-Authored-By trailers to also forbid AI-generation
footers/descriptions (e.g. "Generated with Claude Code") anywhere in a commit
message, PR title/body, or CHANGELOG — they are equivalent to crediting an AI as
co-author. Explicitly overrides any harness/template default that auto-appends
such a footer.
2026-06-19 22:02:16 -03:00
Diego Rodrigues de Sa e Souza
1dc4e0781d chore(quality): reconcile complexity baseline 1888->1890 (lote3 merge drift) (#4330)
#4313 (harvested features) and #4323 (compression e2e audit) added new conditional
branches that landed after #4318 measured 1888; the release fast-path doesn't run
check:complexity, so reconcile on the merged release tip (Rule #9, release-volatile).
2026-06-19 21:55:39 -03:00
Diego Rodrigues de Sa e Souza
5d89fa84e7 fix(compression): end-to-end audit — fixes across the whole compression flow (#4323)
* fix(compression): SLM worker resolves deps+worker file without import.meta.url (B-SLM)

The Next.js standalone bundle (webpack) replaces createRequire(import.meta.url)
with a stub that always throws MODULE_NOT_FOUND, and freezes import.meta.url to the
build-machine path. So depsAvailable() was always false (the worker never spawned)
and resolveWorkerFile() anchored on a path absent at runtime — the SLM silently
fell back to the aggressive summarizer in production. Confirmed by inspecting
dist/.build/next/server/chunks/26410.js (stub module 215743 + frozen file:// path).

Replace both with filesystem probing from runtime anchors (process.cwd(),
process.argv[1]) that survive the bundle. Necessary complement to #4286 (deps
co-location) for the SLM to actually engage in prod; still fail-open without it.
VPS live validation deferred (Rule #18); local resolver regression tests added.

* fix(compression): ultra heuristic preserves code blocks / inline code / URLs (B-ULTRA-CODE)

ultra.ts called pruneByScore on raw text with no tombstoning, so the token pruner
dropped low-score code tokens (`b)`, `{`, `+`) inside fenced blocks while leaving the
fence markers intact — output that looked like valid code but was syntactically
destroyed. caveman + llmlingua both extract/restore preserved blocks first; ultra was
the only pruning engine that didn't.

Add pruneProseOnly(): extractPreservedBlocks tombstones fenced code, inline code,
URLs, CONST_CASE, versions; only the prose between placeholders is pruned; preserved
blocks are re-stitched verbatim.

* fix(compression): GCF round-trips values containing the inline-array pattern [..]: (B-GCF-QUOTE)

A value like `ERR[404]: Not Found` / `[Speaker 1]: Hello` nested one level deep was
emitted bare and re-parsed by the decoder as an inline-array header → it threw
`count_mismatch` (or silently decoded wrong), losing the whole block. headroomEngine
.apply() ships such blobs in prod, so this was a reachable lossless violation.

Two complementary fixes, both per SPEC §2.4:
- encode: needsQuote() now quotes strings matching `[`…`]``:` (spec compliance / other
  decoders).
- decode: the inline-array branch only fires when the bracket is in the KEY position
  (no `=` before it), so a quoted `note="ERR[404]: …"` value falls through to key=value.

* fix(compression): aggressive fidelity — keep text blocks, compress Anthropic tool_result, don't corrupt JSON (B-AGG-*)

Three fidelity fixes in the aggressive path (each TDD, aggressive-fidelity.test.ts):
- B-AGG-TEXTDROP: replaceTextContent dropped 2nd+ text blocks unconditionally; now a
  trailing block is dropped only when its text is already subsumed by newText, else kept.
- B-AGG-ANTHROPIC-TR: tool-result compression only fired for OpenAI role:tool messages;
  now Anthropic-shape tool_result content blocks (inside user messages) are compressed
  too, preserving tool_use_id + block structure.
- B-AGG-JSONTAG: the [COMPRESSED:aging:*] prefix corrupted JSON/code payloads; pure JSON
  is now kept verbatim+untagged (stays parseable), fenced blocks get the tag on a
  preceding line.

* fix(compression): accessibility collapse preserves [ref] anchors + fires on interleaved trees (B-MCPA11Y-*)

- B-MCPA11Y-ANCHORS: collapseRepeated silently dropped the omitted middle siblings'
  [ref=eNN] anchors (the agent could no longer click them); now every omitted ref is
  kept alongside the collapse notice. Wires the previously-dead preserveRefPattern.
  Invariant: extractRefs(input) ⊆ extractRefs(output).
- B-MCPA11Y-COLLAPSE: noise removal blanked lines (replace→""), and a blank line broke
  the sibling run so collapse never fired on realistic interleaved trees; noise lines
  are now deleted, and the sibling walk skips stray blanks.

* fix(compression): rtk intensity scales the line budget (B-RTK-INTENSITY)

The intensity knob only set smartTruncate's preserveHead/Tail (16↔24), which rarely
fired because the matched filter capped lines first — so minimal/standard/aggressive
produced byte-identical output on filter-matched tool output. effectiveMaxLines() now
scales the effective line budget (minimal 1.5x, standard 1x, aggressive 0.5x) at both
the per-filter and engine-level truncation sites. Both go through smartTruncate with
priorityPatterns, so error/failure lines survive at every intensity (tested).

* fix(compression): robust language detection + auto-detect honors the detected pack (B-LANG-*)

- B-LANG-DETECTOR: detector was first-match-wins on a single keyword, and some hints are
  English-ambiguous ("configuration" in fr, "error" in es) → English text misclassified.
  Now score-based (count native-keyword hits, highest wins), and the two English-ambiguous
  words are removed from the hint lists, so a lone shared word never misclassifies while
  sparse-keyword languages (id) still detect on a single native word.
- B-LANG-DORMANT: with autoDetectLanguage on but enabledPacks ["en"], detected non-English
  text fell back to the English pack, whose `articles` rule deletes foreign articles
  (pt-BR "a"/"o"). Auto-detect now uses the detected pack directly (it always has rules);
  enabledPacks still gates manual selection.

* fix(compression): mode selection enables its engine + align stacked allowlist (B-MODE-ENGINE-DECOUPLE, B-PIPELINE-DIVERGENCE)

- B-MODE-ENGINE-DECOUPLE: picking the standard/rtk MODE now runs caveman/rtk regardless of
  the per-engine enabled flag — the mode selection is the enable signal (the per-engine flag
  still gates stacked pipeline steps). Previously an operator who picked a mode but left the
  engine toggle off got silent 0% compression.
- B-PIPELINE-DIVERGENCE: the global stackedPipeline normalizer stripped
  session-dedup/ccr/headroom/llmlingua (engines the combo path accepts via KNOWN_ENGINE_IDS).
  The allowlist now matches, so the global setting can use all registered engines.

* docs(compression): correct SLM "stable" claim + document partial packs / stacked telemetry limits

- The llmlingua `stable:true` comment claimed the bundle walk-up + deps-gate were
  "confirmed against the live install" — that was wrong (webpack froze import.meta.url and
  stubbed createRequire, so the worker never spawned in prod). Corrected to reflect B-SLM.
- COMPRESSION_ENGINES.md: add a Known limitations section (SLM dep co-location requirement,
  partial de/fr/ja packs, no-op engines absent from engineBreakdown).

* fix(compression): cast normalized engine id to CompressionPipelineStep['engine'] (typecheck)
2026-06-19 21:51:12 -03:00
Diego Rodrigues de Sa e Souza
2c0fd04704 feat: implement 5 harvested feature requests (#4239, #4155, #3841, #3266, #4240) (#4313)
* feat(providers): add OpenAdapter, dit.ai and TokenRouter OpenAI-compatible providers (#4239, #4155, #3841)

Three community-requested OpenAI-compatible aggregators register as standard
named OpenAI-style providers (the zenmux pattern): live /v1/models discovery via
NAMED_OPENAI_STYLE_PROVIDERS, falling back to a seeded catalog on upstream error.
No custom executor/translator — default OpenAI passthrough.

- OpenAdapter  https://api.openadapter.in/v1  (free tier)            #4239
- dit.ai       https://api.dit.ai/v1          (dynamic-pricing)      #4155
- TokenRouter  https://api.tokenrouter.com/v1 (free MiniMax model)   #3841

Base paths confirmed live (each returns a 401 OpenAI-style error body). Seed
catalogs are intentionally minimal (author/doc-cited ids only; TokenRouter
deepseek ids come from production via #3946); full upstream model lists arrive
through live discovery once a key is configured.

* feat(combo): per-step account allowlist for round-robin over a connection subset (#3266)

A combo model step can now carry a first-class `allowedConnectionIds` so a
round-robin / weighted strategy is scoped to a subset of a provider's
connections (e.g. {foo1, foo2}) without hand-pinning one step per account.

- steps.ts: parse `allowedConnectionIds` on the model step (trim + drop empty)
- comboStructure.ts: second writer — propagate the step allowlist onto the
  resolved target (tag routing is the first writer)
- autoStrategy.ts: when a step allowlist AND tag routing both apply, intersect
  them (most-restrictive wins); empty intersection drops the target
- builderDraft.ts + combos UI: optional 'Restrict to accounts' picker in the
  Precision step editor (a pinned single account still takes precedence)

The downstream credential-selection filter (auth.ts) already honours
allowedConnectionIds, so a round-robin scoped to {foo1, foo2} provably never
selects foo3/foo4 (regression test included). Ships the enhancement only; the
#2829 bug-triage half stays open pending the reporter.

* feat(dashboard): category (media serviceKind) filter on the providers page (#4240)

Add a media-category filter row (Image / Video / Music / Text→Speech /
Speech→Text / Embedding) to /dashboard/providers that composes with the existing
search, free-only and 'show configured only' filters.

- serviceKindIndex.ts: client-side resolver unioning a provider's declared
  serviceKinds with the registry-derived media kinds (memoised)
- providerPageUtils: filterConfiguredProviderEntries gains a serviceKindFilter
  argument; threaded through every provider section on the page
- ProviderSummaryCard: a second chip row drives the serviceKind filter

Membership is derived from the backend media registries, so a provider that
serves a kind is surfaced even when it never declared serviceKinds — keeping the
UI in lockstep with the backend (mirrors the media-providers pages).

* chore(quality): rebaseline file-size for the v3.8.30 harvested features

Four frozen files grew from their own additive feature wiring (#4239/#4155/#3841
providers, #3266 combo allowlist UI, #4240 serviceKind filter):
- src/shared/constants/providers.ts 3169->3213 (3 provider entries)
- src/app/api/providers/[id]/models/route.ts 2554->2560 (3 NAMED set entries)
- src/app/(dashboard)/dashboard/combos/page.tsx 4350->4385 (allowlist picker)
- src/app/(dashboard)/dashboard/providers/page.tsx 1925->1927 (serviceKind state)

All cohesive additive wiring at existing chokepoints; rationale recorded in the
_rebaseline_2026_06_19_v3830_harvest_features key.
2026-06-19 21:49:27 -03:00
Diego Rodrigues de Sa e Souza
7ce875f404 feat(providers): provider model sweep — live discovery, refreshed catalogs, dead-provider cleanup (#4324)
* feat(providers): refresh core official model catalogs (sweep lote 1)

Adiciona modelos GA atuais (verificados online) aos provedores oficiais core:
- openai: gpt-5.5-pro, gpt-5.4-pro
- anthropic: claude-opus-4.8 + claude-fable-5 (sampling fixo 4.7+, espelha 4.7), claude-opus-4.5
- groq: qwen/qwen3.6-27b, openai/gpt-oss-safeguard-20b
- xai: grok-build-0.1

Fase 4 do provider-model-sweep. provider-consistency/file-size/typecheck:core verdes.

* feat(providers): wire live /models discovery for 7 openai-style providers (sweep lote 2)

venice, deepinfra, wandb, pollinations, nscale, inference-net and moonshot each
expose a real live `<baseUrl>/models` catalog (the sweep probed each upstream),
but were classified fixed-official, so import served their small hardcoded seed
and re-staled the catalog. Add them to NAMED_OPENAI_STYLE_PROVIDERS so import does
a live `<baseUrl>/models` fetch, keeping the registry seed only as the offline
fallback — same fix shape as #4249 (vercel-ai-gateway) / #4202 (zenmux) / #3976
(llm7/byteplus). siliconflow was already classified.

TDD regression in tests/unit/provider-sweep-live-discovery.test.ts pins each
derived /models URL + the local-seed fallback path. file-size baseline bumped
2538->2548 (+10 = 7 Set entries + 3-line comment; not extractable).

* feat(providers): wire live /models discovery for 12 aggregator marketplaces (sweep lote 3)

crof, featherless-ai, ovhcloud, sambanova, orcarouter, uncloseai, opencode-go,
baseten, hyperbolic, nebius, scaleway and together are GPU-cloud / aggregator
marketplaces hosting large, volatile OSS catalogs. The sweep probed each and
confirmed a live `<baseUrl>/v1/models` endpoint (200 public or 401/403 = exists +
keyed), yet they were classified fixed-official and served a small hardcoded seed.
Add them to NAMED_OPENAI_STYLE_PROVIDERS so import does a live `<baseUrl>/models`
fetch (graceful fallback to the registry seed on any upstream error), keeping the
catalog fresh instead of re-staling a hardcoded list.

Extends tests/unit/provider-sweep-live-discovery.test.ts to 20 cases pinning each
derived /models URL. file-size baseline bumped 2548->2564 (+16; not extractable).

* feat(providers): add verified new models to nvidia, meta-llama, morph (sweep lote 4)

Curated first-party / specialist menus (kept hardcoded — their per-model flags
like toolCalling/supportsReasoning can't be inferred from a live catalog):

- nvidia: + stepfun-ai/step-3.7-flash, deepseek-ai/deepseek-v4-flash
  (supportsReasoning), moonshotai/kimi-k2.6 — all confirmed present in the live
  NIM /v1/models catalog. minimaxai/minimax-m3 deliberately left out per #3329
  (now listed, but its inference still needs confirmation before re-adding).
- meta-llama: + Llama-3.3-8B-Instruct.
- morph: + morph-qwen35-397b, morph-minimax27-230b, morph-qwen36-27b,
  morph-dsv4flash (Morph-hosted fast models, with context lengths).

Skipped this batch after review: upstage solar-pro2 (older than the solar-pro3
already in the registry); longcat LongCat-2.0-Preview (deliberately commented out).

* feat(providers): refresh Chinese first-party model catalogs, online-verified (sweep lote 5)

Each registry held a single stale id; refreshed against official docs after
per-id online verification (subagent research, cross-checked against first-party
sources). Rejected/omitted entries are documented inline.

- baidu: + 15 ERNIE ids (5.0/5.1 are the current flagships, confirmed live on Qianfan).
- doubao: + 8 Seed-2.0/1.x dated Ark ids (Seed 2.0 GA 2026-02-14, confirmed real).
- sensenova: + 8 SenseChat/SenseNova ids (V6.5-Pro flagship; 6.7-flash-lite lowercase).
- tencent: + hunyuan-turbos-latest/t1-latest/vision/functioncall/lite. Dropped legacy
  standard/-256K/code/role + pinned turbos-20250226. NOTE: legacy Hunyuan platform
  EOLs turbos/t1 on 2026-06-22 (migrating to TokenHub/hy3-preview) — revisit.
- baichuan: + Baichuan4-Turbo/Air, Baichuan3-Turbo/-128k (official pricing page).
- stepfun: + step-3.7-flash (flagship), step-3.5-flash(-2603), step-1o-turbo-vision.
- iflytek: + 4.0Ultra, max-32k, generalv3, pro-128k, lite (exact HTTP domains).
- sparkdesk: + 4.0Ultra, generalv3, pro-128k. Rejected spark-x (separate /v2|/x2 endpoint).
- volcengine: + doubao-seed-2-0-pro-260215, kimi-k2-5-260127 (Ark-hosted).

* feat(providers): add verified models to kie, nlpcloud, publicai (sweep lote 6)

- kie: + claude-opus-4-8, gemini-3-5-flash (current flagships the proxy surfaces;
  gemini-3-pro skipped — registry already carries the newer gemini-3-1-pro).
- nlpcloud: + chatdolphin, dolphin (branded models), finetuned-llama-3-70b,
  llama-3-1-405b. Host confirmed reachable.
- publicai: + Apertus-8B, Gemma-SEA-LION-v4-27B, Olmo-3-7B, EuroLLM-22B (open models).

Skipped after review: minimax M2/M2.1 (older than the M2.5 floor the registry
curates); yi (api.lingyiwanwu.com degraded + 01.AI exited foundation models);
llamagate (host llamagate.ai unreachable, code 000) — both flagged for Track C.

* feat(providers): finish Track B tail — cloudflare-ai, bailian, suno, +5 (sweep lote 7)

- cloudflare-ai: + 7 Workers AI catalog ids (llama-3.3-70b-fp8-fast, qwen2.5-coder-32b,
  qwq-32b, llama-3.2-3b, glm-4.7-flash, kimi-k2.6, gemma-4-26b).
- bailian-coding-plan: + qwen3.7-plus, qwen3-coder-plus, qwen3-coder-next, glm-4.7.
- suno: + chirp-fenix (V5.5), chirp-crow (V5).
- monsterapi: + Meta-Llama-3.1-8B, Llama-3.3-70B.
- huggingchat: + Qwen3-235B-A22B, Mistral-Small-3.1-24B.
- vertex-partner: + claude-opus-4-8, claude-opus-4-6.
- puter: + google/gemini-3.5-flash.
- codestral: + codestral-2508.

Skipped after verification: windsurf + devin-cli — docs.devin.ai exposes DASHED ids
(claude-opus-4-8-low, MODEL_PRIVATE_4 for "Grok Code Fast 1", minimax-m2-5) while the
registry uses DOTTED (claude-opus-4.7-max); id-form ambiguity needs owner confirmation
before adding 13+ entries. leonardo/ideogram (image UUID-vs-friendly convention),
glmt (shared GLM_SHARED_MODELS, redundant with the live `glm` provider).

* fix(providers): drop retired models, add codestral-2405 forward (sweep lote 8, Track C C1)

Confirmed removals that interacted with the sweep's adds:
- codestral: drop codestral-2405 (retired 2025-06-16, Mistral official docs) from the
  menu + add a codestral-2405 -> codestral-2508 deprecation alias so old configs forward.
- monsterapi: drop llama-3-8b-fuse (no longer evidenced in the catalog).
- volcengine: drop kimi-k2-thinking-251104 (retired on Ark; superseded by kimi-k2-5-260127).

* chore(providers): mark 6 dead providers deprecated (sweep lote 9, Track C C2)

The sweep verified these providers are no longer reachable/operational, so flag
them with the existing deprecation mechanism (deprecated:true + a deprecation risk
notice) instead of silently offering non-working options. Conservative — plumbing
(executors/icons/free-catalogs) is left intact; only the UI-facing metadata changes.

- kluster, glhf, predibase, inclusionai, galadriel: api host DNS no longer resolves.
- phind: API shut down 2026-01 (www.phind.com/api/chat no longer serves).

Not touched: gemini-cli (Google OAuth infra still live), qwen (already deprecated),
chipotle (easter-egg, out of scope). file-size baseline bumped 3169->3198.

* fix(providers): replace retired LongCat-Flash line with LongCat-2.0-Preview (sweep lote 10)

The LongCat-Flash-* models (Lite/Chat/Thinking/Omni-2603) were officially retired
2026-05-29; the current longcat.chat/platform docs expose only LongCat-2.0-Preview
(confirmed via WebFetch of the live API docs). Swap the stale 4-model seed for the
single current model so the provider stops offering dead ids.

* chore(quality): reconcile antigravity.ts file-size baseline 1664->1680

#4309 (Undici socket-leak fix) grew antigravity.ts by +26 lines but its file-size
baseline was not bumped at merge time; reconcile it here on the combined tree so the
release file-size gate stays green (Rule #9, release-volatile reconciliation).
2026-06-19 21:45:54 -03:00
Diego Rodrigues de Sa e Souza
831bd0a7b3 feat(quality): make the a11y gate real (@axe-core/playwright in nightly) (#4321)
* feat(quality): instala @axe-core/playwright + allowlist (T13)

Pré-requisito do gate de a11y real. O spec tests/e2e/a11y.spec.ts já tem o
mecanismo REQUIRE_AXE=1 que falha se o pacote estiver ausente quando exigido —
faltava só o pacote + o job nightly + o baseline real.

- @axe-core/playwright@^4.11.3 em devDependencies (via --package-lock-only,
  não toca o node_modules compartilhado das worktrees)
- adicionado à dependency-allowlist (check:deps OK, 127 deps)
- vuln-ratchet OK (1 moderate pré-existente, baseline 10 — axe não regride)

* feat(quality): job a11y nightly (REQUIRE_AXE=1) + gate per-PR (T13)

- novo job 'a11y' em nightly-resilience.yml: o webServer do Playwright builda o
  Next (build-next-isolated.mjs) e sobe o standalone sozinho (sem artefato pré-
  buildado como o test-e2e do ci.yml), REQUIRE_AXE=1 roda a análise axe real.
- gate de nightly no spec: a11y.spec.ts é casado pelo glob 'tests/e2e/*.spec.ts'
  do job per-PR (9 shards); sem gate, instalar @axe-core/playwright ligaria axe
  em TODO PR (e falharia em baseline 0). Os 4 testes de página agora skipam a
  menos que REQUIRE_AXE=1 — per-PR fica rápido/verde, nightly roda real.
- zizmor 139->145: +3 drift pré-existente da base a23d0d678 (release fast-path
  não ratcheta workflows) + 3 do job novo (checkout/setup-node/cache @vN, mesma
  convenção deliberada e INTOCADA de todos os workflows). Ver nota no baseline.

* feat(quality): congela baseline real de violações a11y (T13)

Medido no primeiro run nightly (27852779527, REQUIRE_AXE=1): /login=1,
/dashboard=4, /dashboard/providers=3, /dashboard/settings=5. O webServer
buildou+subiu o app e o axe rodou nas 4 páginas; a falha do run foi só o
assert vs baseline 0 (esperado), não infra.
2026-06-19 21:43:51 -03:00
Diego Rodrigues de Sa e Souza
facbb1964f feat(quality): unblock R1 — test-redundancy measurement via disableBail (#4322)
* feat(quality): config stryker disableBail para medir redundância (R1)

Estende stryker.conf.json com disableBail:true (killedBy lista TODOS os killers,
não só o primeiro) + incremental:false (medição limpa). One-off, não toca o
nightly — só roda via mutation-redundancy.yml.

* feat(quality): workflow on-demand de mutação disableBail (R1)

mutation-redundancy.yml (workflow_dispatch): roda os 6 leaf-batches combo+chatCore
do nightly com o override disableBail e sobe os reports. Batches granulares (d/e/f/
g/h/i) em vez de 2 mega-batches: disableBail é mais caro e Stryker só grava o report
se COMPLETAR, então cada batch cabe nos 300min.
zizmor 139->145: +3 drift de base + 3 do workflow (@vN, convenção do repo). Ver nota.

* feat(quality): mutation-radiography --candidates (lista de prune R1)

Reusa aggregateRadiography (DRY) em vez de um script novo: redundancyCandidates()
retorna 🔴 empty ∪ 🟠 redundant (zero kills únicos) = candidatos a prune. Sob
disableBail o killedBy é completo, então 🟠 redundant fica ACURADO. CLI --candidates
emite a lista com o aviso do gate humano (excluir segurança/contrato/repro).
2026-06-19 21:40:57 -03:00
Diego Rodrigues de Sa e Souza
0d6c5686d2 feat(dashboard): list MITM hosts-file entries in the tool card (#4325)
The CLI-tools MITM card's "How it works" section showed a single hardcoded example
domain (only antigravity/kiro). It now lists every 127.0.0.1 <host> entry for the
selected tool, so users on locked-down machines — where the automatic, sudo-gated
hosts-file edit isn't available — can add them manually. Hosts come from a client-safe
projection of the canonical src/mitm/targets registry (MITM_TOOL_HOSTS), kept in
lock-step with the registry by a sync test (no duplicated source of truth).

Co-authored-by: mrcyclo <13806369+mrcyclo@users.noreply.github.com>
2026-06-19 21:39:56 -03:00
Ardem2025
5c68048650 fix(sse): release reader and cancel stream on abort/error to prevent Undici pool socket leak (#4309)
* fix(sse): release reader and cancel stream on abort/error to prevent Undici pool socket leak

* test(sse): regression guard for Undici socket release on abort (#4309)

Proves collectStreamToResponse releases the reader and cancels the response
body on the abort/error path (fails without the fix, passes with it) — Rule #18.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: Dmitry Kuznetsov <dmitry@kuznetsov.me>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-06-19 21:39:28 -03:00
NOXX - Commiter
3b21a952fc fix(kiro): emit early role-only start chunk to release stream-readiness gate (#4311)
* fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security) (#4304)

* fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security)

Resolves Dependabot alerts on package-lock.json and electron/package-lock.json:

- undici 7.x -> 7.28.0: TLS certificate validation bypass via dropped requestTls in SOCKS5 ProxyAgent (GHSA-vmh5-mc38-953g, HIGH) + cross-user information disclosure via shared-cache whitespace bypass (GHSA-pr7r-676h-xcf6, MEDIUM). Fixed in the root (jsdom transitive) and electron lockfiles.

- dompurify -> 3.4.11: permanent ALLOWED_ATTR pollution via setConfig() bypassing the hook clone-guard (GHSA-cmwh-pvxp-8882, MEDIUM). Bumped the overrides floor from ^3.4.9 to ^3.4.11.

Also bumps node-gyp's transitive undici 6.26.0 -> 6.27.0, clearing the <6.27.0 advisories (WebSocket DoS, Set-Cookie handling) surfaced by npm audit. Lockfile/override-only change; no production source touched.

* ci(quality): exclude dependency manifests/lockfiles from PR test-policy

The PR test-policy gate classifies any changed file under src/, open-sse/, electron/, or bin/ as production code requiring tests. This false-flags lockfile/manifest-only changes (e.g. this Dependabot security bump touching electron/package-lock.json), since a lockfile cannot have a meaningful unit test.

Adds package.json / package-lock.json to EXCLUDED_PATTERNS, consistent with the existing .md/.yaml/.yml exclusions. Real production-code changes remain flagged.

* fix(kiro): emit early role-only start chunk to release stream-readiness gate

CodeWhisperer sends framing/metadata frames before the first content token; on large/agentic contexts that gap can be many seconds. ensureStreamReadiness holds the whole response from the client until it sees a useful SSE frame, so without an early frame the client sees a frozen connection up to STREAM_READINESS_TIMEOUT_MS (180s in VibeProxy) then a burst. Emit a role-only chat.completion.chunk on the first parsed AWS EventStream frame (a non-ping structured payload that satisfies hasStreamReadinessSignal) to hand off immediately, mirroring Claude message_start / OpenAI response.created.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
2026-06-19 21:36:12 -03:00
Diego Rodrigues de Sa e Souza
ad4338449c fix(quality): complexity gate covers bin/+electron + tracked-artifacts in pre-commit (#4318)
* fix(quality): complexity gate varre bin/ + electron (6A.11, fake-green fix)

ESLINT_ARGS passava só 'src open-sse', mas o config eslint.complexity.config.mjs
e o complexity-baseline.json já documentavam o escopo src+open-sse+electron+bin.
A edição do scan nunca tinha sido aplicada: o gate alegava cobrir bin/electron e
nunca os varria (fake-green — uma god-function nova em bin/ passava verde).

- exporta ESLINT_ARGS + adiciona 'electron' e 'bin' (casa o config)
- teste de build trava o escopo do scan
- baseline 1887->1888: electron+bin medem 0 (widening 0-custo); o +1 é drift
  pré-existente de src/open-sse da base a23d0d678 (#4308 et al.), não do widening

* fix(quality): check:tracked-artifacts no pre-commit (6A.12)

O gate estava no CI + pre-push, mas faltava no pre-commit — e o incidente do
symlink (artefato de build trackeado) acontece no 'git add'. Fecha o buraco no
ponto mais cedo.
2026-06-19 21:34:59 -03:00
Diego Rodrigues de Sa e Souza
0abc00cf2a fix(translator): clamp Responses API call_id to 64 chars (#4317)
* fix(translator): clamp Responses API call_id to 64 chars (port from 9router#396)

The OpenAI Responses API rejects call_id values longer than 64 characters with
a 400. Long upstream tool-call ids (some clients emit ids well over the limit)
were forwarded verbatim. Clamp the id deterministically on both the
function_call item and its matching function_call_output, so the pair stays
matched through the orphaned-output filter and the request is accepted.

Reported-by: ngapngap (https://github.com/decolua/9router/issues/393)
Co-authored-by: Anurag Saxena <17893081+anuragg-saxenaa@users.noreply.github.com>
Co-authored-by: ngapngap <27039619+ngapngap@users.noreply.github.com>

* chore(quality): bump translator-openai-responses-req file-size baseline 1011->1047

The clamp-call_id regression test (+36 lines) grew the test file past its frozen
baseline; bump it in the same change (Rule #9).

---------

Co-authored-by: Anurag Saxena <17893081+anuragg-saxenaa@users.noreply.github.com>
Co-authored-by: ngapngap <27039619+ngapngap@users.noreply.github.com>
2026-06-19 21:33:28 -03:00
Diego Rodrigues de Sa e Souza
032387401a fix(oauth): GitHub Copilot token refresh sends the public client_id (#4320)
GitHub Copilot is a public device-flow OAuth client (client_id, no client_secret),
but the github provider config never populated clientId. The standalone refresh path
omitted client_id (buildFormParams drops undefined) and the executor path sent the
literal "client_id=undefined&client_secret=undefined" — both rejected by GitHub, so a
Copilot connection got stuck once its short-lived token expired and the long-lived
refresh path was needed. Populate the provider clientId from the embedded public cred
(resolvePublicCred, never a literal) and only send client_secret when one exists. The
prior github refresh test patched a fake clientId/clientSecret onto PROVIDERS.github,
masking the broken real config — it now exercises the real config.

Co-authored-by: Manuel B. <1494154+baslr@users.noreply.github.com>
2026-06-19 21:31:42 -03:00
Diego Rodrigues de Sa e Souza
5193a595bf fix(dashboard): proxy modal stops pre-filling new scopes with an unrelated proxy (#4312)
The proxy assignments list returned by /api/settings/proxies/assignments is
global, so its first entry belongs to some other scope. ProxyConfigModal picked
`items.find(matchingScope) || items[0]`, so opening the proxy config for a freshly
created provider/key (which has no assignment of its own) fell back to items[0]
and pre-filled host/port/user/password from an unrelated proxy plus set
hasOwnProxy=true — users reported a new provider already carried a proxy they
never configured.

Extracted the scope helpers into proxyAssignment.ts and added selectScopeAssignment
which returns null (never items[0]) when the current scope has no assignment. The
modal then shows the empty/custom state for new scopes. Both call sites now use it.

TDD: src/shared/components/proxyAssignment.test.tsx (no-match -> null red->green for
provider/key/global scopes; matching-scope + empty-list regression guards). Existing
ProxyConfigModal component test stays green.
2026-06-19 21:31:39 -03:00
Diego Rodrigues de Sa e Souza
466d3cf6eb fix(open-sse): inner-ai stops silently rerouting unmatched models to models[0] (#4310)
Inner.ai's live model list is plan-gated, and findModel() fell back to
models[0] (the first live model, typically gpt-4o) whenever the requested
model did not match by exact/case-insensitive/substring. That silently
rerouted every model not exposed by the plan to gpt-4o, so users reported
that only gpt-4o ever responded.

findModel() now returns null on no match; the single caller already builds
a synthetic entry carrying the actually-requested model name, so the request
is sent for the model the user asked for and Inner.ai can surface a clear
error if the plan does not expose it.

TDD: tests/unit/inner-ai-find-model.test.ts (no-match -> null red->green;
exact / case-insensitive / substring / empty-list regression guards).
2026-06-19 21:31:30 -03:00
Diego Rodrigues de Sa e Souza
6912664133 fix(sse): retry direct socket failures on a fresh no-keep-alive dispatcher (#4252) (#4319)
The default direct dispatcher pools keep-alive sockets for up to
fetchKeepAliveTimeoutMs (4s). Edges like nvidia / opencode-zen silently
close idle keep-alive sockets within that window, so the next request
reusing a pooled socket fails with UND_ERR_SOCKET ("other side closed") —
in bursts. proxyFetch retried once, but the retry reused the SAME pooled
dispatcher and could grab another stale socket, then fell through to native
fetch (which also pools) → the job sat in the rate-limit queue until the
30s timeout → 502 + circuit breaker open.

Add getRetryDispatcher() (no keep-alive, no pipelining, mirrors the proxy
dispatcher mitigation) and use it for the retry attempt so it opens a fresh
socket that can't be a dead pooled one. The first attempt still uses the
pooled dispatcher, preserving healthy keep-alive reuse.

Regression test (DI mock) asserts the retry uses getRetryDispatcher(), not
getDefaultDispatcher() (RED before, GREEN after).

Refs #4281 (describeFetchCause diagnostics). Closes #4252
2026-06-19 20:44:07 -03:00
Diego Rodrigues de Sa e Souza
9a678497ad fix(sse): stop combo at the first body-specific 400 (#4279) (#4316)
The #2101 guard that detects a body-specific 400 (context overflow /
malformed / model-access-denied) logged "stopping combo" but executed a
bare `break`, which only exits the inner retry loop. executeTarget then
returns null, and the outer target loop treats null as "this target
produced nothing" and advances to the next model — so the guard never
actually stopped fallback, and a combo of N targets that all reject the
same request body tried all N (the report shows a 143-model Codex combo
marching through every target).

Surface the 400 via the {ok,response} contract (mirrors the 499
client-disconnect path) so the outer loop resolves the combo and stops.

Regression test: a 3-target priority combo whose targets all return a
body-specific 400 must stop after target 1 (RED before, GREEN after).

Closes #4279
2026-06-19 20:26:03 -03:00
Paijo
db7c8c5edc fix(pollinations): handle auth-required premium models (#4266)
Pollinations now requires API keys for premium models (claude, gemini, midijourney). The executor surfaces an actionable 401 with the keyless-model list, chatCore preserves the upstream HTTP status (401 -> authentication_error instead of 502), and the free catalog marks the premium models as key-required. Rebased onto the release tip and reconciled the file-size baseline (chatCore 5128). Thanks @oyi77.
2026-06-19 20:23:05 -03:00
Diego Rodrigues de Sa e Souza
84bf5dc7de fix(sse): recover reconstructed message when Responses terminal output is textless (#3948) (#4315)
A Responses-API target (codex/cx) streams from upstream even on stream:false.
Its terminal `response.completed` snapshot can carry a non-empty `output` that
lacks the assistant message item (e.g. only a reasoning item) even though the
streamed output_text deltas reconstructed a full message. parseSSEToResponsesOutput
preferred the terminal output wholesale, dropping the reconstructed text → empty
content on stream:false (hit via n8n, which defaults to stream:false).

Fall back to the reconstructed delta output when the terminal output has no
message item but the reconstruction does; the terminal snapshot still wins when
it already carries the message.

Regression test feeds a synthetic codex SSE (reasoning-only terminal + message
deltas) and asserts the assistant text survives, plus a control case where the
terminal carries the message.

Closes #3948
2026-06-19 20:17:49 -03:00
Diego Rodrigues de Sa e Souza
6103288c48 fix(executors): preserve tool-name casing on native Claude OAuth (#4307) (#4314)
The native-Claude OAuth anti-fingerprint cloak renames a tool named `read`
to `Read` on the wire and records the reverse alias on a non-enumerable
`_toolNameMap`, which the response side un-cloaks to restore the client's
original casing. Since v3.8.27 (#3941/#3968) `execute()` returned a
JSON-round-tripped `serializedBody` as `transformedBody`; the round-trip
drops the non-enumerable map, so the restore saw an empty map and the
cloaked `Read` streamed verbatim to the client.

Re-attach the live `_toolNameMap` onto the serialized body before returning
(non-enumerable, mirrors antigravity.ts::attachToolNameMap) so tool-name
casing round-trips correctly.

Regression test exercises base.ts execute() through the claude-OAuth cloak
path and asserts the returned transformedBody carries the reverse map.

Closes #4307
2026-06-19 20:12:03 -03:00
Diego Rodrigues de Sa e Souza
a23d0d678a fix(api): semantic-cache HIT bills incremental cost 0 + X-OmniRoute-Cost-Saved (PRD-2026-06-19) (#4308)
Cache HITs now report Response-Cost 0 (incremental) and surface the avoided cost in X-OmniRoute-Cost-Saved. MISS path unchanged. TDD + per-key isolation guard.
2026-06-19 18:50:08 -03:00
Diego Rodrigues de Sa e Souza
205be2f8f8 fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security) (#4306)
Resolves Dependabot alerts on package-lock.json and electron/package-lock.json:

- undici 7.x -> 7.28.0: TLS certificate validation bypass via dropped requestTls in SOCKS5 ProxyAgent (GHSA-vmh5-mc38-953g, HIGH) + cross-user information disclosure via shared-cache whitespace bypass (GHSA-pr7r-676h-xcf6, MEDIUM). Fixed in the root (jsdom transitive) and electron lockfiles.

- dompurify -> 3.4.11: permanent ALLOWED_ATTR pollution via setConfig() bypassing the hook clone-guard (GHSA-cmwh-pvxp-8882, MEDIUM). Bumped the overrides floor from ^3.4.9 to ^3.4.11.

Also bumps node-gyp's transitive undici 6.26.0 -> 6.27.0, clearing the <6.27.0 advisories (WebSocket DoS, Set-Cookie handling) surfaced by npm audit. Lockfile/override-only change; no production source touched.
2026-06-19 18:28:08 -03:00
Diego Rodrigues de Sa e Souza
bbc9d1e1c5 feat(quality): seed per-module mutationScore floors + blocking aggregation ratchet (T3) (#4305)
First full mutation measurement landed (run 27823984918, the split nightly from #4272):
31 modules now have a COVERED mutation score. T3 turns that into an enforced gate.

Seed: 31 `mutationScore.<path>` floors in quality-baseline.json at ~2pt below the measured
score (absorbs run-to-run variance), direction:up, dedicatedGate:true. dedicatedGate means
the generic check-quality-ratchet SKIPS them (check-quality-ratchet.mjs:62) — they are
enforced only by check-mutation-ratchet.mjs. Range: memorySkillsInjection 13.49 (weakest)
to headers 94.29 (strongest); the security/critical floors: auth 52.57, accountFallback
68.38, routeGuard 76.08, circuitBreaker 56.94, error 43.83, publicCreds 59.76.

Gate: a new `mutation-ratchet` job in nightly-mutation.yml runs AFTER all batches
(needs: stryker, if: always()), downloads every mutation report, and ratchets the MERGED
per-module scores with `check-mutation-ratchet --ratchet` (blocking). It must aggregate
because the split batches each emit a PARTIAL view of a file (auth.ts in a1+a2,
accountFallback in b1+b2) — a per-batch ratchet would compare half a file against the
whole-file floor. check-mutation-ratchet unions same-file mutants across reports (#4272).
A module dropping below its floor fails the run; missing reports (upload flake) are skipped.

Verified: ratchet exits 0 on the seeded measurements, exits 1 on a synthetic regression
(auth 33.33 < 52.57), exits 0 advisory without --ratchet. Baseline change is additive
(31 floors + one comment; existing keys untouched). check-mutation-ratchet tests 8/8.
2026-06-19 18:28:06 -03:00
Diego Rodrigues de Sa e Souza
23455fdb0a feat(cli): setup-gemini — point the Gemini CLI at OmniRoute's native /v1beta endpoint (#4303)
The Gemini CLI is not OpenAI-compatible — it speaks the native Gemini API.
OmniRoute exposes a Gemini-native surface at /v1beta, so setup-gemini emits the
@google/genai env recipe (GOOGLE_GEMINI_BASE_URL root + GEMINI_API_KEY) and
optionally writes ~/.gemini/settings.json (model). Remote-aware (resolves
baseUrl + key from the active context, --remote or --api-key). Documents the
cached-Google-login caveat that can override the base URL.

Completes the per-CLI setup series (Codex, Claude, OpenCode, Cline, Kilo,
Continue, Cursor, Roo, Crush, Goose, Qwen, Aider, Gemini).
2026-06-19 18:14:58 -03:00
Diego Rodrigues de Sa e Souza
0ab1876008 feat(mitm): translate Antigravity cloudcode end-to-end (Gap B) (#4299)
The Antigravity IDE speaks cloudcode (the Gemini payload wrapped under
`request`) and expects a cloudcode reply ({response:{candidates}}). The
AgentBridge proxy forwarded that envelope verbatim to /v1/chat/completions
(OpenAI), which 400s on the missing `messages` field — so the IDE could be
decrypted/intercepted but never actually routed to a provider.

Wire the inbound cloudcode path, reusing the already-registered bidirectional
translators (no new translators needed):

- provider.ts: detectFormatFromEndpoint classifies the /antigravity path as
  sourceFormat "antigravity" (mirrors /messages -> claude), so the pipeline
  translates request antigravity->openai and response openai->antigravity.
- /v1/antigravity route (new): cloudcode-compatible endpoint — just calls
  handleChat (mirrors /v1/messages).
- server.cjs: routes cloudcode envelopes to /v1/antigravity (translates both
  ways) and plain OpenAI bodies to /v1/chat/completions, via a testable shim.

Tests: forward-target shim (cloudcode vs openai routing) + endpoint format
detection. The antigravity<->openai translators are already covered by
translator-antigravity-to-openai / translator-resp-openai-to-antigravity.

Stacked on #4285 (Gap A). Full Antigravity-IDE e2e validates on the next
standalone deploy (provider.ts + the route compile into .next).
2026-06-19 18:01:14 -03:00
Xiangzhe
915991c762 fix(codex): isolate Spark quota scope (#4293)
* fix(codex): isolate Spark quota scope

* fix(codex): address Spark quota review feedback

* fix(ci): update Electron undici override

* fix(ci): update root undici overrides

* test(integration): sync stale expectations

* test(tproxy): tolerate available native addon

* test(tproxy): avoid environment-specific skips

* test(tproxy): keep assertion count stable

* fix(ci): stabilize quality and tproxy checks

* chore(ci): rebaseline auth file size

* fix(ci): extend node compatibility budget

* chore(quality): reconcile complexity + file-size baselines after release/v3.8.30 merge (#4293)

Measured on the actual merged tree (not the PR's main-based estimate):
complexity 1885->1887 (+2); file-size auth.ts 2219->2279, chatCore.ts 5116->5125,
accountFallback.ts 1727->1731, + the 4 Codex test files. Drift test-file conflicts
(search-providers-catalog, tproxy-transparent-socket, integration-wiring) resolved
to the already-merged release versions (#4276).

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: ci <ci@local>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-06-19 17:57:23 -03:00
Diego Rodrigues de Sa e Souza
165d9cdae9 feat(cli): setup-aider — configure Aider for OmniRoute (.aider.conf.yml + env recipe) (#4302)
CLI #12 of the series. `omniroute setup-aider` writes Aider's ~/.aider.conf.yml
(openai-api-base = ROOT url, NO /v1 — LiteLLM appends /v1/chat/completions —
model: openai/<id>), merges to preserve existing config, and prints the env
recipe (OPENAI_API_BASE + OPENAI_API_KEY in the env, never the file) plus the
headless command (aider --message ... --yes). Remote-aware; model via --model or
interactive pick.

Researched against aider.chat: OpenAI-compatible via OPENAI_API_BASE (base, no
/v1) + --model openai/<id>. Aider's wire (/v1/chat/completions) already validated → "OK".

Tests: resolveAiderTarget (/v1 strip, key), buildAiderConfig (openai-api-base +
openai/<model> + preserve), buildAiderRecipe (env-ref key + headless). 4 unit tests; cli-i18n green.
2026-06-19 17:49:47 -03:00
Édrick Renan
b01b72052f fix(dashboard): improve API try it functionality (#4296)
* fix(dashboard): improve api try it functionality and allow manual key entry

* test(api): cover generateExampleFromSchema for the Try It panel (#4296)

Export generateExampleFromSchema from the /api/openapi/spec route and add a
unit test covering type handling, property-name heuristics, $ref/oneOf/anyOf/
allOf resolution, the 'required + first 3 optional' object policy, and the
depth-3 recursion guard — the example bodies the dashboard Try It panel
pre-fills. Rule #18 regression guard for the new helper.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: ci <ci@local>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-06-19 17:48:54 -03:00
PizzaV
b56b7b1914 fix: polyfill crypto.randomUUID for non-secure contexts (#4287)
* fix: polyfill crypto.randomUUID for non-secure contexts

crypto.randomUUID() requires a secure context (HTTPS or localhost).
When accessing the dashboard over HTTP on a LAN IP, the function is
undefined, causing 'Failed to add account' errors on providers that
generate account IDs client-side (e.g. mimocode).

Adds a lightweight polyfill that falls back to a Math.random()-based
UUID v4 generator when the native API is unavailable.

* fix: address review comments on crypto.randomUUID polyfill

- Use crypto.getRandomValues() for cryptographic security instead of Math.random()
- Add typeof window !== 'undefined' guard to avoid ReferenceError in non-browser envs
- Use window.crypto for safe access instead of bare crypto reference
- Replace var with const and == with === for modern JS syntax
- Add fallback to Math.random() when getRandomValues is unavailable
- Add unit tests verifying valid UUID v4 format, version/variant nibbles,
  uniqueness, and preference for getRandomValues over Math.random

* test(dashboard): regression guard for crypto.randomUUID polyfill (#4287)

Reads src/app/layout.tsx and asserts the blocking inline script installs a
guarded window.crypto.randomUUID polyfill (RFC4122 v4 shape, getRandomValues
preferred with a Math.random fallback). Fails on the pre-fix tree (no polyfill),
passes with the fix — Rule #18 regression guard for the non-secure-context
(HTTP/LAN-IP) dashboard breakage.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: pizzav-xyz <pizzav-xyz@users.noreply.github.com>
Co-authored-by: ci <ci@local>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-06-19 17:45:46 -03:00
dependabot[bot]
fcdf29f8c5 chore(deps): bump actions/checkout from 4 to 7 (#4297)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 17:39:59 -03:00
Wilson
133432b523 fix(proxy): allow concurrent proxy dispatcher streams (#4288) 2026-06-19 17:39:57 -03:00
Diego Rodrigues de Sa e Souza
0acb8d0aeb fix(build): co-locate llmlingua SLM optionals into dist/node_modules (postinstall) (#4286)
The compression "ultra" SLM tier (#4257) runs @atjsh/llmlingua-2 + transformers + tfjs
+ js-tiktoken in a worker thread shipped under dist/. These are optionalDependencies
installed into the ROOT node_modules on --include=optional, but the Next.js standalone
trace bundles ONLY @huggingface/transformers (3.5.2, pinned) into dist/node_modules —
not the dynamically-imported optionals.

Result: the worker resolves transformers from dist/node_modules (3.5.2) for its env
config but resolves @atjsh/llmlingua-2 from the ROOT, whose own transformers import
hits a DIFFERENT instance. The cacheDir config never reaches the instance llmlingua-2
uses, so the local model never loads and the SLM tier silently fails-open (and on a
root transformers 4.x, llmlingua-2 throws on the tokenizer API change).

Fix: postinstall co-locates the SLM optional closure from the root node_modules into
dist/node_modules (no-clobber, so the pinned dist transformers/onnxruntime stay), so
the worker resolves a single 3.5.2 instance and the local model loads.

VPS-validated (Rule #18): the co-located layout produced real 54.8% compression
(11520->5203 chars) via real ONNX inference on the production host, both the default
and the #4257 modelPath code paths.

- scripts/build/colocateOptionals.mjs: closure walk (deps+optionalDeps, skips the
  transformers peer) + no-clobber co-location; idempotent + fail-soft
- wired into scripts/build/postinstall.mjs next to ensureSwcHelpers
- registered in package.json files + pack-artifact allow/required lists
- tests/unit/colocate-optionals.test.ts: closure, no-clobber, idempotence, gates
- docs/ops/RELEASE_CHECKLIST.md: note the auto co-location
2026-06-19 17:39:10 -03:00
Diego Rodrigues de Sa e Souza
efbe0a6af1 fix(mitm): surface AgentBridge traffic in the Traffic Inspector (D4 ingest) (#4285)
The standalone server.cjs proxy intercepts AgentBridge requests inline (no
MitmHandlerBase / agentBridgeHook), so intercepted traffic never reached the
TS hook that pushes into globalTrafficBuffer — the Traffic Inspector stayed
empty for AgentBridge even on successful intercepts. Three gaps closed:

- _internal/ingest.cjs (new): pure payload builder + fire-and-forget poster
  (never throws — capture must not break proxy traffic).
- server.cjs: intercept() accumulates response (bounded) + status/headers and
  posts the captured entry to the local-only /internal/ingest endpoint in a
  finally block; also captures error/4xx intercepts.
- manager.ts: resolves the ingest token via getIngestTokenForBootstrap() and
  passes it to the spawned proxy so the endpoint accepts the post.
- authz management policy: exempt the loopback /internal/ingest endpoint from
  management auth — it has its own shared-secret token gate, and server.cjs
  has no dashboard cookie. Stays strictly loopback (LOCAL_ONLY gate unchanged).
- ingest route: masks secrets / strips hop-by-hop headers before buffering
  (server.cjs sends raw over the token-gated loopback) — Hard Rule #12.

Tests: ingest shim (build/post/no-token/error) + route sanitization + 403 +
management-policy carve-out (loopback allow / remote LOCAL_ONLY).
2026-06-19 17:39:08 -03:00
Diego Rodrigues de Sa e Souza
0ec476b755 feat(cli): setup-qwen — configure Qwen Code for OmniRoute (settings.json modelProvider) (#4301)
CLI #11 of the series. `omniroute setup-qwen` writes Qwen Code's file-based
~/.qwen/settings.json: an openai `modelProvider` (id omniroute, authType openai,
baseUrl WITH /v1, envKey OMNIROUTE_API_KEY — secret stays in the env), selects it,
sets the model. Merges (de-dupes the omniroute provider, preserves the rest).
Remote-aware; model via --model or interactive pick; headless test `qwen -p`.

Researched against QwenLM/qwen-code: modelProviders authType openai, baseUrl /v1,
envKey reference. Qwen's wire (/v1/chat/completions) already validated → "OK".

Tests: resolveQwenTarget (/v1, key), buildQwenSettings (openai provider + /v1 +
envKey + model, de-dupe + preserve). 4 unit tests; cli-i18n green.
2026-06-19 17:26:38 -03:00
Diego Rodrigues de Sa e Souza
25f9dac9e9 feat(cli): setup-goose — configure Goose for OmniRoute (config.yaml + env recipe) (#4300)
CLI #10 of the series. `omniroute setup-goose` writes Goose's file-based
~/.config/goose/config.yaml (GOOSE_PROVIDER=openai, GOOSE_MODEL=<model>,
OPENAI_HOST=<root, NO /v1 — Goose appends the path itself>), merges to preserve
existing keys, and prints the guaranteed env-var recipe (the key lives in the env
/ OS keyring, never the config). Remote-aware (--remote/--api-key → context →
localhost); model via --model or interactive pick.

Researched against block/goose: provider openai + OPENAI_HOST base (no /v1).
Goose's wire (/v1/chat/completions) already validated → "OK".

Tests: resolveGooseTarget (/v1 strip, key), buildGooseConfig (provider/model/host
+ preserve), buildGooseEnvRecipe (env-ref key). 4 unit tests; cli-i18n green.
2026-06-19 17:04:44 -03:00
Diego Rodrigues de Sa e Souza
26dd5d775c feat(cli): setup-crush — OmniRoute openai-compat provider in crush.json (#4298)
CLI #9 of the series. `omniroute setup-crush` writes Crush's file-based
~/.config/crush/crush.json with an `openai-compat` provider for OmniRoute:
base_url WITH /v1, api_key referenced as $OMNIROUTE_API_KEY (secret off disk),
curated catalog models with context_window. Merges (preserves existing config).
Remote-aware (--remote/--api-key → active context → localhost); --only filter.

Researched against charmbracelet/crush: openai-compat provider type, base_url
needs /v1, $VAR api_key references. Crush's wire (/v1/chat/completions) already
validated → "OK".

Tests: resolveCrushTarget (/v1, key), buildCrushProvider (openai-compat + env-ref
+ curated models + skip-unknown), mergeCrushConfig (preserve). 4 unit tests; cli-i18n green.
2026-06-19 16:38:48 -03:00
Diego Rodrigues de Sa e Souza
bf5b615969 feat(memory): x-omniroute-no-memory opt-out + memory off-by-default + token-cost alert (PRD-2026-06-19) (#4290)
* feat(memory): x-omniroute-no-memory opt-out + memory off-by-default + token-cost UI alert

PRD-2026-06-19-no-memory-header. The gateway injects up to memorySettings.maxTokens
(~2k) of memory (and skills) context into every chat call for memory-enabled keys,
inflating tokens+cost ~137x for clients that manage their own context (e.g. Omniflow).

Three changes:
- A) x-omniroute-no-memory request header (mirrors x-omniroute-no-cache): when truthy
  (true/1/yes), skip memory+skills injection for that request. New pure helper
  isNoMemoryRequested() in chatCore/headers.ts; chatCore passes memoryOwnerId=null on
  opt-out (a null owner disables both injection branches).
- B) Memory OFF by default: DEFAULT_MEMORY_SETTINGS.enabled true->false. Enabling injects
  billed context per request, so it's now an explicit opt-in. Installs that already
  enabled it keep it; unset installs default off (no migration seeds memoryEnabled).
- C) Settings -> Memory shows a token-cost warning callout when memory is enabled
  (new settings.memoryTokenCostWarning i18n key, interpolating the configured maxTokens).

Tests: no-memory-header.test.ts (5, helper truthiness/case/Headers); memory-settings-default
and chatcore-memory-skills-injection aligned to the new off-by-default. 65/65 memory+chatcore
tests green; typecheck/lint/file-size/i18n(@65) clean.

* test(memory): enable memory in memory-tools test (memory now off by default)

The full CI unit suite flagged memory-tools.test.ts 'memory search ...' failing
after DEFAULT_MEMORY_SETTINGS.enabled flipped to false: omniroute_memory_search
routes through retrieveMemories, which returns [] while memory is disabled
(enabled:false → maxTokens 0). The memory MCP tools operate within the memory
subsystem, so the test now enables memory explicitly (updateSettings + cache
invalidation) — the realistic precondition for a client using the tools.
Aligns the test to the intentional off-by-default change; assertions unchanged.
2026-06-19 16:14:18 -03:00
Diego Rodrigues de Sa e Souza
5b40069b71 feat(cli): setup-roo — configure Roo Code for OmniRoute (import JSON + autoImport + UI) (#4292)
CLI #8 of the series. Roo Code (RooVeterinaryInc.roo-cline, a Cline fork) keeps
live settings in opaque VS Code globalStorage, but supports Settings Import and
an `roo-cline.autoImportSettingsPath` (VS Code settings.json) that loads a JSON
at startup.

`omniroute setup-roo`:
- writes ~/.omniroute/roo-settings.json — a Roo provider profile
  (providerProfiles.apiConfigs.OmniRoute: apiProvider=openai, openAiBaseUrl WITH
  /v1 — Roo appends /chat/completions — openAiApiKey, openAiModelId).
- sets roo-cline.autoImportSettingsPath in VS Code settings.json when present
  (preserves other settings).
- prints the guaranteed UI path (Settings → Providers → OpenAI Compatible) +
  the "Import Settings" fallback.
- remote-aware; model via --model or interactive pick.

Researched against current Roo docs: OpenAI-compatible needs baseUrl WITH /v1 and
native tool-calling (OmniRoute supports it). Roo's wire (/v1/chat/completions)
already validated → "OK".

Tests: resolveRooTarget (/v1, key), buildRooImport (provider profile + /v1 + key
fallback), buildRooVscodeAutoImport (pointer + preserve). 5 unit tests; cli-i18n green.
2026-06-19 16:13:26 -03:00
Diego Rodrigues de Sa e Souza
9616b65b53 feat(cli): setup-cursor — print Cursor setup steps for OmniRoute (#4291)
CLI #7 of the series. Cursor stores its OpenAI key + "Override OpenAI Base URL"
in an opaque SQLite DB (state.vscdb) with no stable schema — not safe to
file-write. So `omniroute setup-cursor` prints the exact in-app steps and lists
real model names from /v1/models.

- Resolves apiBase WITH /v1 (Cursor appends /chat/completions) + key from
  --remote/--api-key → active context → localhost.
- Prints Settings → Models → Override OpenAI Base URL + key + model-name steps,
  with a clear caveat that the custom base URL powers Cursor's CHAT panel only
  (Composer / inline-edit / autocomplete stay on Cursor's backend).

Researched against current Cursor behavior. Tests: resolveCursorTarget (/v1, key),
buildCursorInstructions (base URL + /v1 note + model samples + caveat). 4 unit
tests; check:cli-i18n green.
2026-06-19 13:21:55 -03:00
Diego Rodrigues de Sa e Souza
138ea6628d feat(cli): setup-continue — generate ~/.continue/config.yaml for OmniRoute (#4289)
CLI #6 of the series. `omniroute setup-continue` writes Continue's file-based,
mergeable ~/.continue/config.yaml (shared by the VS Code/JetBrains extensions AND
the `cn` CLI) from the live model catalog.

- Each curated model → a Continue model entry: provider: openai, model: <id>,
  apiBase WITH /v1 (Continue appends /chat/completions), apiKey:
  ${{ secrets.OMNIROUTE_API_KEY }} (secret referenced, never written), roles
  [chat, edit, apply] (+ autocomplete for the fast tier).
- Merges into existing config.yaml (js-yaml load/dump): drops prior models on the
  same apiBase, preserves the user's other models + top-level keys.
- Remote-aware (--remote/--api-key → active context → localhost); --only filter.
- Prints how to provide the key (shell env for cn; ~/.continue/.env for IDE).

Researched against current Continue docs: provider: openai + custom apiBase (with
/v1), the ${{ secrets.X }} syntax, roles, and that the `cn` CLI shares the same
config. Continue's wire (/v1/chat/completions) already validated → "OK".

Tests: buildContinueModels (provider/apiBase/secret/roles, fast→autocomplete,
skip uncategorised), mergeContinueConfig (replace-ours/keep-others/defaults),
resolveContinueTarget (/v1). 6 unit tests; check:cli-i18n green.
2026-06-19 13:19:12 -03:00
Diego Rodrigues de Sa e Souza
70bd6fbcc9 feat(cli): setup-kilo — configure Kilo Code for OmniRoute (CLI auth + VS Code settings) (#4284)
CLI #5 of the series. `omniroute setup-kilo` configures Kilo Code
(kilocode.kilo-code, a Cline/Roo descendant) to use OmniRoute.

Two surfaces (both written, matching the dashboard cli-tools/kilo-settings):
- ~/.local/share/kilo/auth.json — CLI mode: auth["openai-compatible"] =
  { apiKey, baseUrl (WITH /v1 — Kilo appends /chat/completions), model }.
- VS Code settings.json — extension: kilocode.customProvider (name/baseURL/apiKey)
  + kilocode.defaultModel. Only touched when the file already exists.

Remote-aware (--remote/--api-key → active context → localhost). Model via --model
or an interactive pick from /v1/models (Kilo's extension has no auto-discovery).
Prints the exact UI settings to paste. Merges both files (preserves existing).

Researched against current Kilo docs: confirmed openAiBaseUrl needs /v1 (unlike
Cline's root url), the openai-compatible keys, and the export/import + CLI surfaces.
Kilo's wire (/v1/chat/completions) already validated → "OK".

Tests: buildKiloAuth (provider + /v1 + merge + key fallback), buildKiloVscodeSettings
(kilocode.* keys + preserve), resolveKiloTarget (/v1 ensure, key win). 6 unit tests;
check:cli-i18n green.
2026-06-19 12:57:22 -03:00
Diego Rodrigues de Sa e Souza
6f16faa039 fix(models): keep vision capability for imported (synced) models (#4264) (#4283)
After importing a provider key, vision-capable models (OpenRouter models whose
architecture declares image input, and other synced providers) were shown as
text-only in /v1/models and the dashboard, even though image requests worked.

Root cause: SyncedAvailableModel never captured a vision flag, and the catalog's
OpenRouter live-enrichment block (which derives vision from architecture.input_modalities)
is skipped once a provider has synced models. So the synced path emitted no vision.

Fix (mirrors the existing supportsThinking capture):
- modelDiscovery.normalizeDiscoveredModels derives supportsVision via the new
  detectVisionInput() from architecture.input_modalities, the string
  architecture.modality ("text+image->text"), or a top-level input_modalities.
- SyncedAvailableModel gains supportsVision; the read-normalize path preserves it.
- catalog.ts emits capabilities.vision for synced models and merges (not clobbers)
  capabilities when the model already exists.

TDD: tests/unit/openrouter-vision-sync-4264.test.ts — capture unit test + an
end-to-end /v1/models assertion (RED before, GREEN after).

Closes #4264
2026-06-19 12:53:06 -03:00
Diego Rodrigues de Sa e Souza
158a2246dd feat(cli): setup-cline — configure Cline for OmniRoute (CLI files + VS Code hints) (#4280)
CLI #4 of the series. Cline's VS Code extension keeps config in opaque VS Code
globalStorage (not file-writable); its CLI/standalone mode reads ~/.cline/data/.

`omniroute setup-cline`:
- writes ~/.cline/data/globalState.json (act/planModeApiProvider=openai,
  openAiBaseUrl = ROOT url WITHOUT /v1 — Cline appends /v1/chat/completions —
  openAiModelId + planModeOpenAiModelId) and ~/.cline/data/secrets.json
  (openAiApiKey), both merged to preserve existing state. Matches the dashboard
  cli-tools/cline-settings schema.
- remote-aware (--remote/--api-key → active context → localhost).
- model resolved via --model or an interactive pick from /v1/models (Cline has
  no model auto-discovery).
- prints the exact VS Code extension settings (Base URL/key/model) to paste,
  since the extension's storage can't be written directly.

Researched against the current Cline docs (saoudrizwan.claude-dev): confirmed
the openai-compatible keys, the Plan/Act split, and that openAiBaseUrl must be
the ROOT (no /v1). Cline's wire (/v1/chat/completions) already validated → "OK".

Tests: buildClineGlobalState (provider+root+model, merge-preserve),
buildClineSecrets (key + placeholder), resolveClineTarget (/v1 strip, key win).
6 unit tests; check:cli-i18n green.
2026-06-19 12:30:51 -03:00
Diego Rodrigues de Sa e Souza
550440f65f fix(providers): Cloudflare Workers AI discovery uses model names, not UUIDs (#4259) (#4282)
Cloudflare's /ai/models/search returns { id: "<uuid>", name: "@cf/..." } where
name is the callable slug and id is an internal UUID. The cloudflare-ai discovery
config passed the raw objects through (parseResponse: data.result), so buildResponse
used id (the UUID) as the model id — the dashboard/import listed UUIDs instead of
@cf/... model names. Map each result's name -> id (mirrors the gemini/huggingface/
clarifai parseResponse normalizers in the same map); falls through to the local
catalog on error so import never breaks.

TDD: tests/unit/cloudflare-models-uuid-4259.test.ts (RED on UUID ids -> GREEN on slugs).

Closes #4259
2026-06-19 12:25:35 -03:00
Diego Rodrigues de Sa e Souza
98b0d5e51e fix(sse): surface undici err.cause on dispatcher failure (#4281)
Surface err.cause + propagate diagnosable error fast on undici dispatcher failure. TDD. #4252.
2026-06-19 12:10:43 -03:00
Diego Rodrigues de Sa e Souza
5c4b0e327d fix(cli): harden launch/launch-codex with free-claude-code patterns (#4278)
Applies proven patterns from the free-claude-code reference adapters:

launch (Claude Code):
- always set ANTHROPIC_AUTH_TOKEN — a no-auth sentinel when none is resolved —
  so newer Claude Code doesn't stop at its local login gate before contacting
  OmniRoute (an open backend ignores the value; ANTHROPIC_API_KEY stays stripped).

launch-codex:
- remote-aware: resolves the root base URL + auth from --remote/--api-key, the
  active context, then localhost (was localhost/--remote only).
- inject the `omniroute` provider via `-c` flags (model_provider + base_url +
  env_key + wire_api=responses + requires_openai_auth=false) so it works WITHOUT
  a pre-existing ~/.codex/config.toml.
- strip OPENAI_*/CODEX_* from the child env (defense-in-depth) and set
  OMNIROUTE_API_KEY to the token or a sentinel. (Honest note: this does NOT
  silence codex's refresh_token log noise — that comes from ~/.codex/auth.json,
  is cosmetic, and does not block requests.)
- replace a hard-coded Tailscale IP in --remote help with a placeholder.

Tests: buildCodexEnv (strip + sentinel + no-mutate), buildCodexProviderArgs
(inline provider def), resolveCodexTarget; updated buildClaudeEnv sentinel test.
Validated remotely vs VPS v3.8.30: `launch-codex --remote ... exec` → "OK".
19 unit tests pass; check:cli-i18n green.
2026-06-19 11:40:53 -03:00
Diego Rodrigues de Sa e Souza
871d109066 feat(quality): cap test-file size (anti-reinflation Layer 1) — freeze god-tests, cap new at 800 (#4273)
Layer 1 anti-reinflation: cap test-file size (freeze god-tests, cap new at 800). Gate validated green against the full combined tree.
2026-06-19 11:09:14 -03:00
Diego Rodrigues de Sa e Souza
1696944dce feat(cli): OpenCode setup commands (openai-compatible provider + remote-aware plugin) (#4277)
setup-opencode: remote-aware openai-compatible provider generator; API key referenced by env var (never on disk). 6 tests.
2026-06-19 11:09:12 -03:00
Diego Rodrigues de Sa e Souza
b7414c21c5 ci(mutation): split over-budget batches by range/pair so every batch fits the job cap (#4272)
Split over-budget mutation batches by range/pair + union same-file mutants across sibling batches. CI-only + TDD.
2026-06-19 11:09:09 -03:00
Diego Rodrigues de Sa e Souza
ec4d94f4c1 test(ci): reconcile release/v3.8.30 baseline + test drift (#4276)
Reconcile baseline + test drift on release/v3.8.30 (complexity, opaque surface, search count, tproxy addon). Round-robin left as a canary for the undici-dispatcher issue.
2026-06-19 10:48:59 -03:00
Diego Rodrigues de Sa e Souza
3e6be47012 feat(cli): Claude Code launcher + setup commands (remote mode + profiles) (#4274)
Brings Claude Code to parity with the Codex CLI integration.

- `omniroute launch` is now remote-aware: --remote <url>, --profile <name>,
  --api-key. Resolves base URL + auth from the active context (so
  `omniroute connect <vps>` then `omniroute launch` just works), accepts a bare
  port OR a full base URL, health-checks the (possibly remote) server, and sets
  CLAUDE_CONFIG_DIR for the chosen profile.
- `omniroute setup-claude` (new): fetches the live /v1/models catalog and writes
  ~/.claude/profiles/<name>/settings.json per model. Claude Code has no native
  profile files, so CLAUDE_CONFIG_DIR is the idiomatic mechanism. Reuses the SAME
  profile names as setup-codex (glm52, kimi-k27, …) via the shared categoriseModel
  (now exported). The auth token is NEVER written to disk — launch injects it.
- Docs: docs/guides/CLAUDE-CODE-CONFIGURATION.md + README index. i18n (en + pt-BR).
- Tests: setup-claude profile generation (incl. "no token on disk"), buildClaudeEnv
  (port + URL + CLAUDE_CONFIG_DIR), resolveLaunchTarget.

check:cli-i18n + check-docs-sync green; 13 unit tests pass.
2026-06-19 10:27:24 -03:00
diegosouzapw
3517d935e4 docs(design): add OmniRoute design system and visual identity specification
Introduce a design system and visual identity plan to unify the OmniRoute
dashboard and marketing site. The document covers color alignment,
missing design tokens, and the implementation strategy for a consistent
brand experience.
2026-06-19 10:20:21 -03:00
Diego Rodrigues de Sa e Souza
8aa9d6330b chore(ci): align electron audit gate to root advisory policy (#4275)
Align electron audit gate to root policy (critical blocks, high warns). Un-blocks the Lint job on release/v3.8.30.
2026-06-19 10:17:00 -03:00
Diego Rodrigues de Sa e Souza
c34d37a11e feat(cli): Codex CLI launcher + setup commands (#4270)
* feat(cli): Codex CLI launcher + setup commands

Two new CLI subcommands mirroring the `launch`/`configure` pattern, for driving the
OpenAI Codex CLI against OmniRoute:

- `omniroute launch-codex` — boots OmniRoute (if needed) and launches Codex CLI
  pointed at it (local or remote VPS), with no manual env/config editing.
- `omniroute setup-codex` — generates ~/.codex profile files from OmniRoute's live
  model catalog.

Registered in the command registry; en.json + pt-BR.json locale sections added
(keeps the cli-i18n-catalog top-level parity gate green). CODEX-CLI-CONFIGURATION.md
refreshed. Consolidated from work-in-progress that was uncommitted on the shared
checkout; reconstructed on release/v3.8.30.

* chore(vscode): reduce git repo-detection overhead for nested worktrees

Disable VS Code's git auto-repository-detection / submodule scan / autofetch so the
Source Control view stops indexing the ~44 nested repos (worktrees + _references/*
+ _mono_repo/*), which caused constant "validating" churn. Only the root repo is
tracked. Editor-only settings; no runtime impact.
2026-06-19 09:36:41 -03:00
diegosouzapw
a5c0576c3b chore(release): open v3.8.30 development cycle 2026-06-19 07:14:16 -03:00
11439 changed files with 290886 additions and 2273501 deletions

View File

@@ -1,201 +0,0 @@
# codebase-memory-mcp ignore list
#
# Padrão gitignore-style. Linhas começando com `#` são comentários.
# Barra final (`/`) = só diretório. Sem barra = casa arquivo OU diretório.
#
# O CBM também lê `.gitignore` automaticamente — esta lista deixa explícito o que
# os hooks do CBM vão pular. Se uma regra entrar em conflito entre os dois arquivos,
# vale a união. Editar este arquivo é mais barato do que confiar na herança implícita.
#
# Última reconciliação: 2026-07-31, status `ready` (513k nodes / 689k edges),
# `auto_index_limit=50000`, total indexável medido ≈11.546 arquivos (folga 4,3×).
#
# Fontes cruzadas:
# - `codebase-memory-mcp cli index_status --project home-diegosouzapw-dev-proxys-OmniRoute`
# → `not_indexed.dirs` (27) + `not_indexed.files` (336), todos `BY DESIGN`.
# - `.gitignore` deste repo (5.691 B) — fonte canônica secundária.
#
# Como auditar mudanças: depois de editar este arquivo, rodar `index_repository`
# (ou esperar `auto_watch` re-indexar) e re-checar `cli index_status` → comparar
# contagens em `not_indexed.dirs_count` e `not_indexed.files_count`.
# ─────────────────────────────────────────────────────────────────────────────
# 1. Diretorios de runtime / pacote — nao sao codigo-fonte
# ─────────────────────────────────────────────────────────────────────────────
node_modules/
node_modules
# Builds e artefatos reproduziveis (Layer 1 Next.js / Electron)
.build/
dist/
.next/
out/
# Electron especifico
electron/dist-electron/
electron/node_modules/
icon.iconset/
# Workspaces internos que tem proprio node_modules
@omniroute/opencode-plugin/dist/
@omniroute/opencode-plugin/node_modules/
@omniroute/opencode-provider/dist/
@omniroute/opencode-provider/node_modules/
# Recursos nativos compilados (C/JNI/wasm)
src/mitm/tproxy/native/build/
# Artefatos locais do Stryker / Playwright / coverage
.stryker-tmp/
reports/mutation/
stryker-output-*.json
.playwright-mcp/
test-results/
playwright-report/
blob-report/
# Analise / linters / caches
.analysis/
.sisyphus/
.plans/
.gitnexus
.worktrees
.codegraph/
# Quality artifacts (gerados por npm run lint --cache etc)
.eslintcache
.eslintcache-complexity
# Claude Code local state
.claude/scheduled_tasks.lock
.claude/scheduled_tasks/
.claude/sessions/
.claude/state.json
.claude/settings.local.json
# Serena / Antigravity / outras tools locais
.serena/
.antigravitycli/
.gemini/
.config/
# ─────────────────────────────────────────────────────────────────────────────
# 2. Diretorios com prefixo `_` — locais / privados (regra global do .gitignore)
# ─────────────────────────────────────────────────────────────────────────────
_*/
_artifacts/
_cache/
_mono_repo/
_references/
_tasks/
# ─────────────────────────────────────────────────────────────────────────────
# 3. Diretorios de tooling IA (state local, nao codigo)
# ─────────────────────────────────────────────────────────────────────────────
.agents/
.claude/
.vscode/
.idea/
.junie/
.omc/
.data/
.data-dev/
.local-data/
.logs/
.artifacts/
.source/
.superpowers/
.claude-flow/
.omnivscodeagent/
omnirouteCloud/
omnirouteSite/
.omniroute/
.stent/
# Subpaths especificos do Claude Code que nao estao em .claude/ (criados sob repo)
.claude/worktrees/
# ─────────────────────────────────────────────────────────────────────────────
# 4. Diretorios de dados / runtime locais (storage, env, secrets, scratch)
# ─────────────────────────────────────────────────────────────────────────────
data/
# NOTA: src/lib/env/, src/app/api/{cloud,sync/cloud,system/env,agent-skills/coverage}/
# foram removidos daqui (2026-08-05). Os nomes sugerem dados/segredos locais, mas os
# 8 arquivos sao route handlers e modulos rastreados no git — escondia-los do grafo
# criava pontos cegos em buscas e em analise de impacto.
tests/golden-set/data/
# Logs e saida de teste
logs/*
test_output.log
home-diegosouzapw-dev-automacoes-*.txt
# ─────────────────────────────────────────────────────────────────────────────
# 5. Diretorios do monorepo por subprojeto (nao fazem parte do app principal)
# ─────────────────────────────────────────────────────────────────────────────
security-analysis/
vscode-extension/
obsidian-plugin/node_modules/
# ─────────────────────────────────────────────────────────────────────────────
# 6. Diretorios de documentacao interna / workflow
# ─────────────────────────────────────────────────────────────────────────────
docs/superpowers/
# Docs traduzidas: 1.215 arquivos / 94 MB (inclui 20+ copias do CHANGELOG).
# Sao traducoes do tree em ingles, ja indexado — no grafo so geram ruido em
# search_code e consomem o auto_index_limit.
docs/i18n/
# ─────────────────────────────────────────────────────────────────────────────
# 7. Arquivos especificos (nao diretorios inteiros)
# ─────────────────────────────────────────────────────────────────────────────
# Segredos e env — NUNCA indexar
.env
.env.*
!.env.example
!.env.homolog.example
# TypeScript build info e next env declaration
*.tsbuildinfo
next-env.d.ts
typescript
# SQLite transient files (WAL/SHM/journal)
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
# Mapas e source maps
*.map
# Bun / npm lockfiles ruidosos
bun.lock
# `cheaper-inference-gateway.svg` e arquivos de midia na raiz/asset ja cobertos
# pelos `ignored-suffix` do indexador (svg/png/jpg/ico/etc >50kB ou >500linhas);
# manter a regra explicita aqui ajuda a auditar:
cheaper-inference-gateway.svg
cheaper-inference-gateway-*.svg
# Husky internals
.husky/_/
# CI / quality metric artifacts
config/quality/quality-metrics.json
config/quality/test-impact-map.json
audit-report.json
.gh-discussions.json
# i18n audit (gerado por npm run scripts)
scripts/i18n/_audit.json
scripts/i18n/_pending-keys.json
# NOTA: bin/omniroute.mjs foi removido daqui (2026-08-05). Estava marcado como
# "scratch", mas e o entrypoint real do CLI publicado (package.json -> bin.omniroute)
# e consta em PACK_ARTIFACT_REQUIRED_PATHS. Precisa estar no grafo.
# Deploy / docker backups
deploy.sh
docker-compose.yml.bak
docker-compose.minimal.yml

View File

@@ -7,13 +7,7 @@
**/.vscode
# Dependencies and build output
# `node_modules` alone matches the ROOT only — Docker's matcher does not cross
# `/` like .gitignore does. Without the `**/` form, nested installs ship in the
# build context (e.g. @omniroute/opencode-provider/node_modules, ~79 MB of
# devDependencies). Both forms are kept: the bare one is the documented root
# rule, the `**/` one covers every nested package.
node_modules
**/node_modules
.next
.build
out
@@ -24,7 +18,6 @@ coverage
# Runtime data and logs
data
logs
.sandbox
# Local env files (inject at runtime via --env-file or -e)
.env
@@ -44,19 +37,6 @@ tests
test-results
playwright-report
blob-report
output
.playwright-cli
.playwright-mcp
.stryker-tmp
reports/mutation
# Local caches and quality-gate artifacts (all gitignored). `_*` does not match
# dot-prefixed names, so these need explicit entries.
.artifacts
.eslintcache*
.fakebin-*
MAX
quality-ratchet/
# Documentation
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
@@ -69,10 +49,6 @@ quality-ratchet/
# (English) sources at runtime, so translations are not required in the
# container image.
docs/i18n/**
# Internal planning artifacts (gitignored). `*.md` above only matches the root,
# so without this rule these land in /app/docs and become readable through the
# dashboard's Docs viewer at runtime.
docs/superpowers/**
docs/diagrams/**/*.png
docs/diagrams/**/*.jpg
docs/diagrams/**/*.jpeg
@@ -101,8 +77,6 @@ bun.lock
# Agent config
.agents
.gemini
.claude
.source
# Misc
llm.txt
@@ -149,4 +123,3 @@ app.__qa_backup/
.worktrees
.next-playwright/
cloud/
electron/dist-electron

View File

@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.sh]
indent_size = 4

View File

@@ -1,6 +0,0 @@
ENABLE_LIVE_DEVIN_TESTS=0
DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real)
HOMOLOG_BASE_URL=http://192.168.0.15:20128
# Senha de management do dashboard da VPS (a mesma do /login)
HOMOLOG_ADMIN_PASSWORD=
# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim.
# Só preencha para depurar uma camada isolada com uma key fixa.
HOMOLOG_API_KEY=
# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo.
HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter

11
.gitattributes vendored
View File

@@ -1,11 +0,0 @@
# Shell scripts must always be checked out with LF line endings.
#
# On Windows, core.autocrlf=true converts text files to CRLF in the working
# tree. Scripts that are kernel-exec'd (Docker ENTRYPOINT, bin/*.sh on Linux
# hosts) then fail with `exec ...: no such file or directory` because the
# shebang becomes "#!/bin/sh\r". eol=lf overrides autocrlf for these files.
*.sh text eol=lf
# This file must stay LF too: git parses it as-is, and a trailing CR would
# corrupt every pattern (e.g. "*.sh\r" matches nothing).
.gitattributes text eol=lf

View File

@@ -1,71 +0,0 @@
name: npm ci with retry
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:
- 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
max_attempts=3
delay_seconds=20
for attempt in $(seq 1 "$max_attempts"); do
if [ "$attempt" -gt 1 ]; then
echo "npm ci attempt $attempt/$max_attempts after transient failure"
fi
# --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
exit_code=$?
if [ "$attempt" -eq "$max_attempts" ]; then
exit "$exit_code"
fi
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"

View File

@@ -24,56 +24,20 @@ updates:
update-types: ["version-update:semver-major"]
- dependency-name: "eslint-config-next"
update-types: ["version-update:semver-major"]
# typescript majors are peer-blocked by typescript-eslint, which pins a hard
# upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7
# bump therefore violates the peer and takes down the whole toolchain at once —
# #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet
# + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking
# the innocuous updates riding along with it. Un-ignore once typescript-eslint
# widens the peer, and migrate TS majors intentionally (own PR, own CI run).
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
# parses jscpd-report.json against a frozen baseline). A v5 major would break
# the duplication gate — migrate the gate intentionally, not via dependabot.
- dependency-name: "jscpd"
update-types: ["version-update:semver-major"]
# ioredis is a SOFT/optional dependency loaded through a dynamic import
# (src/lib/quota/redisQuotaStore.ts — "Redis driver requires ioredis package"),
# so a breaking major never fails at build or typecheck time: the only consumers
# are the distributed quota store (redisQuotaStore.ts, storeFactory.ts) and the
# `import type Redis` in src/shared/utils/rateLimiter.ts. Nothing in the unit or
# vitest suites exercises a live Redis connection, so a v5→v6 API break would ship
# green and only surface at runtime for operators running distributed quota — the
# exact users least able to absorb it. #9310 grouped that major with 9 harmless
# bumps; majors here need their own PR and a deliberate migration review.
- dependency-name: "ioredis"
update-types: ["version-update:semver-major"]
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
# Migrate it intentionally, not via dependabot (#4050).
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
# every version). Migrate it intentionally, not via dependabot (#4050).
- dependency-name: "@huggingface/transformers"
# 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: "/"

View File

@@ -9,16 +9,11 @@
## Validation
Choose the change type and focused loop from the
[Contribution Golden Path](../docs/ops/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
- [ ] Focused tests and category gates from the golden path
- [ ] `npm run lint`
- [ ] Reconciled with the current active release base; focused checks rerun afterward
- [ ] Production-code changes include a new or updated automated test in this PR
- SonarQube is temporarily opt-in while the private project has no quota; it is not a PR gate.
- [ ] `npm run test:unit`
- [ ] `npm run test:coverage`
- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
## Tests Added Or Updated
@@ -32,4 +27,4 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
## Reviewer Notes
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.

View File

@@ -1,36 +0,0 @@
name: API Route Typecheck
on:
pull_request:
branches:
- main
- "release/**"
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
api-typecheck:
name: API Route Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Reject new API-route TypeScript diagnostics
run: node scripts/check/check-api-typecheck.mjs
- name: API typecheck gate unit tests
run: node --import tsx/esm --test tests/unit/build/check-api-typecheck.test.ts

71
.github/workflows/build-fork.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Publish Fork Image to GHCR
on:
push:
branches: [main]
tags:
- "v*"
workflow_dispatch:
# Least-privilege default: read-only at the top level; the build job that pushes to
# GHCR grants packages: write itself (Scorecard TokenPermissions).
permissions:
contents: read
env:
IMAGE_NAME: ghcr.io/kang-heewon/omniroute
jobs:
build:
name: Build and Push Fork Image
if: github.repository == 'kang-heewon/OmniRoute'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-
type=ref,event=tag
labels: |
org.opencontainers.image.title=omniroute
org.opencontainers.image.description=Unified AI proxy/router — fork image
org.opencontainers.image.url=https://github.com/kang-heewon/OmniRoute
org.opencontainers.image.source=https://github.com/kang-heewon/OmniRoute
org.opencontainers.image.licenses=MIT
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
target: runner-base
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,64 +0,0 @@
name: Build App
# Manual-only since #11946. The hosted 7 GB runner can no longer build this tree — 19 of
# the last 30 runs died with "The runner has received a shutdown signal" (VM out of
# memory) ~8 min into `next build`, release/v3.8.51 itself included, even with the 10 GB
# swapfile below. Triggered on `push: branches: ["**"]` it painted every branch and every
# PR red while producing an artefact nothing downloads. The bundle is validated where a
# build actually fits:
# - main: ci.yml `Build` (self-hosted omni-build pool) on every merge
# - release/**: nightly-release-green.yml (same pool, continuous)
# Dispatch this workflow by hand when a hosted build artefact is genuinely needed.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Fast Production Build
runs-on: ubuntu-latest
steps:
- name: Expand Virtual Memory (Native 10GB Swap)
run: |
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
free -h
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Next.js app & CLI bundle
run: |
npm run build:release
env:
NODE_OPTIONS: "--max-old-space-size=12288"
OMNIROUTE_BUILD_MEMORY_MB: "12288"
OMNIROUTE_USE_TURBOPACK: "1"
- name: Archive build outputs
run: |
tar -czf omniroute-build.tar.gz .build dist
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: omniroute-build
path: omniroute-build.tar.gz
retention-days: 7

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +19,13 @@ jobs:
actions: read
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:javascript-typescript"

View File

@@ -1,60 +1,34 @@
name: DAST smoke (PR)
# PRs into main only since #11946. The job's "Build CLI bundle" step is a backend-only
# `next build`; on the hosted 7 GB runner it fits main's tree (~5.5 min) but dies on
# release/v3.8.51 (VM shutdown ~7 min in, before the server even starts), and because the
# job is continue-on-error the result was a permanently red advisory check on every
# release PR — noise, not signal. DAST coverage for release/** lives on the nightly rail
# (nightly-schemathesis.yml, nightly-llm-security.yml); dispatch this workflow by hand
# to smoke a release branch on demand.
on:
workflow_dispatch:
pull_request:
branches: ["main"]
# Runner-cost guard (#8084): the CLI-bundle build alone is 6-11min; a docs-only PR
# cannot change DAST behavior, so skip the whole workflow for pure docs/markdown
# changes. Any code path in the diff still runs the full smoke.
paths-ignore:
- "docs/**"
- "**/*.md"
branches: ["main", "release/**"]
permissions:
contents: read
# Superseded runs on the same PR must not stack 25-minute advisory builds
# (force-push storms were holding 2-3 runners each). Same group rule as quality.yml.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
dast-smoke:
runs-on: ubuntu-latest
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
continue-on-error: true
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive
# timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis
# mid-run) — 25min leaves real headroom for the actual DAST steps.
timeout-minutes: 25
timeout-minutes: 12
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- name: Build CLI bundle
env:
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute
env:
PORT: "20128"
INJECTION_GUARD_MODE: block
REQUIRE_API_KEY: "false"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
@@ -62,31 +36,21 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- run: pip install schemathesis
- name: Schemathesis smoke (high-risk endpoints, blocking)
run: |
# /api/auth/oidc/* is a BROWSER redirect flow (302 to the IdP, 302 back to
# /login?oidc_error=... on every failure), not a REST endpoint: Schemathesis reads
# those 302s as "the API accepted a schema-violating request" and the configured-off
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
# still right (operators need it); fuzzing it is not what this smoke is for.
# /api/auth/login has brute-force rate limiting: repeated failed logins return 429,
# which Schemathesis flags as rejection of schema-compliant requests.
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
schemathesis run docs/reference/openapi.yaml --url http://localhost:20128 \
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
--exclude-path-regex '^/api/auth/(oidc/|login)' \
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
--request-timeout 20 --suppress-health-check all --no-color
- name: Install promptfoo
run: npm install -g promptfoo@0.122.0
- name: promptfoo injection-guard (blocking)
env:
OMNIROUTE_URL: http://localhost:20128
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
run: promptfoo eval -c promptfooconfig.yaml --no-cache
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- "release/v*"
tags:
- "v*"
paths-ignore:
@@ -26,14 +25,6 @@ on:
type: boolean
default: false
# One publish per ref. A merge storm used to fan out 8 concurrent hosted builds,
# every one OOM-killing `npm run build` inside BuildKit (#11976). The :next
# channel only needs the newest SHA; cancel-in-progress is the same pattern as
# quality.yml / nightly-release-green.
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
# Least-privilege default: read-only at the top level; the build and merge jobs that
# push to GHCR grant packages: write themselves (Scorecard TokenPermissions).
permissions:
@@ -66,30 +57,39 @@ jobs:
REF_TYPE: ${{ github.ref_type }}
INPUT_VERSION: ${{ inputs.version }}
PROMOTE_INPUT: ${{ inputs.promote_latest }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
# 1) Resolve version/channel from the trigger. Only the current default
# release branch publishes the mutable `next` channel; main keeps `main`.
VERSION=$(bash scripts/ci/resolve-docker-publish-version.sh \
"$EVENT_NAME" "$REF_TYPE" "$REF_NAME" "$INPUT_VERSION" "$DEFAULT_BRANCH")
# 1) Resolve version string from the trigger (all inputs come via env).
case "$EVENT_NAME" in
workflow_dispatch)
VERSION="${INPUT_VERSION#v}"
;;
push)
if [ "$REF_TYPE" = "tag" ]; then
VERSION="${REF_NAME#v}"
else
# Push to main → build & tag as `main` only. Never touch :latest.
VERSION="main"
fi
;;
release)
VERSION="${REF_NAME#v}"
;;
*)
VERSION="${REF_NAME#v}"
;;
esac
# Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth).
if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then
echo "Refusing to use unsafe VERSION value: $VERSION" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Frozen release branches keep receiving coordination commits after the
# next cycle becomes the default branch. They must not overwrite :next,
# but that expected no-op is not a workflow failure.
if [ "$VERSION" = "skip" ]; then
echo "promote_latest=false" >> "$GITHUB_OUTPUT"
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping Docker publish from non-default release branch: $REF_NAME"
exit 0
fi
# 2) Decide whether to promote :latest. Floating channels are never
# eligible, and the helper independently fails closed for non-semver.
# 2) Decide whether to promote :latest.
PROMOTE="false"
if [ "$VERSION" = "main" ] || [ "$VERSION" = "next" ]; then
if [ "$VERSION" = "main" ]; then
PROMOTE="false"
elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
echo "Pre-release identifier detected — skipping :latest."
@@ -98,21 +98,19 @@ jobs:
PROMOTE="${PROMOTE_INPUT:-false}"
else
git fetch --tags --quiet || true
# Decide via the extracted helper, which folds VERSION into the
# candidate set so the result is independent of git-tag sync timing
# on `release` events (#5301). Without that, the freshly-created tag
# is often not yet visible here and :latest stays a release behind.
PROMOTE=$(git tag -l 'v[0-9]*' | bash scripts/ci/should-promote-latest.sh "$VERSION")
if [ "$PROMOTE" != "true" ]; then
echo "Version $VERSION is not the highest stable semver. Not promoting :latest."
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
PROMOTE="true"
else
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Not promoting :latest."
fi
fi
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
# 3) Skip immutable version tags that already exist. Floating `main`
# and `next` channels are intentionally rebuilt on every matching push.
# 3) Skip if this exact version is already published in Docker Hub.
# `main` is always rebuilt (mutable floating tag).
SKIP="false"
if [ "$VERSION" != "main" ] && [ "$VERSION" != "next" ]; then
if [ "$VERSION" != "main" ]; then
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
SKIP="true"
@@ -126,23 +124,7 @@ jobs:
name: Build Docker (${{ matrix.platform }})
needs: prepare
if: needs.prepare.outputs.skip != 'true'
# amd64: the .113 omni-build pool (31 GB / 32 cores, ONE listener since
# #12048). Hosted ubuntu-24.04 is ~7 GB and dies ResourceExhausted (#11976).
# Falls back to hosted when USE_VPS_RUNNER is off. arm64: no ARM box — stay
# on GitHub's ubuntu-24.04-arm.
# Webpack on BOTH arches: Turbopack on omniroute-113-6 hit
# TurbopackInternalError "there must be a path to a root" after 26 min
# (run 33253576569). The same tree's arm64 webpack build on hosted ARM
# succeeded (run 33264823398). Dockerfile already documents webpack as the
# Docker escape hatch (OMNIROUTE_USE_TURBOPACK=0).
runs-on: ${{ matrix.arch == 'amd64' && (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-24.04') || 'ubuntu-24.04-arm' }}
# Share the 1-slot omni-build ceiling (#12048) with ci.yml `Build` /
# npm-publish. Same group as main's Build so a :next publish waits beside
# the artefact instead of sitting next to it. arm64 is hosted — its own
# group, cancelled by the workflow-level concurrency.
concurrency:
group: ${{ matrix.arch == 'amd64' && 'heavy-build-main' || format('docker-publish-arm-{0}', github.ref) }}
cancel-in-progress: ${{ matrix.arch != 'amd64' }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
@@ -151,8 +133,10 @@ jobs:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
env:
IMAGE_NAME: diegosouzapw/omniroute
@@ -165,20 +149,17 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
fetch-depth: 0
- name: Assert Docker Engine
run: docker info
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -191,14 +172,12 @@ jobs:
context: .
target: runner-base
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max,ignore-error=true
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
@@ -210,68 +189,12 @@ jobs:
context: .
target: runner-web
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-web-${{ matrix.arch }}
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max,ignore-error=true
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Build and push BUN base platform image by digest
id: build-bun-base
# Bun is a best-effort compatibility target, not a supported runtime
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
# both arches; letting that sink the whole publish means the SUPPORTED
# runner-base / runner-web images never reach the registry either. The
# image is still built and pushed whenever it succeeds — only its power to
# block the release is removed.
continue-on-error: true
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.bun
target: runner-base
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-bun-base-${{ matrix.arch }}
cache-to: type=gha,scope=docker-bun-base-${{ matrix.arch }},mode=max,ignore-error=true
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Build and push BUN web platform image by digest
id: build-bun-web
# Bun is a best-effort compatibility target, not a supported runtime
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
# both arches; letting that sink the whole publish means the SUPPORTED
# runner-base / runner-web images never reach the registry either. The
# image is still built and pushed whenever it succeeds — only its power to
# block the release is removed.
continue-on-error: true
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.bun
target: runner-web
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-bun-web-${{ matrix.arch }}
cache-to: type=gha,scope=docker-bun-web-${{ matrix.arch }},mode=max,ignore-error=true
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
@@ -280,22 +203,11 @@ jobs:
env:
DIGEST_BASE: ${{ steps.build.outputs.digest }}
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
DIGEST_BUN_BASE: ${{ steps.build-bun-base.outputs.digest }}
DIGEST_BUN_WEB: ${{ steps.build-bun-web.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
mkdir -p /tmp/digests/base /tmp/digests/web
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
# Empty when the (non-blocking) bun build produced no image. `if` blocks,
# not `[ -n ] && touch`: under `set -e` a failing AND-list aborts the step,
# which is precisely the case being handled here.
if [ -n "$DIGEST_BUN_BASE" ]; then
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
fi
if [ -n "$DIGEST_BUN_WEB" ]; then
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
fi
- name: Upload base digests
uses: actions/upload-artifact@v7
@@ -313,30 +225,6 @@ jobs:
if-no-files-found: error
retention-days: 1
- name: Upload bun-base digests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digests-bun-base-${{ matrix.arch }}
path: /tmp/digests/bun-base/*
# `ignore`, not `error`: the bun build is non-blocking, so an absent
# digest is the expected outcome of a failed/skipped bun image — the
# manifest step already treats these tags as optional. Leaving `error`
# here just relocates the blocker from the manifest to the upload.
if-no-files-found: ignore
retention-days: 1
- name: Upload bun-web digests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digests-bun-web-${{ matrix.arch }}
path: /tmp/digests/bun-web/*
# `ignore`, not `error`: the bun build is non-blocking, so an absent
# digest is the expected outcome of a failed/skipped bun image — the
# manifest step already treats these tags as optional. Leaving `error`
# here just relocates the blocker from the manifest to the upload.
if-no-files-found: ignore
retention-days: 1
merge:
name: Publish multi-arch manifests
needs:
@@ -365,13 +253,13 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -391,32 +279,12 @@ jobs:
path: /tmp/digests/web
merge-multiple: true
- name: Download bun-base digests
# Non-blocking: the bun image is best-effort, so its artifact may not
# exist at all. The manifest step treats these tags as optional.
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: digests-bun-base-*
path: /tmp/digests/bun-base
merge-multiple: true
- name: Download bun-web digests
# Non-blocking: the bun image is best-effort, so its artifact may not
# exist at all. The manifest step treats these tags as optional.
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: digests-bun-web-*
path: /tmp/digests/bun-web
merge-multiple: true
- name: Create Docker Hub manifest
run: |
set -euo pipefail
create_manifest() {
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
local image="$1" suffix="$2" dir="$3"
local tags=(-t "${image}:${VERSION}${suffix}")
if [ "$PROMOTE_LATEST" = "true" ]; then
tags+=(-t "${image}:latest${suffix}")
@@ -426,10 +294,6 @@ jobs:
refs+=("${image}@sha256:$(basename "$digest_file")")
done < <(find "$dir" -type f | sort)
if [ "${#refs[@]}" -eq 0 ]; then
if [ -n "$optional" ]; then
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
return 0
fi
echo "No image digests in $dir" >&2
exit 1
fi
@@ -438,15 +302,13 @@ jobs:
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
- name: Create GHCR manifest
run: |
set -euo pipefail
create_manifest() {
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
local image="$1" suffix="$2" dir="$3"
local tags=(-t "${image}:${VERSION}${suffix}")
if [ "$PROMOTE_LATEST" = "true" ]; then
tags+=(-t "${image}:latest${suffix}")
@@ -456,10 +318,6 @@ jobs:
refs+=("${image}@sha256:$(basename "$digest_file")")
done < <(find "$dir" -type f | sort)
if [ "${#refs[@]}" -eq 0 ]; then
if [ -n "$optional" ]; then
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
return 0
fi
echo "No image digests in $dir" >&2
exit 1
fi
@@ -468,8 +326,6 @@ jobs:
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
- name: Inspect image
if: needs.prepare.outputs.version != 'main'
@@ -488,15 +344,6 @@ jobs:
# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
#
# ignore-unfixed mirrors the blocking gate: the Security tab must surface only
# ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding
# on a patched base or bumping the dep. Without it the advisory upload floods the
# tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream
# patch yet, overwhelmingly local-only and not reachable from the proxy request
# surface), which is noise an operator cannot act on. trivyignores points at the
# repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home.
# See docs/security/SUPPLY_CHAIN.md.
- name: Trivy image scan (SARIF, advisory)
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
@@ -506,8 +353,6 @@ jobs:
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore
exit-code: "0"
# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the
@@ -528,21 +373,18 @@ jobs:
severity: CRITICAL
ignore-unfixed: true
exit-code: "1"
# Explicit: the advisory scan above already points at it, and the blocking
# gate must honour the same accepted-risk list (#12084).
trivyignores: .trivyignore
- 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.9
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-results.sarif
category: trivy-image
- name: Update Docker Hub description
# Only refresh README/description when we actually promote :latest
# (avoids overwriting from main, next, or back-fill builds).
# (avoids overwriting from main pushes or back-fill builds).
if: needs.prepare.outputs.promote_latest == 'true'
uses: peter-evans/dockerhub-description@v5
with:

View File

@@ -4,21 +4,12 @@ on:
push:
tags:
- "v*"
# A dispatch builds the ref it is dispatched ON (`gh workflow run … --ref v3.8.50` rebuilds
# that tag; `--ref main` builds the repaired line). The ref is deliberately NOT an input:
# CodeQL flags an input-controlled checkout next to the npm cache on the default branch as
# cache poisoning (actions/cache-poisoning/poisonable-step), and `github.ref` is trusted.
workflow_dispatch:
inputs:
version:
description: "Release version (e.g., v1.6.8)"
required: true
type: string
publish_npm:
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
required: false
default: true
type: boolean
# Least-privilege default: read-only at the top level; each job grants the writes it
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
@@ -64,78 +55,9 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "✓ Valid version: $VERSION"
web-build:
name: Build shared Next standalone
needs: validate
# Stage 8 (issue #10321): the four desktop legs used to each run the full
# `npm run build` (Next standalone) — ~111 runner-minutes per release just to
# produce the same platform-independent bundle four times. This job builds it
# once on ubuntu; every leg then restores the byte-verified archive and
# re-forks its native optionals (scripts/build/standaloneBundle.mjs).
#
# Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled.
# This job then skips, every leg falls back to building its own web bundle
# (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 —
# no revert needed.
if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Install dependencies
run: npm ci
env:
NPM_CONFIG_LEGACY_PEER_DEPS: true
- name: Build Next.js standalone
# webpack, not Turbopack, for the same hosted-runner RAM reason as the
# linux leg (see the long comment on the fallback step in `build`).
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
OMNIROUTE_USE_TURBOPACK: "0"
run: npm run build
- name: Pack standalone bundle
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
# archive's own sha256 so artifact-transfer corruption is caught before
# extraction, and every entry is re-verified after extraction.
run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz
- name: Upload shared web bundle
uses: actions/upload-artifact@v7
with:
name: web-standalone-bundle
# compression-level 0: the payload is already a deterministic tar.gz;
# re-zipping would only burn runner CPU without shrinking it further.
compression-level: 0
# Legs consume this within minutes; no reason to retain it like the
# installer artifacts (default 90d).
retention-days: 3
path: |
web-bundle.tar.gz
web-bundle.tar.gz.manifest.json
build:
name: Build Electron (${{ matrix.platform }})
needs: [validate, web-build]
# `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback
# mode); legs then run the legacy per-leg web build below. If it ran and
# failed, fail closed: legs cannot package without the bundle, and silently
# falling back to four per-leg builds would hide exactly the regression the
# shared job exists to surface.
if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }}
needs: validate
runs-on: ${{ matrix.runner }}
permissions:
contents: write # electron-builder may publish artifacts with GH_TOKEN
@@ -147,62 +69,43 @@ jobs:
runner: windows-latest
target: win
ext: .exe
os: win32
arch: x64
- platform: macos-intel
runner: macos-15-intel
target: mac-x64
ext: .dmg
os: darwin
arch: x64
- platform: macos-arm64
runner: macos-latest
target: mac-arm64
ext: -arm64.dmg
os: darwin
arch: arm64
- platform: linux
runner: ubuntu-latest
target: linux
ext: .AppImage
deb_ext: .deb
os: linux
arch: x64,arm64
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Cache node_modules
uses: actions/cache@v5.0.5
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
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
@@ -213,52 +116,12 @@ jobs:
mkdir -p "$RUNNER_TEMP/home"
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
- name: Build Next.js standalone (legacy per-leg fallback)
# Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled)
# or when the shared web-build job was skipped. Otherwise the leg restores
# the shared bundle from the `web-build` job below.
if: needs.web-build.result == 'skipped'
- name: Build Next.js standalone
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
# Linux builds with webpack, not Turbopack. Turbopack's production build
# allocates natively (Rust, off the V8 heap), so --max_old_space_size does
# not bound it, and on this module graph it peaks above what the hosted
# runner can give — the VM is reclaimed mid-compile with "The runner has
# received a shutdown signal", no exit code. That is what silently took the
# whole desktop channel out of v3.8.49: the linux leg died, `release` was
# skipped, and the release shipped with ZERO assets. Measured on a 32 GB
# box the same build passes and peaks past 14 GB. The webpack fallback is
# the project's documented escape hatch for RAM-constrained machines
# (docs/reference/ENVIRONMENT.md, #6409) and is the same remedy already
# applied to nightly-compat's Node 26 build (#8090).
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
run: npm run build
- name: Download shared web bundle
# Stage 8: inverse of the fallback step above — runs exactly when the
# shared `web-build` job produced the bundle.
if: needs.web-build.result == 'success'
uses: actions/download-artifact@v8
with:
name: web-standalone-bundle
- name: Restore + hydrate shared web bundle
if: needs.web-build.result == 'success'
shell: bash
# restore: verify the archive's sha256 against the manifest, extract, then
# re-verify every entry (existence + size + content hash + symlink
# 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-*,
# @wreq-js/binding-*, fsevents) carry linux forks. Replace them with the forks this
# leg's own `npm ci` resolved, then assert every bundled native
# (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
node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }}
- name: Sync version in electron/package.json
shell: bash
env:
@@ -283,7 +146,7 @@ jobs:
- name: Install Electron dependencies
working-directory: electron
run: npm ci --no-audit --no-fund
run: npm install --no-audit --no-fund
- name: Build Electron for ${{ matrix.platform }}
working-directory: electron
@@ -310,14 +173,9 @@ jobs:
- name: Smoke packaged Electron app (Linux)
if: matrix.platform == 'linux'
# #7592: also cold-restart against the same DATA_DIR and assert a
# native SQLite driver (not the sql.js WASM fallback) is selected on
# the second launch — blocking here since Linux has no Windows-style
# sandbox caveats that would make it flaky.
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
ELECTRON_SMOKE_STREAM_LOGS: "1"
ELECTRON_SMOKE_COLD_RESTART: "1"
run: xvfb-run -a npm run electron:smoke:packaged
- name: Collect installers
@@ -343,12 +201,6 @@ jobs:
[ -f "$file" ] && cp "$file" "../../release-assets/OmniRoute.exe" && break
done
fi
# electron-updater manifests (latest.yml / latest-mac.yml / latest-linux.yml)
# must be published alongside the installers, or autoUpdater fails with
# "Cannot find latest.yml in the latest release artifacts" (#6766).
for file in latest*.yml; do
[ -f "$file" ] && cp "$file" ../../release-assets/
done
- name: Upload artifacts
uses: actions/upload-artifact@v7
@@ -359,16 +211,6 @@ jobs:
release:
name: Create Release
needs: [validate, build]
# Fail-partial, not fail-closed. `build` is a 4-leg matrix with `fail-fast: false`,
# so the legs that succeed still upload their artifacts — but a default `needs:`
# gate skips this job the moment ANY leg fails, discarding all of them. That is
# exactly what happened to v3.8.49: the linux leg died and the release shipped with
# ZERO assets, throwing away 1.7 GB of good Windows/macOS installers **and** the
# source archives + SBOM, which do not depend on a build at all. The result was
# indistinguishable from "this version has no desktop channel".
# Now: attach everything that did build, then fail the job loudly (see the last
# step) so an incomplete channel is visible instead of silent.
if: ${{ !cancelled() && needs.validate.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write # softprops/action-gh-release creates the GitHub Release
@@ -378,36 +220,12 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
# Source archives + SBOM come from the tag being released, not the dispatching branch.
ref: ${{ needs.validate.outputs.version }}
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
# own dmg (measured: 338 and 350 bytes, different content, identical name). One silently
# overwrote the other — arm64 won in the published v3.8.48, and since the Intel dmg
# carries no arch suffix in its name, electron-updater's
# `files.find(url includes process.arch) ?? files.shift()` sends every Intel Mac to the
# ARM dmg. Downloading into per-artifact subdirectories keeps both, so they can be
# merged on purpose instead of by luck.
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
# Writes release-assets/latest-mac.yml with BOTH dmgs, un-suffixed entry first (that is
# the one electron-updater can only reach through its fallback). Refuses to write when the
# inputs disagree on version — a manifest stitched from two builds is worse than none.
- name: Merge the per-arch macOS updater manifests
run: node scripts/release/merge-mac-update-manifest.mjs artifacts release-assets
# Everything else moves across as-is. The partial latest-mac.yml files are excluded so
# they cannot clobber the merged one; -n is a second belt on the same braces.
- name: Collect the remaining artifacts
run: |
mkdir -p release-assets
find artifacts -type f ! -name latest-mac.yml -exec cp -n {} release-assets/ \;
echo "release-assets:"
ls -la release-assets/
path: release-assets
merge-multiple: true
- name: Create source archives
env:
@@ -437,14 +255,7 @@ jobs:
tag_name: ${{ needs.validate.outputs.version }}
draft: false
prerelease: false
# NEVER. Phase 3 of the release flow creates the GitHub Release with the curated
# notes seconds after pushing the tag, so by the time this step runs (1-2 h of
# builds later) the body already exists — and `true` APPENDS GitHub's
# auto-generated "What's Changed" block to it (v3.8.48 shipped that way; the
# v3.8.50 re-attach dispatch added +1,416 chars to a 121 KB body, run
# 33238093090). A curated body sits ~3 KB under the 125,000-char cap, so the
# append can also turn this step RED and leave the release with no assets.
generate_release_notes: false
generate_release_notes: true
fail_on_unmatched_files: false
files: |
release-assets/*.dmg
@@ -452,70 +263,19 @@ jobs:
release-assets/*.AppImage
release-assets/*.deb
release-assets/*.blockmap
release-assets/*.yml
release-assets/*.source.tar.gz
release-assets/*.source.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
verify-desktop-assets:
name: Verify desktop assets landed
needs: [validate, release]
# Deliberately a SEPARATE job, not a final step of `release`: failing inside
# `release` would cascade into `publish-npm` (which gates on `needs: release`) and
# block the npm channel over a desktop-only gap. Here the assets are attached, npm
# still publishes, and an incomplete desktop channel shows up as a red job instead
# of passing unnoticed — the v3.8.49 release had ZERO assets and every gate was
# green, because nothing ever asserted the release HAS binaries.
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Assert every platform is present on the release
env:
# Regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in the `validate` job, and
# passed via env rather than interpolated into the script body.
VERSION: ${{ needs.validate.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
names=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \
--json assets --jq '.assets[].name')
echo "Assets on $VERSION:"
echo "$names" | sed 's/^/ /'
missing=""
# `[ ... ] && missing=...` as the last command in a branch returns 1 and
# would abort the whole script under Actions' default `set -e`. Use if/fi.
for want in '\.exe$' '\.dmg$' '\.AppImage$' '\.deb$' '^latest.*\.yml$' '\.source\.tar\.gz$'; do
if ! echo "$names" | grep -qE "$want"; then
missing="$missing $want"
fi
done
if [ -n "$missing" ]; then
echo "::error::Desktop channel incomplete on $VERSION — no asset matching:$missing"
exit 1
fi
echo "✓ every platform present on $VERSION"
publish-npm:
name: Publish to npm
needs: [validate, release]
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
permissions:
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
# A reusable workflow's job cannot request more permission than the caller grants,
# so a `read` here makes GitHub reject the run at startup (startup_failure).
#
# `actions: read` for the same reason: the called `publish` job downloads the next-build
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
# this block a superset of every job's permissions in npm-publish.yml.
actions: read
contents: write
id-token: write # npm provenance (forwarded to the reusable workflow)
packages: write # publish to npm.pkg.github.com

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

View File

@@ -1,139 +0,0 @@
name: Nightly Node Compat
# Plano mestre testes+CI (Eixo D2, aprovado 2026-07-04): as matrizes de compatibilidade
# Node 24/26 custavam ~28% de CADA run do CI pesado (2 execuções completas da suíte por
# sync da release-PR) para pegar uma classe de quebra que raramente nasce num PR típico.
# Elas rodam aqui 1×/dia contra o tip da release ativa (mesmo alvo do nightly-release-green)
# e continuam obrigatórias no gate de release via workflow_dispatch do ci.yml se preciso.
# fail-fast desligado: numa quebra queremos saber TODAS as versões afetadas de uma vez.
on:
schedule:
- cron: "47 6 * * *" # 06:47 UTC diário — slot distinto dos demais nightlies
workflow_dispatch:
inputs:
branch:
description: "Branch to validate (default: highest release/vX.Y.Z)"
required: false
type: string
permissions:
contents: read
issues: write
concurrency:
group: nightly-compat
cancel-in-progress: true
jobs:
resolve-branch:
name: Resolve active release branch
runs-on: ubuntu-latest
outputs:
target: ${{ steps.branch.outputs.target }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve active release branch
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
else
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
| sed 's#origin/##' \
| sort -t/ -k2 -V \
| tail -1)
fi
case "$TARGET" in
release/v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Refusing non-canonical branch name: $TARGET"; exit 1 ;;
esac
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
compat-build-26:
name: Node 26 Compatibility Build
runs-on: ubuntu-latest
timeout-minutes: 25
needs: resolve-branch
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "26"
cache: npm
- uses: ./.github/actions/npm-ci-retry
# #8090 — this is the ONLY Node 26 build in the whole CI matrix (ci.yml pins
# CI_NODE_VERSION=24). It failed every nightly with the runner-reclaimed
# signature ("The runner has received a shutdown signal" / "The operation was
# canceled", no exit code) always at the same Turbopack compile phase — a
# classic OOM kill on the memory-constrained ubuntu-latest runner. Turbopack's
# native (Rust, off-V8-heap) allocation is NOT bounded by --max-old-space-size
# and peaks far higher than webpack on this large module graph (#6409), and is
# heavier still under Node 26. Use the documented webpack fallback here: it still
# validates that the app *builds* on Node 26 (the point of this compat job) at a
# much lower memory peak. Turbopack-on-Node-24 stays covered by ci.yml's build
# job. See docs/reference/ENVIRONMENT.md (OMNIROUTE_USE_TURBOPACK) and #6409.
- run: npm run build
env:
OMNIROUTE_USE_TURBOPACK: "0"
compat-tests:
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
runs-on: ubuntu-latest
timeout-minutes: 25
needs: resolve-branch
strategy:
fail-fast: false
matrix:
node: [24, 26]
shard: [1, 2, 3, 4]
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
TEST_SHARD: ${{ matrix.shard }}/4
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:unit:ci:shard
report:
name: Open / update tracking issue on failure
runs-on: ubuntu-latest
if: ${{ !cancelled() && (needs.compat-tests.result == 'failure' || needs.compat-build-26.result == 'failure') }}
needs: [resolve-branch, compat-build-26, compat-tests]
permissions:
issues: write
steps:
- name: Open or update issue
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ needs.resolve-branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
TITLE="🌙 nightly-compat: Node 24/26 failures on $TARGET"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "$TITLE in:title" --json number --jq '.[0].number')
BODY="Nightly Node-compat run failed on \`$TARGET\`: $RUN_URL — triage which Node version/shard broke (fail-fast off, all versions reported)."
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY"
fi

View File

@@ -10,21 +10,16 @@ permissions:
jobs:
promptfoo-guard:
name: promptfoo — injection guard (block mode, no secret)
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute (block mode)
env:
@@ -49,10 +44,7 @@ jobs:
garak:
name: garak probes (skip without provider secret)
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
runs-on: ubuntu-latest
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
# it there makes GitHub reject the file on push (startup_failure on every push).
# Map the secret into a job-level env and gate each step on a presence check, so
@@ -73,16 +65,14 @@ jobs:
with:
persist-credentials: false
if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
if: steps.gate.outputs.run == 'true'
with: { node-version: "24", cache: npm }
- run: npm ci
if: steps.gate.outputs.run == 'true'
- name: Build CLI bundle
if: steps.gate.outputs.run == 'true'
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute
if: steps.gate.outputs.run == 'true'
@@ -96,7 +86,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
if: steps.gate.outputs.run == 'true'
with: { python-version: "3.12" }
- run: pip install garak

View File

@@ -107,13 +107,13 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Restore Stryker incremental cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: reports/mutation/stryker-incremental.json
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
@@ -148,10 +148,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Download all mutation reports

View File

@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

View File

@@ -1,562 +0,0 @@
name: Release-Green (continuous)
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
#
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
# accrue silently on release/** and explode — in layers — at release time. This
# workflow reproduces the release-equivalent validation on the release branch and,
# when there are HARD failures, opens/updates a single tracking issue.
#
# WS5.1 (v3.8.49 quality plan) — two modes:
# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the
# captain's direct pushes (sync-back — the one ungated write path) AND the merged
# COMBINATION right after every PR merge, attributing the offending push range in
# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push.
# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites).
#
# It is NOT a required status check and never touches a contributor PR — it only
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
# expected mid-cycle and is reported but never raises the alarm on its own; only
# real defects (typecheck / lint errors / unit / vitest / db-rules / public-creds /
# package-artifact) flip the issue open.
on:
push:
branches: ["release/v*", "main"]
paths:
- "src/**"
- "open-sse/**"
- "bin/**"
- "electron/**"
- "scripts/**"
- "tests/**"
- "config/**"
- "package.json"
- "package-lock.json"
- "tsconfig*.json"
schedule:
- cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies
- cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×)
- cron: "23 18 * * *" # full sweep — evening
workflow_dispatch:
inputs:
branch:
description: "Release branch to validate (default: highest release/vX.Y.Z)"
required: false
type: string
permissions:
contents: read
issues: write
concurrency:
# push storms during merge campaigns collapse to the newest commit per branch;
# scheduled full sweeps keep their own single lane.
group: release-green-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
env:
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
jobs:
release-green:
name: Validate active release branch
# On a push, only run for release/* pushes — a push to main is handled by the
# main-green job below. Schedule/dispatch always run (they validate the highest release).
if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }}
# Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight)
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
# no local noauth CLIs => zero machine-specific false positives) and no contention.
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve active release branch
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
EVENT_NAME: ${{ github.event_name }}
PUSHED_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
elif [ "$EVENT_NAME" = "push" ]; then
# validate exactly what was pushed, not the highest branch
TARGET="$PUSHED_REF"
else
# highest release/vX.Y.Z by semver among remote branches
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
| sed 's#origin/##' \
| sort -t/ -k2 -V \
| tail -1)
fi
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
# Strict format guard — reject anything that isn't release/vX.Y.Z (blocks
# ref/command injection via the workflow_dispatch input).
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Refusing non-canonical branch name: $TARGET"; exit 1
fi
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "Active release branch: $TARGET"
- name: Checkout the release branch
env:
TARGET: ${{ steps.branch.outputs.target }}
run: |
set -euo pipefail
git checkout "$TARGET"
git log -1 --oneline
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Release-green validation (full)
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
# operator env; hosted ignores the unknown flag before #6300 lands).
# push → --quick: fast HARD gates only (~5-8min), per-merge signal.
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict,
# pr-test-policy) + build + full suites. PRs into release/** only get the
# fast-gates, so these accrue silently and explode in layers on the release PR
# (v3.8.46: 11 static base-reds leaked).
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
# MODE is an intentional flag list, so word-splitting is wanted here
# shellcheck disable=SC2086
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> release-green.json 2> release-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
cat release-green.log
- name: Open / update tracking issue on HARD failure
if: steps.validate.outputs.exit != '0'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
run: |
set -euo pipefail
TITLE="🔴 Release branch not green: ${TARGET}"
{
echo "The **release-green** validation found HARD failures on \`${TARGET}\`."
echo "These are real defects that would block the release PR — fix them in the"
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
# WS5.1 attribution: on push events the offending change IS this push's range
# (one merge per push in the normal queue), so name it — no bisect needed.
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo ""
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
echo '```'
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
echo '```'
fi
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
echo '```'
echo ""
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._"
} > issue-body.md
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
echo "Updated existing issue #$EXISTING"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
TITLE="🔴 Release branch not green: ${TARGET}"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`${TARGET}\` is release-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
echo "Closed issue #$EXISTING"
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: release-green-report
path: |
release-green.json
release-green.log
if-no-files-found: ignore
# Companion arm for `main`. Under the parallel-cycle model, main only receives merged
# work at the release squash — so a gate/infra fix that lands only on release leaves
# main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines)
# turn EVERY PR into main red on a check unrelated to its diff. This detects that and
# opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex
# (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop.
main-green:
name: Validate main branch
# On a push, only run for a push to main — a push to release/* is handled by
# release-green above. Schedule/dispatch always run (they also sweep main).
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Main-green validation
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep.
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[main-green] mode: $MODE (event: $EVENT_NAME)"
# MODE is an intentional flag list, so word-splitting is wanted here
# shellcheck disable=SC2086
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> main-green.json 2> main-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
cat main-green.log
- name: Open / update tracking issue on HARD failure
if: steps.validate.outputs.exit != '0'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
TITLE="🔴 main branch not green"
{
echo "The **main-green** validation found HARD failures on \`main\`."
echo ""
echo "Because \`main\` only receives merged work at the release squash, a gate/infra"
echo "fix that landed only on the release branch leaves \`main\` broken for the whole"
echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn"
echo "**every open PR into main** red on a check unrelated to its diff. The fix is a"
echo "companion PR \`--base main\` carrying the release-side fix (see"
echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log
echo '```'
echo ""
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._"
} > issue-body.md
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
echo "Updated existing issue #$EXISTING"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
TITLE="🔴 main branch not green"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`main\` is main-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
echo "Closed issue #$EXISTING"
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: main-green-report
path: |
main-green.json
main-green.log
if-no-files-found: ignore
# ── Banking lane (#8584) ──────────────────────────────────────────────────
# The ratchet is asymmetric: RAISING a cap is a ten-second manual JSON edit made
# under merge pressure, LOWERING one requires someone to run `--update` and commit
# — which no workflow does. Grep `.github/workflows/` for `--update`: only
# wiki-sync.yml (unrelated) and ci.yml's check-quality-ratchet.mjs --require-tighten
# (a different script, a different metric). So a cap outlives the code that earned
# it and every completed decomposition silently becomes a growth allowance for
# whoever touches the file next. Measured on 2026-07-25: 18 frozen files already at
# or under the 800-line new-file cap, up to 132x (schemas.ts, 19 lines / 2523 cap),
# and 31 unfulfilled "tighten via --update next cycle" notes honoured exactly once
# (-1 unit) in six weeks.
#
# This job closes that loop by making the DOWNWARD direction as automatic as the
# upward one is easy. It measures the active release branch, runs the shrink-only
# `--update` paths, and opens ONE always-current PR with the result. It never
# pushes to release/* — a human still merges, so a bad measurement cannot land
# unreviewed. verify-ratchet-bank.mjs is the hard guarantee that the automation can
# only ever write in the shrink direction; if anything was raised, added, or a
# rebaseline note was touched, the job aborts and opens nothing.
#
# Schedule/dispatch only, deliberately NOT on push: banking has no latency
# requirement (a shrink banked within 8h is fine) and a per-merge run would rebuild
# the PR branch repeatedly during merge campaigns while paying for a full ESLint
# walk each time. Detection stays on push (release-green above); banking is batched.
bank-ratchet-shrinks:
name: Bank ratchet shrinks
if: ${{ github.event_name != 'push' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve active release branch
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
else
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
| sed 's#origin/##' \
| sort -t/ -k2 -V \
| tail -1)
fi
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
# Same strict guard as the validation job — blocks ref/command injection
# through the workflow_dispatch input.
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Refusing non-canonical branch name: $TARGET"; exit 1
fi
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "bank_branch=chore/bank-ratchet-${TARGET#release/}" >> "$GITHUB_OUTPUT"
- name: Checkout the release branch
env:
TARGET: ${{ steps.branch.outputs.target }}
run: |
set -euo pipefail
git checkout "$TARGET"
git log -1 --oneline
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Ratchet the baselines down
run: |
# Both --update paths are shrink-only by construction (file-size writes only
# on `improvements`, complexity-ratchets only when `.improved`), and both exit
# non-zero while the branch is over baseline — which is exactly when there is
# nothing to bank. Their exit code is not the signal; the verifier below is.
# Velocity phase (quality-baseline.json `_policy`, relax-baselines.mjs): the caps
# were raised on purpose, so banking the measured shrink would silently undo the
# 20% headroom every night. Pause the downward ratchet until the phase closes.
if node -e 'process.exit(require("./config/quality/quality-baseline.json")._policy?.phase === "velocity" ? 0 : 1)'; then
echo "Velocity phase active — ratchet banking paused (see docs/architecture/QUALITY_GATES.md → Velocity phase)."
exit 0
fi
set +e
node scripts/check/check-file-size.mjs --update
node scripts/check/check-complexity-ratchets.mjs --update
exit 0
- name: Verify the write only went downward
id: verify
run: |
set -euo pipefail
# Exits 1 if ANYTHING was raised/added or a rebaseline note was touched.
# `set -e` then aborts the job before a commit exists — no PR is opened.
node scripts/quality/verify-ratchet-bank.mjs > bank-summary.md
if [ -n "$(git status --porcelain config/quality/)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Nothing to bank — baselines already match the code."
fi
- name: Open / update the banking PR
if: steps.verify.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
BANK_BRANCH: ${{ steps.branch.outputs.bank_branch }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BANK_BRANCH"
git add config/quality/
git commit -m "chore(quality): bank ratchet shrinks measured on ${TARGET}"
git push --force origin "$BANK_BRANCH"
{
echo "Automated banking of quality-ratchet **shrinks** already present in"
echo "\`${TARGET}\` — the downward half of the ratchet, which nothing else runs (#8584)."
echo ""
echo "Produced by \`check:file-size --update\` + \`check:complexity-ratchets --update\`,"
echo "then verified by \`scripts/quality/verify-ratchet-bank.mjs\`: **nothing was raised,"
echo "nothing was added, no rebaseline note was touched** — the job aborts without"
echo "opening a PR if any of those is violated."
echo ""
echo "No product code changes. Merging retires growth allowances that the code no"
echo "longer needs; not merging leaves them available to whoever edits those files next."
echo ""
cat bank-summary.md
echo ""
echo "**Run:** ${RUN_URL}"
} > pr-body.md
EXISTING=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BANK_BRANCH" \
--state open --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh pr edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file pr-body.md
echo "Updated existing PR #$EXISTING"
else
gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET" --head "$BANK_BRANCH" \
--title "chore(quality): bank ratchet shrinks (${TARGET})" --body-file pr-body.md
fi
# ── Baseline headroom (velocity phase, 2026-08-30 → v4.0) ──────────────────────
# The ratchets only speak when a baseline is crossed. With every baseline loosened by
# 20% (scripts/quality/relax-baselines.mjs) the question is how fast the budget is
# being consumed — this job measures each gate the way CI does and posts the headroom
# table to one living issue, so a budget that fills in a week is visible before the
# first red PR. Advisory: never fails the workflow.
baseline-headroom:
name: Baseline headroom
if: ${{ github.event_name != 'push' }}
timeout-minutes: 60
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Measure headroom on ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/quality/baseline-headroom.mjs \
--json reports/quality/headroom.json --md reports/quality/headroom.md
cat reports/quality/headroom.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload headroom report
if: always()
uses: actions/upload-artifact@v7
with:
name: baseline-headroom-${{ github.run_id }}
path: reports/quality/headroom.*
retention-days: 90
- name: Post to the living issue
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
TITLE="📈 Baseline headroom (velocity phase)"
BAD=$(node -e 'const r=require("./reports/quality/headroom.json").rows;console.log(r.filter(x=>x.status==="critical"||x.status==="warn").length)')
{
echo "Branch: \`${GITHUB_REF_NAME}\` · run: ${RUN_URL}"
echo ""
cat reports/quality/headroom.md
} > headroom-comment.md
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -z "$EXISTING" ]; then
EXISTING=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label quality-gate-finding \
--body "Living tracker for the velocity-phase baseline budget (docs/architecture/QUALITY_GATES.md → Velocity phase). One comment per nightly run; the newest comment is the current state." \
| grep -oE '[0-9]+$')
fi
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file headroom-comment.md
if [ "$BAD" != "0" ]; then
gh issue edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --add-label "headroom-alert" 2>/dev/null || true
else
gh issue edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --remove-label "headroom-alert" 2>/dev/null || true
fi

View File

@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -51,7 +51,6 @@ jobs:
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute (background)
env:
@@ -78,10 +77,7 @@ jobs:
a11y:
name: A11y axe (nightly, freeze-and-alert)
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
runs-on: ubuntu-latest
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
@@ -98,13 +94,13 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v6.1.0
uses: actions/cache@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

View File

@@ -10,22 +10,17 @@ permissions:
jobs:
schemathesis:
name: Schemathesis — OpenAPI contract fuzz (advisory)
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute (background)
env:
@@ -38,7 +33,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
sleep 2
done
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
with: { python-version: "3.12" }
- name: Install schemathesis
run: pip install schemathesis
@@ -50,7 +45,7 @@ jobs:
# PROVE the contract is fuzzable and surface regressions, not to gate the build.
continue-on-error: true
run: |
schemathesis run docs/openapi.yaml \
schemathesis run docs/reference/openapi.yaml \
--url http://localhost:20128 \
--max-examples 20 \
--workers 4 \

View File

@@ -22,15 +22,6 @@ on:
- latest
- next
- historic
publish_mode:
description: "auto = publish through npm Trusted Publishing (OIDC, no token, no 2FA prompt — the default); staged = npm stage publish (owner approves with 2FA); direct = legacy token publish (emergency fallback only)"
required: false
default: "auto"
type: choice
options:
- auto
- staged
- direct
workflow_call:
inputs:
version:
@@ -57,21 +48,10 @@ env:
jobs:
publish:
# Same dynamic-runner rule as ci.yml's `build`/`test-unit`: `build:cli` falls back to a
# full `next build`, whose working set outgrew the 16 GB hosted runner during the
# v3.8.49 cycle — the publish died with "The runner has received a shutdown signal"
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
# it is kept verbatim so the expression stays greppable against ci.yml.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
skip: ${{ steps.resolve.outputs.skip }}
runs-on: ubuntu-latest
permissions:
actions: read # find + download the CI run's next-build artifact for this SHA
contents: write # gh release upload (attach SBOM to the GitHub Release)
id-token: write # npm provenance (GitHub Packages step)
id-token: write # npm provenance
packages: write # publish to npm.pkg.github.com
steps:
- name: Checkout
@@ -83,7 +63,7 @@ jobs:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
@@ -157,105 +137,12 @@ jobs:
run: |
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Fast path: CI already built the standalone tree for THIS commit and uploaded it as
# `next-build`. `build:cli` (scripts/build/prepublish.ts) only shells out to a full
# `next build` when `.build/next/standalone/server.js` is missing — restoring the
# artifact turns the heaviest step of the publish into a download. Matching on
# `head_sha` is the tree-equality guarantee: same commit, same tree.
# Best-effort by design (retention is 1 day): every miss falls through to the build
# step below, which is why the dynamic runner above matters as the backstop.
#
# The `head_repository.full_name == env.REPO` clause is a supply-chain guard, not a
# filter refinement. This artifact becomes the published npm tarball. `pull_request`
# runs from forks execute in THIS repository's context and upload their own
# `next-build` built from fork-controlled source, and the runs API returns them for a
# matching `head_sha` — 57 such runs exist in this repo today. Without the clause,
# anything that made a fork's head commit coincide with the publish commit could put
# attacker-built bytes on npm. Requiring the run to originate from this repository
# excludes every fork run while keeping the fast path intact (verified: the same
# single run is selected either way for the current tip).
# CodeQL: actions/artifact-poisoning/critical.
- name: Reuse CI's next-build artifact (skips the heavy rebuild)
if: steps.resolve.outputs.skip != 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
# The question is "which run HAS the artifact", not "which run passed" (gap 16).
# Requiring `conclusion == "success"` on the whole run discarded a perfectly good tree
# whenever any unrelated shard went red — one flaky test then pushed the publish into
# the 40-minute build this step exists to avoid. The artifact is only uploaded if the
# Build job itself succeeded, so its PRESENCE is the accurate signal; the run's overall
# conclusion is noise from jobs that have nothing to do with the tree.
#
# `head_repository.full_name == env.REPO` stays, and it is not a filter refinement:
# this tree becomes the published npm tarball, and fork `pull_request` runs execute in
# THIS repository's context uploading their own next-build. That clause is the
# supply-chain guard (CodeQL actions/artifact-poisoning).
CANDIDATES=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
--jq '[.workflow_runs[]
| select(.name == "CI"
and .head_repository.full_name == env.REPO)]
| sort_by(.run_started_at) | reverse | .[0:5] | .[].id') || CANDIDATES=""
if [ -z "$CANDIDATES" ]; then
echo "::notice::no CI run from this repository for $HEAD_SHA — falling back to a full build"
exit 0
fi
RUN=""
# $RUNNER_TEMP, never /tmp: on the .113 pool /tmp is a 12 GB tmpfs (RAM). Parking
# this 1.3 GB artefact there took 2732 min of the 76-min publish job — the
# same bytes upload from disk in 2 min. RUNNER_TEMP is per-runner and on disk.
for candidate in $CANDIDATES; do
if gh run download "$candidate" --repo "$REPO" --name next-build --dir "$RUNNER_TEMP/next-build" 2>/dev/null; then
RUN="$candidate"
break
fi
echo " run $candidate carries no usable next-build — trying the next"
done
if [ -z "$RUN" ]; then
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
exit 0
fi
tar -xzf "$RUNNER_TEMP/next-build/e2e-build.tar.gz" -C .
rm -rf "$RUNNER_TEMP/next-build"
if [ -f .build/next/standalone/server.js ]; then
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
else
echo "::notice::extract did not yield .build/next/standalone — falling back to a full build"
rm -rf .build
fi
- name: Build CLI bundle (standalone app)
if: steps.resolve.outputs.skip != 'true'
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
run: npm run build:cli
# `build:cli` assembles dist/ but does NOT write dist/BUILD_SHA — only
# `build:release` does, by calling write-build-sha.mjs. The #10427 provenance
# guard inside check:pack-artifact rejects an artifact with no SHA (and rejects
# it even under OMNIROUTE_ALLOW_CANARY_BUILD=1: what cannot be identified cannot
# be vouched for). Without this step the build+validate pair in this job is
# structurally incompatible and fails 100% of the time — the same gap that was
# fixed in ci.yml's Package Artifact job.
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
if: steps.resolve.outputs.skip != 'true'
env:
OMNIROUTE_BUILD_SHA: ${{ github.sha }}
run: |
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
node scripts/build/write-build-sha.mjs
# The guard checks ancestry against origin/main by default, which is correct
# here (a release tag is cut from main), but the ref has to exist locally for
# `git merge-base` to resolve it.
- name: Fetch main for the provenance probe
if: steps.resolve.outputs.skip != 'true'
run: git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
- name: Validate npm package artifact
if: steps.resolve.outputs.skip != 'true'
run: npm run check:pack-artifact
@@ -273,91 +160,25 @@ jobs:
if-no-files-found: error
- name: Attach SBOM to GitHub Release
# Not only on the `release` event: the v3.8.50 package shipped through a
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
# GitHub Release carried no SBOM until it was attached by hand from the run's
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
run: |
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
exit 0
fi
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
# real tarball and fails the publish before anything reaches the registry.
- name: Boot-smoke the tarball before ANY publish
if: steps.resolve.outputs.skip != 'true'
run: npm run check:pack-boot
# The boot-smoke above proves a CLEAN install boots. It does not prove the path that
# actually broke us: installing over an existing version, where ~110 SQLite migrations
# run against a populated database. v3.8.48 shipped as a hotfix because the published
# 3.8.47 crashed on boot, and the v3.8.49 upgrade path was first exercised end-to-end
# by hand on a real 3.8.48 box (VPS .16) — after publishing, which is exactly backwards.
# Runs BEFORE `npm stage publish` so a broken upgrade never reaches the registry at all;
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
- name: Prove clean-install AND upgrade-over-previous both boot
if: steps.resolve.outputs.skip != 'true'
# 60, not 30. This gate was added in #8953 and the 2026-08-27 v3.8.50 publish
# was the FIRST run to ever reach it — every earlier attempt died upstream, so
# its budget had never been measured against a real run. It then blew the limit
# on its debut: `npm pack` alone took 24m37s, leaving 5 minutes for two installs
# and two boots. 30 was a guess; 60 is sized to the one measurement we have.
timeout-minutes: 60
run: npm run check:install-upgrade
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
# parks the exact bytes on the registry WITHOUT making them installable; the
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
# fallback (legacy immediate publish) via workflow_dispatch.
- name: Ensure npm supports staged publishing
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
run: |
set -euo pipefail
CUR=$(npm --version)
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
# Pinned exact version (supply-chain: never float @latest in the publish
# job); bump deliberately when a newer npm is required.
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
npm install -g --ignore-scripts npm@11.15.0
fi
npm --version
# The registry upload itself moved to the `stage-npm` job below: npm REFUSES
# `--provenance` from a self-hosted runner (422 "Unsupported GitHub Actions
# runner environment"), and the heavy verification above cannot move to a
# hosted one (16 GB is not enough for build:cli's next-build fallback — see
# this job's runs-on comment). So this job proves the bytes and hands them
# over; a tiny hosted job does the upload.
- name: Pack the verified tarball for the upload job
- name: Publish to npm
if: steps.resolve.outputs.skip != 'true'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# --ignore-scripts: prepublishOnly would re-run build:cli-api && build:cli,
# rebuilding bytes this job has already built, validated and boot-smoked.
npm pack --ignore-scripts
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "expected $TARBALL to exist after npm pack" >&2; ls -la ./*.tgz || true; exit 1; }
echo "packed $TARBALL ($(du -h "$TARBALL" | cut -f1))"
- name: Hand the tarball to the hosted publish job
if: steps.resolve.outputs.skip != 'true'
uses: actions/upload-artifact@v7
with:
name: npm-tarball
path: omniroute-${{ steps.resolve.outputs.version }}.tgz
retention-days: 1
if-no-files-found: error
# Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, `npm publish --tag historic` will
# NOT promote it to `@latest`.
npm publish --provenance --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'
@@ -374,117 +195,6 @@ jobs:
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
echo "✅ Action finished for GitHub Packages"
# npm REFUSES `--provenance` from a self-hosted runner:
# 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
# Unsupported GitHub Actions runner environment: "self-hosted".
# Only "github-hosted" runners are supported when publishing with provenance.
# v3.8.49 published fine because it predates USE_VPS_RUNNER being turned on
# (2026-08-02); v3.8.50 was the first release after it, so this had been latent
# for four weeks. Dropping --provenance was not an option: 3.8.49 carries a
# SLSA attestation and 3.8.50 must not regress that.
# The `publish` job cannot simply move to a hosted runner either — 16 GB is not
# enough for build:cli's next-build fallback. So it keeps proving the bytes and
# this job, which needs no memory at all, performs the upload.
stage-npm:
needs: publish
if: needs.publish.outputs.skip != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance — the whole reason this job is separate
steps:
- name: Download the tarball the publish job proved
uses: actions/download-artifact@v8
with:
name: npm-tarball
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Ensure npm supports staged publishing
run: |
set -euo pipefail
CUR=$(npm --version)
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
# Pinned exact version (supply-chain: never float @latest in a publish
# job); bump deliberately when a newer npm is required.
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
npm install -g --ignore-scripts npm@11.15.0
fi
npm --version
# Trusted Publishing (OIDC): npm mints a short-lived credential for THIS run from
# GitHub's id-token — no NPM_TOKEN secret, no 2FA prompt, provenance included, and
# it is the bypass npm sanctions now that tokens which skip 2FA are being retired
# (gh.io/npm-gat-bypass2fa-deprecation). Requires the package's Trusted Publisher to
# be configured on npmjs.com (owner: diegosouzapw/OmniRoute, workflow
# npm-publish.yml) and a github-hosted runner — which is why this job exists.
# Without that configuration `npm publish` fails with ENEEDAUTH: re-dispatch with
# publish_mode=staged or direct. Automatic publishing was the flow up to v3.8.48;
# v3.8.49 moved to staged (WS1.3) to keep a leaked token from publishing alone —
# OIDC gives the same guarantee without the manual approve.
- name: Publish to npm (Trusted Publishing / OIDC — automatic)
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode == 'auto'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}
run: |
set -euo pipefail
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
# Deliberately NO NODE_AUTH_TOKEN in this step: npm >= 11.5 detects the GitHub
# OIDC token itself. Always pass --tag explicitly (defense in depth: an older
# VERSION can never claim `@latest`).
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) via Trusted Publishing"
- name: Publish to npm (staged — owner approves with 2FA)
# Only on an explicit request now: Trusted Publishing below is the default.
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'staged'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
# Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, the historic tag will NOT claim `@latest`.
# --ignore-scripts: publishing a built tarball must never re-run
# prepublishOnly (build:cli-api && build:cli) on this small runner.
npm stage publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
{
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
echo ""
echo "The exact bytes are parked on the registry. To release them:"
echo '```'
echo "npm stage list omniroute # find the stage id"
echo "npm stage approve <id> # owner 2FA — THE publish"
echo '```'
echo "To verify the staged bytes first: npm stage download <id> → run"
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
echo "To discard: npm stage reject <id>."
} >> "$GITHUB_STEP_SUMMARY"
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
- name: Publish to npm (DIRECT — emergency fallback)
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
publish-opencode-plugin:
runs-on: ubuntu-latest
permissions:
@@ -495,56 +205,13 @@ jobs:
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
uses: actions/setup-node@v6
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Auto-bump plugin version if plugin changed since last release
id: bump
working-directory: "@omniroute/opencode-plugin"
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 dir actually changed since that tag
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin/"; then
echo "⏭️ No plugin 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 dependencies
working-directory: "@omniroute/opencode-plugin"
run: npm install --no-audit --no-fund
@@ -573,92 +240,3 @@ 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}"

View File

@@ -2,15 +2,13 @@ name: opencode-plugin CI
on:
push:
branches: [main, "release/**"]
branches: [main, release/v3.8.2]
paths:
- "@omniroute/opencode-plugin/**"
- "@omniroute/opencode-plugin-v2/**"
pull_request:
branches: [main, "release/**"]
branches: [main, release/v3.8.2]
paths:
- "@omniroute/opencode-plugin/**"
- "@omniroute/opencode-plugin-v2/**"
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
@@ -37,7 +35,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -46,45 +44,23 @@ 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, test-v2]
needs: test
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Build plugin-v2 artifact
working-directory: "@omniroute/opencode-plugin-v2"
run: npm ci --no-audit --no-fund && npm run build
cache: npm
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
- run: npm install --no-audit --no-fund
- run: npm run build
- uses: actions/upload-artifact@v7
with:
name: opencode-plugin-v2-dist
path: "@omniroute/opencode-plugin-v2/dist"
name: opencode-plugin-dist
path: "@omniroute/opencode-plugin/dist"
retention-days: 7

View File

@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -51,7 +51,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm

View File

@@ -14,169 +14,11 @@ permissions:
contents: read
env:
# CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow
# test installed a fake PEM on a persistent self-hosted runner and broke all
# system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts.
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
CI_NODE_VERSION: "24"
jobs:
# Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release
# path filters share existence reasons: code / docs / i18n / workflow.
changes:
name: Change Classification
runs-on: ubuntu-latest
outputs:
code: ${{ steps.classify.outputs.code }}
docs: ${{ steps.classify.outputs.docs }}
i18n: ${{ steps.classify.outputs.i18n }}
workflow: ${{ steps.classify.outputs.workflow }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ "$EVENT_NAME" != "pull_request" ]; then
{
echo "code=true"
echo "docs=true"
echo "i18n=true"
echo "workflow=true"
} >> "$GITHUB_OUTPUT"
exit 0
fi
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
build:
name: Build (advisory)
needs: changes
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
# (#11946, 2026-08-29: build.yml is now workflow_dispatch-only — the hosted runner cannot
# build this tree in any profile, 8/8 recent fork PRs included — so own-origin PRs rely on
# ci.yml `Build` after merge to main and on nightly-release-green for release/**.)
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
# never fires here, and this is the only pre-merge build signal they get. Measured
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
# the majority of the traffic, not the exception — this job earns its place, it just should
# not duplicate build.yml for the own-origin 28%.
# Disabled 2026-08-29 (#11976 follow-up). `continue-on-error: true` still
# reports a GitHub check FAILURE, so every fork PR into release/** was born
# with a red "Build (advisory)" even when every required gate was green
# (sweep-reds, 41 PRs). Hosted ubuntu-latest cannot finish `npm run build`
# on this tree — VM shutdown ~6 min in, same class as build.yml going
# workflow_dispatch-only in #11962. Pre-merge build signal for release/**
# is nightly-release-green (omni-build); for main it is ci.yml `Build`.
# Restore this job when a runner that actually fits the tree is wired here.
# Bare `false` (not `${{ false }}`) — zizmor obfuscation flags the expression form.
if: false
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
# Measured 2026-08-14 over the last 25
# quality.yml runs: not one Build (advisory) reached a conclusion. Every sample was either
# queued on the self-hosted pool (2 runners, `omniroute-113-6/7`, both permanently busy — one
# job sat queued 2h+ and was still unclaimed) or, when it did land, killed mid-build by this
# workflow's own `cancel-in-progress` concurrency. 6/6 sampled "failures" are exit 143 /
# "The runner has received a shutdown signal" at ~3.5 min into `npm run build` — zero OOM,
# zero build errors. So the job burned a scarce runner that the gates actually need while
# reporting a permanent red on every PR.
#
# Gap 19 left USE_VPS_RUNNER governing build-like jobs on the premise that "the build needs
# the .113's RAM". That premise no longer holds: `Fast Production Build` (build.yml) runs
# `build:release` — a SUPERSET of this job's `npm run build`, plus the CLI bundle — on plain
# ubuntu-latest and passed 24/25 of its last runs in ~15 min. What it has and this job did
# not is memory PROVISIONING: a 10 GB swapfile plus a 12 GB V8 heap. That matters because
# --max-old-space-size only bounds V8's JS heap, never Turbopack's native (Rust) allocation
# (#6409) — swap is what absorbs the native peak. Both are mirrored below.
runs-on: ubuntu-latest
# #7307: advisory for the first week of release-PR runs; remove
# continue-on-error after the production-build signal is stable.
continue-on-error: true
steps:
# Mirrors build.yml: Turbopack's native peak is not bounded by --max-old-space-size, so
# the hosted runner needs swap headroom before the build starts.
- name: Expand virtual memory (10 GB swap)
run: |
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
free -h
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run build
env:
OMNIROUTE_USE_TURBOPACK: "1"
# Same heap build.yml proves sufficient. build-next-isolated.mjs defaults to 8192 and
# honours OMNIROUTE_BUILD_MEMORY_MB; NODE_OPTIONS is set for parity with build.yml.
NODE_OPTIONS: "--max-old-space-size=12288"
OMNIROUTE_BUILD_MEMORY_MB: "12288"
# No artifact upload here: the PR-to-release quality workflow has no
# downstream package/e2e jobs that consume the Next.js build output.
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
docs-gates:
name: Docs Gates (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
- run: npm run check:api-docs-refs
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
run: npm run check:docs-all
fast-gates:
name: Fast Quality Gates
needs: changes
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
# PINNED to hosted (gap 19). This job carried the USE_VPS_RUNNER expression, and that
# expression was DEAD CONFIGURATION: across 160 quality.yml runs the job never once landed on
# a self-hosted runner — every non-skipped sample is `GitHub Actions NNNN`. The classifier is
# not at fault: in the same window ci.yml's Build demonstrably ran on omniroute-113-7 and
# omniroute-113-6, so self-hosted runs are visible when they happen.
#
# And if it ever HAD fired it would have inherited the measured penalty, because this job's
# first two steps are exactly the bottleneck: actions/setup-node + npm ci took 20m06s on .113
# with 4 concurrent runners versus 16s hosted (npm cache restore saturating the link). Median
# here is 5.6 min hosted across 72 successful runs.
#
# With this pinned, USE_VPS_RUNNER governs ONLY build-like jobs — one variable, one coherent
# purpose. That is what gap 19 asked for; a second variable turned out to be unnecessary.
runs-on: ubuntu-latest
# tsx gates (known-symbols, route-guard-membership) import modules that open
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
@@ -189,436 +31,54 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
path: |
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
# cache built under a different suppressions file / lint config / lockfile reports
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
# partial cache from another configuration.
# Security scanners — same hardened install as ci.yml quality-extended
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
# gates below SKIP (exit 0) when their binary is absent — only a measured
# regression vs config/quality/quality-baseline.json blocks.
- name: Install security scanners (gitleaks/osv/actionlint/zizmor/oasdiff)
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set +e
mkdir -p "$HOME/.local/bin"
# Ratchets compare scanner COUNTS across runs. Pin every auditor: a rule-set
# update must be an explicit PR that re-measures/rebaselines, never a random
# red (or green) caused by whatever "latest" served that morning.
GITLEAKS_VERSION=v8.30.1
OSV_SCANNER_VERSION=v2.3.8
ACTIONLINT_VERSION=v1.7.12
ZIZMOR_VERSION=1.25.2
OASDIFF_VERSION=v1.19.1
# gitleaks — pinned linux x64 tarball via gh (authed), extract binary
rm -rf /tmp/gl && mkdir -p /tmp/gl
gh release download "$GITLEAKS_VERSION" --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks
# osv-scanner — pinned linux amd64 bare binary via gh (authed)
rm -rf /tmp/osv && mkdir -p /tmp/osv
gh release download "$OSV_SCANNER_VERSION" --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
# actionlint — official installer from a pinned release tag (never main)
bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" "$HOME/.local/bin"
# zizmor — pinned PyPI package (same version as ci.yml quality-extended)
pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION"
# oasdiff — pinned linux amd64 tarball via gh (authed), extract binary
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
gh release download "$OASDIFF_VERSION" --repo Tufin/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
# ALWAYS export the bin dir (even if any step above failed)
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/gitleaks" version || true
"$HOME/.local/bin/actionlint" -version || true
"$HOME/.local/bin/osv-scanner" --version || true
"$HOME/.local/bin/oasdiff" --version || true
zizmor --version || true
- name: Forgotten sibling tests (advisory)
env:
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
node scripts/quality/build-test-impact-map.mjs
node scripts/check/check-forgotten-sibling-tests.mjs \
--summary-file forgotten-sibling-tests.md \
--json-file forgotten-sibling-tests.json
cat forgotten-sibling-tests.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload forgotten sibling report
if: always()
uses: actions/upload-artifact@v7
with:
name: forgotten-sibling-tests
path: |
forgotten-sibling-tests.md
forgotten-sibling-tests.json
if-no-files-found: ignore
retention-days: 30
# Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps,
# 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation
# step. Each gate runs in a loop with ::group::; failures are collected and
# reported at the end. set -uo pipefail (NOT set -e) so one failing gate does
# not abort the job and mask every later gate. Release-added gates are folded
# in: open-sse typecheck (#8781) and file-size base-relative mode (#8522).
- name: Quality gates (all, non-fail-fast)
env:
# #8522: base-relative file-size mode on PR events — inherited drift (base
# already over frozen cap) must not red an innocent PR. Unset on
# workflow_dispatch (no PR base) → absolute comparison.
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: |
set -uo pipefail
gates=(
provider-consistency provider-asset-provenance fetch-targets deps file-size error-helper
migration-numbering public-creds db-rules known-symbols
route-guard-membership test-discovery test-runner-api
mutation-test-coverage any-budget:t11 build-scope pack-policy
complexity-ratchets model-lifecycle
cycles lockfile duplication dead-code type-coverage compression-budget
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
open-sse-typecheck
)
ratchet_gates=(
secrets vuln-ratchet workflows openapi-breaking
)
failed=()
for g in "${gates[@]}"; do
echo "::group::check:$g"
# #8522: file-size is base-relative on PR events (compare against
# max(frozen, base)) so inherited drift doesn't red an innocent PR;
# workflow_dispatch (no PR base) falls back to absolute comparison.
# New-code mode (Clean-as-You-Code, 2026-08-30): complexity-ratchets and
# dead-code compare the PR's files against the merge-base and block only on
# what the PR added; the global totals are advisory on PRs and re-frozen at
# release. See scripts/check/newCodeMode.mjs.
case "$g" in file-size|complexity-ratchets|dead-code) NEW_CODE=1 ;; *) NEW_CODE= ;; esac
if [ -n "$NEW_CODE" ] && [ -n "${PR_BASE_SHA:-}" ]; then
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
else
npm run "check:$g" || failed+=("$g")
fi
echo "::endgroup::"
done
for g in "${ratchet_gates[@]}"; do
echo "::group::check:$g (ratchet)"
npm run "check:$g" -- --ratchet || failed+=("$g")
echo "::endgroup::"
done
echo "::group::typecheck:core"
npm run typecheck:core || failed+=("typecheck:core")
echo "::endgroup::"
echo "::group::check:dashboard-typecheck"
npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck")
echo "::endgroup::"
# #10134: TS7 zero-new-diagnostics ratchet — folded into this non-fail-fast
# loop (never a separate blocking step) so an earlier red gate cannot abort
# the job and mask it (#8542 mechanism). PR-only: the base-relative
# comparison needs the PR base SHA (empty on workflow_dispatch).
if [ -n "${PR_BASE_SHA:-}" ]; then
echo "::group::check:ts7-diagnostics-ratchet"
npm run check:ts7-diagnostics-ratchet -- --base-ref "$PR_BASE_SHA" || failed+=("ts7-diagnostics-ratchet")
echo "::endgroup::"
fi
if (( ${#failed[@]} )); then
printf '::error::%d gate(s) failed: %s\n' "${#failed[@]}" "${failed[*]}"
exit 1
fi
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
# The full result stays advisory while #8484 has a backlog. The blocking
# base-relative ratchet (folded into the non-fail-fast gates step above)
# rejects only diagnostics added by the PR, so existing release debt does
# not block unrelated work.
- name: Typecheck (core) — TS7 native shadow (advisory)
continue-on-error: true
run: |
RC=0
START=$(date +%s)
npm exec --yes --package=typescript@7.0.2 -- tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
exit $RC
- run: npm ci
- run: npm run check:provider-consistency
- run: npm run check:fetch-targets
- run: npm run check:openapi-routes
- run: npm run check:docs-symbols
- run: npm run check:deps
- run: npm run check:file-size
- run: npm run check:error-helper
- run: npm run check:migration-numbering
- run: npm run check:public-creds
- run: npm run check:db-rules
- run: npm run check:known-symbols
- run: npm run check:route-guard-membership
- run: npm run check:test-discovery
- run: npm run check:any-budget:t11
- name: Typecheck (core)
run: npm run typecheck:core
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
# selector returns __RUN_ALL__ — full-suite authority is the parallel
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
#
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
# coverage remains required via `Unit Tests fast-path` (fast-unit).
- name: Impacted unit tests (TIA subset; blocking)
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
# now blocks on unit-test regressions in the impacted set (typecheck:core already
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
- name: Impacted unit tests (TIA, fail-safe full; blocking)
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: |
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
# The advisory sibling-test step generates the same map earlier in this job.
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
node scripts/quality/build-test-impact-map.mjs
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
# Shadow evidence (#8084): persist every selection so TIA false negatives can
# be measured against fast-unit's full-suite verdict across releases BEFORE
# any gate authority moves off ordinary PRs. Artifact uploaded below.
printf '%s\n' "$SEL" > tia-selection.txt
if [ -z "$SEL" ]; then
echo "TIA selection: empty (no source/test changes)" >> "$GITHUB_STEP_SUMMARY"
echo "No source/test changes — skipping unit tests"; exit 0
fi
if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
# run race-free regardless of concurrency.
if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "TIA selection: __RUN_ALL__ (fail-safe) — full-suite authority stays with fast-unit" >> "$GITHUB_STEP_SUMMARY"
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
exit 0
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
fi
echo "TIA selection: $(grep -c . tia-selection.txt) impacted test file(s) — full suite still runs in fast-unit (shadow-evidence phase, #8084)" >> "$GITHUB_STEP_SUMMARY"
echo "Running impacted tests:"; echo "$SEL"
mapfile -t FILES <<< "$SEL"
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
# under `--import tsx` (CJS transform — required for ESM-only deep imports like
# @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under
# `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
# module-shape test the impact map selects ("Unexpected token 'export'").
DASH=(); REST=()
for f in "${FILES[@]}"; do
case "$f" in
tests/unit/dashboard/*) DASH+=("$f") ;;
*) REST+=("$f") ;;
esac
done
RC=0
if [ ${#REST[@]} -gt 0 ]; then
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$?
fi
if [ ${#DASH[@]} -gt 0 ]; then
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
fi
exit $RC
# #8084 shadow evidence: keep the raw selection downloadable so TIA misses can be
# audited against fast-unit failures on the same run (gate moves need this data).
- name: Upload TIA selection (shadow evidence)
if: always()
uses: actions/upload-artifact@v7
with:
name: tia-selection
path: tia-selection.txt
if-no-files-found: ignore
retention-days: 30
fast-vitest:
name: Vitest (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
# self-hosted is strictly worse here and there is nothing to configure.
runs-on: ubuntu-latest
env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
# which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only.
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
fast-unit:
name: Unit Tests fast-path (${{ matrix.shard }}/4)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
# runner box). Node's native --test-shard=N/total takes any denominator — only
# this matrix and the TEST_SHARD env below encode the shard count.
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
# self-hosted is strictly worse here and there is nothing to configure.
runs-on: ubuntu-latest
# A shard finishes in ~10 min. Without a ceiling a hung test process holds the PR for
# GitHub's 6 h default: on 2026-08-28 shard 1/4 sat 64 min without a line of output
# (twice, same spot — a timing race, gone on the third run) while the other three
# shards were long green. 30 min = 3x the normal wall-clock; a shard that needs more
# is a hang, not a slow run, and a fast red with a re-run beats a silent 6 h hold.
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
- run: npm run test:unit:ci:shard
env:
TEST_SHARD: ${{ matrix.shard }}/4
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
# config/quality/eslint-suppressions.json congela as violações EXISTENTES por
# arquivo+regra; qualquer warning NOVO aparece e o --max-warnings 0 falha o job — o
# drift de +41/+88 warnings por ciclo passa a morrer no PR que o introduz, em vez de
# ser rebaselinado às cegas na release. Aperto do baseline (na reconciliação da
# release): npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json
#
# Princípio Zero: bloqueante SÓ para branches internas (as campanhas/sessões são a
# origem do drift). PR de FORK roda em modo report (continue-on-error → o job fica
# verde com anotação; a campanha /green-prs aplica o fix via co-autoria — o
# contribuidor NUNCA é bloqueado nem cobrado).
lint-guard:
name: No new ESLint warnings
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
# 2026-08-30: a cold full lint with the eslint-plugin-react-hooks 7 compiler rules is
# killed on the 7 GB hosted runner without a message (status null → exit 1, the
# JSON never written); the box lints it in ~12 min with the heap below.
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
# G0 (trilho .50): security-events:read lets the CodeQL ratchet below read open
# code-scanning alerts via `gh api .../code-scanning/alerts` (same as ci.yml's
# quality-gate job). contents: read keeps checkout working.
permissions:
contents: read
security-events: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
path: |
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
# cache built under a different suppressions file / lint config / lockfile reports
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
# partial cache from another configuration.
- name: ESLint (baseline congelado — warning novo = vermelho)
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
run: npm run lint:json -- --max-warnings 0
env:
NODE_OPTIONS: --max-old-space-size=8192
# ── G0 (trilho .50): motor de ratchet também no trilho B ─────────────────────
# This job just wrote .artifacts/eslint-results.json — collect-metrics prefers
# that file, so the ratchet engine lands here at ZERO extra ESLint cost (one
# inventory, two consumers; same reason ci.yml chains lint → quality-gate).
# The coverage-report artifact does not exist on this rail, so both ratchet
# invocations run --allow-missing: coverage.* metrics skip gracefully while
# the deterministic ones (eslint / openapi-coverage / i18n-ui) stay BLOCKING.
# Coverage authority remains on the main rail (ci.yml test-coverage → quality-gate).
- run: npm run quality:collect
- name: Ratchet check (blocking)
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md
- name: Require-tighten (blocking)
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten
# CodeQL alerts ratchet — same semantics as ci.yml quality-gate: exits 1 ONLY
# on a real regression (open alerts > baseline in quality-baseline.json);
# a measurement failure (gh/auth/api) self-skips with exit 0.
- name: CodeQL alerts ratchet (blocking)
run: npm run check:codeql-ratchet
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Append ratchet summary
if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" || true
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
# bullets vizinhos/seções inteiras (incidente #6193, 2026-07-05: 212 linhas /
# 130 bullets); o checkout de PR é refs/pull/N/merge, então comparar contra a
# base detecta o eat ANTES do merge. (2) SKILL.md gerado stale vs o catálogo de
# agent-skills (#6186 mergeou um id de catálogo sem rodar o gerador → 8 reds de
# integration invisíveis até a release).
#
# Princípio Zero: bloqueante SÓ para branches internas; PR de FORK roda em modo
# report (continue-on-error) — a campanha corrige via co-autoria, o contribuidor
# nunca é bloqueado.
merge-integrity:
name: Merge integrity (changelog + generated skills)
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
run: npm run check:changelog-integrity
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)
run: npm run check:agent-skills-sync
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}"

View File

@@ -1,64 +0,0 @@
# Publica o export estável do catálogo consumido pelo OmniRoute Radar numa URL
# fixa (asset de release `radar-export-latest`), para o servidor privado do Radar
# (1 GB RAM, nunca clona/builda o OmniRoute) baixá-lo via `RADAR_EXPORT_URL` em
# vez de depender do snapshot gravado no deploy. Fonte: scripts/release/radar-export.mjs.
#
# A URL estável resultante (definir em RADAR_EXPORT_URL no .env do radar-server):
# https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json
name: Radar Export
on:
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
push:
branches: [main] # produção: só o catálogo do main clobra o asset estável
paths:
- open-sse/config/freeModelCatalog.data.ts
- open-sse/config/freeModelCatalog.ts
- open-sse/config/providerRegistry.ts
- open-sse/config/providers/**
- scripts/release/radar-export.mjs
- .github/workflows/radar-export.yml
schedule:
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos
permissions:
contents: read
concurrency:
group: radar-export-${{ github.ref }}
cancel-in-progress: true
env:
CI_NODE_VERSION: "24"
jobs:
publish-export:
runs-on: ubuntu-latest
permissions:
contents: write # gh release upload — clobra o asset estável do export
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false # publish usa GH_TOKEN via gh release, não a credencial do checkout
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: Generate catalog export with provenance
run: node --import tsx/esm scripts/release/radar-export.mjs "$RUNNER_TEMP/export-omniroute.json"
- name: Publish to the stable release asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="radar-export-latest"
# Cria o release estável na primeira vez; nas seguintes só re-anexa o asset.
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "Radar catalog export (rolling)" \
--notes "Export estável do catálogo OmniRoute para o Radar. Atualizado automaticamente; NÃO é um release de versão do produto." \
--latest=false
fi
gh release upload "$TAG" "$RUNNER_TEMP/export-omniroute.json" --repo "$GITHUB_REPOSITORY" --clobber

View File

@@ -4,15 +4,12 @@ on:
schedule:
- cron: "27 7 * * 1"
push:
# Scorecard only accepts the DEFAULT branch — here the active release/vX.Y.Z,
# not `main`. The job below guards on it so a push to any other branch skips.
branches: ["main", "release/**"]
branches: ["main"]
permissions: read-all
jobs:
analysis:
if: ${{ github.event_name != 'push' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
@@ -29,7 +26,7 @@ jobs:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@v2.4.4
uses: ossf/scorecard-action@v2.4.3
with:
results_file: results.sarif
results_format: sarif

View File

@@ -6,19 +6,13 @@ on:
branches: ["main"]
permissions:
contents: read
# Cancel superseded PR runs (same rule as quality.yml). No paths filter on purpose:
# p/secrets must keep scanning docs-only diffs too — credentials leak in .md files.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
semgrep:
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run semgrep (advisory)

View File

@@ -9,7 +9,7 @@ name: Wiki Sync
# It does NOT overwrite existing wiki pages by default: several docs sources still carry
# stale counts (e.g. ARCHITECTURE.md says "177 providers" while the wiki cover is 226),
# so blind overwrite would regress the wiki. Full content parity (--update-existing) is
# gated on regenerating those sources first.
# gated on regenerating those sources — see docs/ops/DOCUMENTATION_AUDIT_REPORT.md.
on:
push:
@@ -40,7 +40,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: "24"

87
.gitignore vendored
View File

@@ -1,8 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# project-specific directories
/output/
.slim/deepwork/
.omnivscodeagent/
omnirouteCloud/
omnirouteSite/
@@ -14,13 +12,12 @@ _tasks/
.agents/**
.claude/**
.gemini/**
.code-forge/**
.config/**
.data/**
.logs/**
.tests/**
.coverage/**
/coverage/
coverage/
.dist/**
.next/**
.build/**
@@ -46,7 +43,6 @@ memory-bank/
# Root-level underscore-prefixed directories (private/draft — never commit)
/_*/
/_*
# Draft features documentation (internal only)
docs/new-features/
@@ -60,6 +56,10 @@ node_modules/
*.map
.DS_Store
# Obsidian sync plugin — committed for community distribution
!obsidian-plugin/
obsidian-plugin/node_modules/
# Serena AI assistant config (local-only tool, not project code)
.serena/
@@ -71,11 +71,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# Local gitleaks artifacts (do not commit)
gitleaks-local.json
!.env.example
!.env.homolog.example
!.env.devin-bridge.example
# Provider API keys (never commit)
*.api-key
.nvidia-api-key
@@ -88,7 +84,7 @@ gitleaks-local.json
next-env.d.ts
# data and logs
/data/
data/
.data/
logs/*
test_output.log
@@ -110,7 +106,7 @@ open-sse/test/*
test-results/
playwright-report/
blob-report/
/cloud/
cloud/
.tmp/
# Security Analysis (standalone project with own git)
@@ -124,8 +120,6 @@ app.log
deploy.sh
docker-compose.minimal.yml
# Docker Compose override (local-only, never commit)
docker-compose.override.yml
# Backup directories
app.__qa_backup/
@@ -161,15 +155,12 @@ vscode-extension/
# Empty/dangling files
typescript
/MAX
# Gemini Antigravity agent data
.gemini/
# Superpowers plans/specs (internal tooling, not project code)
docs/superpowers/
# Superpowers visual-companion brainstorm mockups (ephemeral)
.superpowers/
# TIA test-impact map — generated at runtime in CI (build-test-impact-map.mjs), never committed (~21MB)
config/quality/test-impact-map.json
@@ -177,6 +168,7 @@ config/quality/test-impact-map.json
# GitNexus local index
.gitnexus
.worktrees
bin/omniroute.mjs
# Consistent with .dockerignore / .npmignore
.omc/
@@ -205,16 +197,13 @@ scripts/i18n/_pending-keys.json
.claude/worktrees/
.codegraph/
# Test executable shims belong in the OS temporary directory, not the repository root
/.fakebin-*/
# Fumadocs generated source
.source/
# AI agent local settings and configs
.agents/
.antigravitycli/
/.claude/
.claude/
# PR Reviews and local feedback files
pr_reviews*.json
@@ -229,26 +218,6 @@ CODEX-SETUP-PROMPT.md
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
config/quality/quality-metrics.json
# Electron desktop build output unpacked into the repo root.
# `electron-builder` (squirrel-windows target) unpacks the packaged app — the
# entire Chromium runtime, ~24k files — directly into the repository root.
# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/`
# or `resources/` would also swallow tracked sources such as the CLI
# translations in `bin/cli/locales/*.json`.
/OmniRoute.exe
/Uninstall OmniRoute.exe
/uninstallerIcon.ico
/locales/
/resources/
/*.pak
/*.dll
/icudtl.dat
/snapshot_blob.bin
/v8_context_snapshot.bin
/vk_swiftshader_icd.json
/LICENSE.electron.txt
/LICENSES.chromium.html
# Runtime logs (diretório local, nunca versionado)
/logs/
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
@@ -258,41 +227,3 @@ docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md
omniroute.md
# mise configuration
mise.toml
_artifacts/ # release-green artifacts
.claude-flow/
# ESLint file cache (npm run lint --cache / complexity ratchets)
.eslintcache
.eslintcache-complexity
/.eslintcache-*
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
.artifacts/
/perf-audit*.md
/quality-ratchet/
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
.env.homolog
tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak
# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
/_tasks
# CLI local cache/state
.playwright-cli
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
/.sandbox/
.aider*
# check:install-upgrade work trees (~12 GB, disposable)
/.install-upgrade/

View File

@@ -74,27 +74,3 @@
# '''tests/unit/''',
# ]
#
[[rules]]
# Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3,
# plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO
# de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API
# da Anthropic (documentado publicamente, não é segredo).
id = "generic-api-key"
[rules.allowlist]
description = "Field names + public Anthropic beta-header value (não são segredos)"
regexes = [
'''latencyP\d{2}Ms''',
'''interleaved-thinking-2025-05-14''',
# v3.8.49 pre-flight (2026-07-28). Nenhum dos dois e credencial:
# - chave de localStorage do banner de patrocinio (#8723; #10200 bumpou v1->v2,
# generalizado para -v\d+ no round 3 de base-reds #9985), so um identificador de UI;
# - x-api-key PUBLICO do Firefly web (documentado em open-sse/utils/publicCreds.ts:207);
# as duas ocorrencias sinalizadas estao em COMENTARIOS JSDoc, o runtime le de resolvePublicCred().
'''omniroute-kimi-sponsor-banner-dismissed-v\d+''',
# CheaperInference sponsor banner localStorage key (upstream #11196 /
# eb5797370). Same UI-identifier pattern as the kimi banner above, not a
# credential; the generic-api-key rule flags the long hyphenated string.
'''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''',
'''SunbreakWebUI1''',
]

View File

@@ -7,7 +7,6 @@ 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

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env sh
# .husky/pre-push — intentionally light.
# any-budget + tracked-artifacts already run on pre-commit; re-running them on
# every push only doubles local wall time for the same existence reason (CI still
# enforces both). Keep this hook as a PATH/npm sanity check + reminder.
# Intentionally excludes test:unit / typecheck (slow; covered by CI).
# .husky/pre-push — fast deterministic gates (<10s total)
# Intentionally excludes test:unit (slow; covered by CI pre-push remote run).
# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub)
if ! command -v npm >/dev/null 2>&1; then
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
@@ -11,5 +9,4 @@ if ! command -v npm >/dev/null 2>&1; then
exit 0
fi
# No-op success: real local gates live in pre-commit; CI owns the rest.
exit 0
npm run check:any-budget:t11 && npm run check:tracked-artifacts

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
# .mailmap — canonical author identities for git log/shortlog/blame.
#
# Why this file exists: between 2026-08-13 and 2026-08-26 this checkout carried a
# `git config --local` whose user.name was one contributor's ("Xiangzhe" / @xz-dev)
# and whose user.email was ANOTHER contributor's (@backryun). Every commit produced
# on this machine in that window was therefore signed with @backryun's address —
# 237 commits, all in the -0300 timezone, while @backryun's own work commits from
# +0900 and continued normally throughout. The local override was removed on
# 2026-08-26; this file repairs the RECORD without rewriting published history
# (those commits live on release/v3.8.50 and release/v3.8.51, which other sessions
# and open PRs build on — a rewrite would force-push both and orphan the v3.8.50 tag).
#
# Format: Canonical Name <canonical@email> Commit Name <commit@email>
# --- Maintainer: several addresses used over the project's life ---
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@gmail.com>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@outlook.com>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@users.noreply.github.com>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Souza <8016841+diegosouzapw@users.noreply.github.com>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza.pw@gmail.com>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <souzamiriamrodrigues790@gmail.com>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza@cdwasolutions.com.br>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@devbox.local>
# --- The misattribution window: name Xiangzhe + @backryun's email, from -0300.
# These are maintainer/session commits, NOT @backryun's contributions.
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <bakryun0718@proton.me>
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <diegosouza.pw@gmail.com>
# --- Xiangzhe (@xz-dev) — a distinct contributor; keep their own work intact ---
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xz-dev@users.noreply.github.com>
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xiangzhedev@gmail.com>
# --- @backryun's own alternate addresses (their real work, kept intact) ---
backryun <24198422+backryun@users.noreply.github.com> <bakryun0718@proton.me>
backryun <24198422+backryun@users.noreply.github.com> <backryun@daonlab.local>
backryun <24198422+backryun@users.noreply.github.com> <busan011@ormbiz.co.kr>
backryun <24198422+backryun@users.noreply.github.com> <backryun@users.noreply.github.com>

17
.mcp.json.example Normal file
View File

@@ -0,0 +1,17 @@
{
"$comment_purpose": "OPT-IN agent-lsp / LSP-in-the-loop (Quality Gates Fase 7 Task 15). Copy this file to `.mcp.json` to enable. It exposes a TypeScript language server to coding agents (Claude Code, etc.) so they get diagnostics / hover / go-to-definition / blast-radius BEFORE writing code — turning 'invented symbol' review-catches into impossible-at-edit-time. Pairs with `npm run typecheck:core` as a compile-before-claim check.",
"$comment_safety": "Shipped as `.example` (NOT `.mcp.json`) on purpose so it never auto-loads an unvetted server into everyone's session. Pick an MCP<->LSP bridge you trust and have verified locally, then drop in its package + args below. A broken MCP entry only logs a connection error; it does not break agent sessions. The underlying language server is `typescript-language-server` (npm, mature) — install via `npm i -g typescript-language-server typescript` or rely on npx.",
"mcpServers": {
"typescript-lsp": {
"command": "npx",
"args": [
"-y",
"<your-mcp-lsp-bridge>",
"--lsp",
"typescript-language-server",
"--stdio"
],
"$note": "Replace <your-mcp-lsp-bridge> with the concrete MCP<->LSP adapter you chose. It must speak MCP on stdio and proxy to `typescript-language-server --stdio`. Scope it to this repo's tsconfig (open-sse/tsconfig.json / tsconfig.json) for accurate diagnostics."
}
}
}

View File

@@ -1,74 +0,0 @@
# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan.
#
# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE
# identity. The manual merge-train validated batches by hand; this queue automates
# it with batching + automatic batch bisection (a red batch of N costs ~log2(N)
# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo.
#
# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's
# pre-merge ⭐ gate):
# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a
# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label
# IS the merge approval; Mergify only executes it.
# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs
# targeting the frozen branch — the freeze is a human-honored coordination signal
# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21).
# • Never label a PR another session is actively working (Hard Rule #22b).
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
# merge_protections_settings — the rules-based queue action / autoqueue path is
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
merge_protections_settings:
auto_merge_conditions:
- label = queue
queue_rules:
- name: release
# Any current or future release branch — the reason GitHub's native queue was
# rejected (no wildcard support on personal-account repos).
queue_conditions:
- base~=^release/v\d+\.\d+\.\d+$
- label=queue
- -draft
- -conflict
# "Everything that ran is green, nothing still running, AND the always-on
# anchor check succeeded" — robust to the path-filtered fast-gates (docs-only
# PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with
# zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY
# non-draft PR (quality.yml) and must be an affirmative success. Review approval
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
# approval in this repo's single-maintainer model (see governance header).
merge_conditions:
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
# continue-on-error by design, and its GH-hosted Turbopack build hangs
# recurrently mid-"Creating an optimized production build" (100% failure rate
# across every sampled PR since the job was added 2026-07-27, always killed by
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
# carrying its tolerance forward would mask problems it no longer causes.
- or:
- "#check-failure=0"
- and:
- "#check-failure=1"
- check-failure=Build (advisory)
- "#check-pending=0"
- "#check-success>=1"
- check-success=Merge integrity (changelog + generated skills)
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
# the free plan). Serial queue (1 PR at a time) still automates the train.
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
merge_method: squash
pull_request_rules:
- name: clean up the queue label after merge
conditions:
- merged
actions:
label:
remove:
- queue

View File

@@ -4,14 +4,11 @@ data/
**/db.json
# VS Code extension test runtime (large binary, not needed in npm package)
app/vscode-extension/
**/data/
**/db.json
# Source code (pre-built dist/ is published instead)
#
# NOTA (2026-08-05): as entradas `app/*` foram removidas — o diretorio `app/`
# foi renomeado para `dist/` na Layer 1 e nao existe mais. Elas sugeriam um
# layout que ja nao e o do projeto.
# Source code (pre-built app/ is published instead)
#
# NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what
# ships. It now allowlists the backend source closure the MCP server needs at runtime
@@ -52,6 +49,8 @@ scripts/
.vscode/
.agents/
.env*
app/.env
app/.env*
eslint.config.mjs
prettier.config.mjs
postcss.config.mjs
@@ -83,6 +82,8 @@ bun.lock
*.deb
*.rpm
electron/
app/electron/
app/vscode-extension/
# Subprojects
clipr/
@@ -92,12 +93,12 @@ vscode-extension/
# Root-level underscore-prefixed directories (private/draft — never publish)
/_*/
app/_*/
app/coverage/
app/logs/
app/tests/
# Consistent with .gitignore and .dockerignore
.claude/
.fakebin-*
.eslintcache*
_tasks/
.DS_Store
.idea/
.config/

10
.npmrc
View File

@@ -2,13 +2,3 @@
# Keeping peer auto-install disabled prevents npm from pulling @lobehub/ui/mermaid
# back into the tree and reopening npm audit findings for unused packages.
legacy-peer-deps=true
# Network resilience: enlarge npm's fetch retry budget so a transient registry
# socket reset (ECONNRESET) mid-download retries instead of failing the job.
# npm defaults to only 2 retries with short timeouts; `npm ci` in
# electron-release.yml hit ECONNRESET during v3.8.41 publish. Applies to every
# CI workflow (electron / docker / unit) and local installs.
fetch-retries=5
fetch-retry-factor=4
fetch-retry-mintimeout=20000
fetch-retry-maxtimeout=120000

View File

@@ -1,24 +0,0 @@
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
docs/reference/ENVIRONMENT.md
# Generated by `npm run gen:provider-reference`; the generator aligns the tables and
# is their formatter of record. Without this, lint-staged reformats the file whenever
# it is staged and the next generator run reverts it — a diff ping-pong.
docs/reference/PROVIDER_REFERENCE.md
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
open-sse/config/freeModelCatalog.data.ts
# Generated by scripts/skills/generate-agent-skills.mjs; the generator is their
# formatter of record and check:agent-skills-sync diffs its output byte-for-byte.
# Prettier reformats the frontmatter (blank line after ---), which makes the gate
# fail on any skill that happens to pass through lint-staged.
skills/*/SKILL.md
# check:changelog-integrity compares release bullets against the base as exact
# strings. Prettier normalizes markdown emphasis inside them (*from* -> _from_)
# and re-wraps table rows, so any PR that stages CHANGELOG.md would "lose" base
# bullets and turn the merge-integrity job red. The changelog is generated and
# reconciled by scripts/release/*, which are its formatter of record.
CHANGELOG.md
docs/i18n/*/CHANGELOG.md

8
.source/dynamic.ts Normal file
View File

@@ -0,0 +1,8 @@
// @ts-nocheck
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from '../source.config';
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
DocData: {
}
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});

22
.source/source.config.mjs Normal file
View File

@@ -0,0 +1,22 @@
// source.config.ts
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
var docs = defineDocs({
dir: "docs",
docs: {
files: [
"./architecture/**/*.md",
"./guides/**/*.md",
"./reference/**/*.md",
"./frameworks/**/*.md",
"./routing/**/*.md",
"./security/**/*.md",
"./compression/**/*.md",
"./ops/**/*.md"
]
}
});
var source_config_default = defineConfig();
export {
source_config_default as default,
docs
};

View File

@@ -1,20 +0,0 @@
# .trivyignore — accepted-risk suppressions for the container image scan
#
# Policy (see docs/security/SUPPLY_CHAIN.md):
# - The Trivy steps in .github/workflows/docker-publish.yml run with
# `ignore-unfixed: true`, so vulnerabilities WITHOUT a published fix are
# already excluded from both the blocking CRITICAL gate and the advisory
# Security-tab upload. You do NOT need an entry here for an unfixable
# base-image OS CVE — it will not be reported.
# - This file is the single auditable home for the rare case where a *fixable*
# CVE must be temporarily accepted (e.g. the upstream fix is not yet in the
# pinned base tag, or the affected package/binary is provably unreachable
# from the proxy request surface and rebuilding now is not justified).
#
# Format — one CVE id per line, each with a justification comment and, where
# possible, an expiry, e.g.:
# # CVE-XXXX-YYYY — <why accepted>; revisit on next base-image bump (YYYY-MM-DD)
# CVE-XXXX-YYYY
#
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
# patched base / bump the dep) over suppressing. Stale entries are debt.

10
.vscode/settings.json vendored
View File

@@ -48,19 +48,11 @@
"**/.build",
"**/dist",
"**/coverage",
"**/.worktrees",
"**/.claude/worktrees",
"**/electron",
"**/_references",
"**/_mono_repo",
"**/_tasks"
"**/.worktrees"
]
},
// Para esconder os diretórios gerados da árvore do Explorer, descomente:
// (MANTIDO comentado — o dono precisa ver _references/_mono_repo/_tasks na árvore.
// A performance é resolvida por watcherExclude + search.exclude + tsserver, sem
// precisar escondê-los do Explorer.)
// "files.exclude": {
// "**/.worktrees": true,
// "**/coverage": true,

View File

@@ -1,4 +0,0 @@
node_modules
dist
*.log
.DS_Store

View File

@@ -1,21 +0,0 @@
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.

View File

@@ -1,107 +0,0 @@
# @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

View File

@@ -1,9 +0,0 @@
# 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/...`.

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +0,0 @@
{
"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"
}
}

View File

@@ -1,211 +0,0 @@
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;
}
}

View File

@@ -1,798 +0,0 @@
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 };
}

View File

@@ -1,66 +0,0 @@
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";
}

View File

@@ -1,101 +0,0 @@
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}.`
);
}

View File

@@ -1,41 +0,0 @@
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}`
);
};
}

View File

@@ -1,43 +0,0 @@
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;
}

View File

@@ -1,539 +0,0 @@
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)}`
);
}
}
},
});

View File

@@ -1,117 +0,0 @@
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;
}

View File

@@ -1,219 +0,0 @@
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: "",
};
}

View File

@@ -1,254 +0,0 @@
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 (0100, 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 ?? "",
};
}

View File

@@ -1,606 +0,0 @@
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;
}

View File

@@ -1,127 +0,0 @@
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");
}

View File

@@ -1,166 +0,0 @@
/**
* 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;
}

View File

@@ -1,9 +0,0 @@
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";

View File

@@ -1,81 +0,0 @@
/**
* 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);

View File

@@ -1,323 +0,0 @@
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),
};
}

View File

@@ -1,295 +0,0 @@
/**
* 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;
}

View File

@@ -1,171 +0,0 @@
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;
}

View File

@@ -1,93 +0,0 @@
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");
});
});

View File

@@ -1,196 +0,0 @@
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)}`
);
});
});

View File

@@ -1,378 +0,0 @@
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;
}
});
});

View File

@@ -1,330 +0,0 @@
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"/);
});
});

View File

@@ -1,34 +0,0 @@
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()));
});
});

View File

@@ -1,128 +0,0 @@
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/);
});
});

View File

@@ -1,59 +0,0 @@
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");
});
});

View File

@@ -1,64 +0,0 @@
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);
});
});

View File

@@ -1,106 +0,0 @@
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")));
});
});

View File

@@ -1,124 +0,0 @@
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);
});
});

View File

@@ -1,68 +0,0 @@
{
"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" }
]
}
]
}

View File

@@ -1,301 +0,0 @@
{
"_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": ""
}
}
}

View File

@@ -1,226 +0,0 @@
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);
});
});

View File

@@ -1,164 +0,0 @@
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);
});
});

View File

@@ -1,244 +0,0 @@
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;
}
});
});

View File

@@ -1,233 +0,0 @@
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();
}
});
});

View File

@@ -1,236 +0,0 @@
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();
}
});
});

View File

@@ -1,129 +0,0 @@
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
);
}
});
});

View File

@@ -1,224 +0,0 @@
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);

View File

@@ -1,137 +0,0 @@
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();
}
});
});

View File

@@ -1,214 +0,0 @@
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();
}
});
});

View File

@@ -1,165 +0,0 @@
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")));
});
});

View File

@@ -1,196 +0,0 @@
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",
});
});
});

View File

@@ -1,77 +0,0 @@
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",
});
});
});

View File

@@ -1,47 +0,0 @@
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"));
});
});

View File

@@ -1,125 +0,0 @@
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);
});
});

View File

@@ -1,56 +0,0 @@
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, []);
});
});

Some files were not shown because too many files have changed in this diff Show More