Compare commits

..

24 Commits

Author SHA1 Message Date
diegosouzapw
3c328d6a62 fix(ci): pack-artifact policy + maxBuffer fixes for npm publish
Cherry-pick of #3622 + #3624: aligns PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES
with the v3.8.21 package.json files expansion, and increases execFileSync
maxBuffer to 64 MB to avoid ENOBUFS on npm pack --dry-run --json.
2026-06-11 05:48:06 -03:00
diegosouzapw
7e9e4ab489 chore(release): v3.8.21 CHANGELOG + i18n + env-doc sync 2026-06-11 03:51:14 -03:00
Diego Rodrigues de Sa e Souza
c2d90229ca fix(review): resolve findings from /review-reviews battery (v3.8.21 hardening) (#3618)
Pre-release hardening from the /review-reviews battery — 15 findings resolved (L1-L13,L15) + L14 live-verified WONTFIX, convergence re-review clean. lint/typecheck:core/test:vitest(146)/build green; zero new test:unit failures vs baseline 797de433f.
2026-06-11 02:40:33 -03:00
diegosouzapw
797de433f2 docs(changelog): credit #3603 (Flash tier IDs) + #3604 (provider quotas) + #3605 (reasoning wrappers)
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-06-11 00:34:44 -03:00
Diego Rodrigues de Sa e Souza
2561beaeb5 feat(cli): add autostart on/off/toggle shorthand for headless serve mode (#3331) (#3614)
Integrated into release/v3.8.21 (#3331)
2026-06-11 00:34:01 -03:00
Raxxoor
68b71bc826 fix(usage): normalize Antigravity and agy provider quotas (#3604)
Split-out PR B from #3584. Normalizes Antigravity/agy provider quotas: prefers retrieveUserQuota for live consumption, falls back to fetchAvailableModels and local usage_history, sanitizes cached Provider Limits so retired upstream IDs are not re-exposed, and schedules a deduplicated post-usage refresh. Maintainer follow-up: decoupled the post-usage refresh via a lightweight usageEvents bus (usageHistory no longer dynamic-imports providerLimits) so it does not pull the executors/translator graph into the typecheck-core surface — typecheck:core stays at 0. Integrated into release/v3.8.21. Thanks @dhaern!
2026-06-11 00:32:51 -03:00
Diego Rodrigues de Sa e Souza
5c11d1c92e docs(opencode-provider): soft-deprecate in favor of @omniroute/opencode-plugin (#3419) (#3613)
Integrated into release/v3.8.21 (#3419)
2026-06-11 00:28:33 -03:00
Diego Rodrigues de Sa e Souza
fcf0f68344 fix(oauth): surface real Kiro import-token failure cause, not a bare 500 (#3589) (#3609)
Integrated into release/v3.8.21 (#3589)
2026-06-11 00:26:09 -03:00
Diego Rodrigues de Sa e Souza
625fae813f fix(agent-bridge): surface real MITM startup-failure cause, not always port 443 (#3606) (#3608)
Integrated into release/v3.8.21 (#3606)
2026-06-11 00:22:07 -03:00
Raxxoor
c6d92058f3 fix(antigravity): normalize Gemini 3.5 Flash tier IDs (#3603)
Split-out PR A from #3584. Normalizes the Antigravity/agy Gemini 3.5 Flash tier IDs to clean public names (gemini-3.5-flash-low/medium/high), maps them to the live upstream IDs at the executor boundary, and removes Antigravity from the global model resolver so the executor owns wire normalization. Maintainer follow-up: kept gemini-3.5-flash-preview as a hidden backward-compat alias routing to the High tier (so saved combos/configs keep working). Live-validated the tier set via the agy CLI catalog. Integrated into release/v3.8.21. Thanks @dhaern!
2026-06-11 00:06:15 -03:00
Raxxoor
e4675924cb fix(gemini): isolate textual reasoning wrappers (#3605)
Split-out PR C from #3584. Isolates textual reasoning wrappers (<think>/<thinking>/<thought>/<internal_thought>, including malformed/open tags) into reasoning_content across both the non-streaming sanitizer and the Gemini streaming translator, with split-chunk buffering. Additive to the existing textual tool-call pipeline; does not touch the #3569 native functionResponse path. Integrated into release/v3.8.21. Thanks @dhaern!
2026-06-11 00:01:31 -03:00
Diego Rodrigues de Sa e Souza
43c312abc9 fix(api): implement GET /api/guardrails + POST /api/guardrails/test, drop shadow/guardrails doc-fiction (#3496) (#3602)
Integrated into release/v3.8.21 — implements GET /api/guardrails + POST /api/guardrails/test, removes shadow/guardrails doc-fiction. TDD-validated (5/5) + check-docs-symbols/typecheck/eslint green.
2026-06-10 22:02:50 -03:00
diegosouzapw
39feb1d22a docs(changelog): credit #3598 (chatCore modularization) + #3588 (combo reasoning buffer)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:42:59 -03:00
Paijo
07b190d07f Refactor: Extract chatCore phases into modular files (#3598)
Integrated into release/v3.8.21 — chatCore phase modularization. Adjusted: re-derive idempotencyKey for the save path after the check moved into the module (co-authored). Thanks @oyi77!
2026-06-10 21:40:49 -03:00
Hernan Javier Ardila Sanchez
987c8f8694 fix: add reasoning token buffer for combo routing (fixes #3587) (#3588)
Integrated into release/v3.8.21
2026-06-10 21:30:20 -03:00
diegosouzapw
017e85ed4d fix(mcp): ship MCP server source closure in npm files + coverage gate (#3578) 2026-06-10 19:21:41 -03:00
diegosouzapw
1b83d97dd3 security(oauth): migrate 5 public client_ids to resolvePublicCred (#3493) 2026-06-10 19:21:33 -03:00
diegosouzapw
f06438feae fix(gamification): add level/badges/badges-earned profile routes (#3484) 2026-06-10 19:21:16 -03:00
Diego Rodrigues de Sa e Souza
9ef4a61e9a fix(agent-bridge): add validate-only upstream-ca/test route (#3488) (#3600) 2026-06-10 18:16:57 -03:00
Diego Rodrigues de Sa e Souza
60d2f05d0e docs: mark DISCOVERY_TOOL_DESIGN endpoints as Phase-2 not-yet-implemented (#3498) (#3599) 2026-06-10 18:13:01 -03:00
Diego Rodrigues de Sa e Souza
fde956aa2b fix: z.ai/GLM coding plan no longer shows Monthly 0% when no monthly cap (#3580) (#3597) 2026-06-10 18:09:46 -03:00
Diego Rodrigues de Sa e Souza
cb08cf221d fix: /v1/completions returns legacy text-completion format, not chat (#3571) (#3596) 2026-06-10 18:06:17 -03:00
Diego Rodrigues de Sa e Souza
1bc858837a fix: pass through valid max_tokens-truncated responses instead of fake 502 (#3572) (#3595) 2026-06-10 18:01:52 -03:00
diegosouzapw
2947bfd5eb chore(release): open v3.8.21 development cycle 2026-06-10 17:20:48 -03:00
12088 changed files with 308682 additions and 2051026 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

5
.github/FUNDING.yml vendored
View File

@@ -1,5 +0,0 @@
# Funding links for OmniRoute — rendered as the "Sponsor" button on GitHub.
# Docs: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: diegosouzapw
# Additional platforms (uncomment and fill in before enabling):
# custom: ["https://omniroute.online/donate"]

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,6 @@ 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).
- 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

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

@@ -0,0 +1,65 @@
name: Publish Fork Image to GHCR
on:
push:
branches: [main]
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
packages: write
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
steps:
- name: Checkout
uses: actions/checkout@v6
- 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

1217
.github/workflows/ci.yml vendored

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,6 @@ on:
pull_request_review:
types: [submitted]
# Least-privilege default: no token permissions at the top level; the `claude` job
# grants exactly what it needs below (Scorecard TokenPermissions).
permissions: {}
jobs:
claude:
if: |
@@ -30,9 +26,8 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude Code

View File

@@ -1,31 +0,0 @@
name: CodeQL
# OWNER ACTION REQUIRED before enabling auto-triggers: advanced CodeQL conflicts with
# GitHub "default setup" — the analyze step fails with "CodeQL analyses from advanced
# configurations cannot be processed when the default setup is enabled". Switch repo
# Settings → Code security → CodeQL from Default to Advanced, THEN restore the
# push/pull_request/schedule triggers below. Until then this only runs on manual dispatch
# so it never produces a red check on PRs. (The codeqlAlerts ratchet keeps working via the
# default setup's alerts in the meantime.)
on:
workflow_dispatch:
permissions:
contents: read
jobs:
analyze:
name: Analyze (javascript-typescript)
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:javascript-typescript"

View File

@@ -1,98 +0,0 @@
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"
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
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
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- 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
for _ in $(seq 1 30); do
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
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 \
--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
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: dast-smoke-logs
path: server.log
retention-days: 7

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- "release/v*"
tags:
- "v*"
paths-ignore:
@@ -26,18 +25,9 @@ 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:
contents: read
packages: write
jobs:
prepare:
@@ -51,9 +41,8 @@ jobs:
IMAGE_NAME: diegosouzapw/omniroute
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
# Need full tag history for semver comparison when deciding :latest.
fetch-depth: 0
@@ -66,30 +55,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 +96,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,59 +122,38 @@ 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' }}
permissions:
contents: read
packages: write
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
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
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
persist-credentials: false
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 +166,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 +183,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 +197,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 +219,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:
@@ -344,10 +226,6 @@ jobs:
- build
if: needs.prepare.outputs.skip != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
env:
IMAGE_NAME: diegosouzapw/omniroute
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
@@ -355,9 +233,8 @@ jobs:
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
fetch-depth: 0
@@ -365,13 +242,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 +268,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 +283,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 +291,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 +307,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,81 +315,15 @@ 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'
run: |
docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}"
- name: Generate CycloneDX SBOM (image, advisory)
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: anchore/sbom-action@v0
with:
image: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
format: cyclonedx-json
output-file: sbom-image.cdx.json
artifact-name: sbom-image.cdx.json
# 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
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
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
# published image. Narrowed to severity CRITICAL (HIGH stays visible in the
# SARIF step above, not blocking). ignore-unfixed:true so an unfixable base-image
# CVE with no upstream patch does not red the release (reduces false-blocks);
# a fixable CRITICAL still blocks. Per docs/security/SUPPLY_CHAIN.md. NB: Trivy
# scans against a CVE DB that grows continuously — a newly-disclosed CRITICAL on
# an unchanged base image can red this gate; the fix is to rebuild on a patched
# base, bump the dep, or add a justified .trivyignore entry (see the CVE-variance
# note in docs/security/SUPPLY_CHAIN.md).
- name: Trivy CRITICAL gate (blocking)
if: needs.prepare.outputs.version != 'main'
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
format: table
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
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,141 +4,50 @@ 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
# to the reusable workflow) — Scorecard TokenPermissions.
permissions:
contents: read
contents: write
id-token: write
packages: write
jobs:
validate:
name: Validate version
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.validate.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- name: Validate version format
id: validate
env:
# Pass workflow context via env (never interpolate ${{ ... }} straight
# into the run: script body) so the shell receives variables, not
# inlined text — zizmor template-injection mitigation. INPUT_VERSION is
# the operator-supplied value and is regex-validated below before use.
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [[ "$EVENT_NAME" == "push" ]]; then
if [[ "${{ github.event_name }}" == "push" ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
else
VERSION="$INPUT_VERSION"
VERSION="${{ inputs.version }}"
fi
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid version format. Expected: v1.6.8"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
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
strategy:
fail-fast: false
matrix:
@@ -147,62 +56,41 @@ 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 }}
- uses: actions/checkout@v6
- 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
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
@@ -211,63 +99,18 @@ jobs:
# that cause EPERM errors during Next.js standalone build glob scans.
# Create a clean temp profile directory to avoid this.
mkdir -p "$RUNNER_TEMP/home"
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
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:
# Pass the validated version via env (never interpolate ${{ ... }}
# straight into the run: script body) — zizmor template-injection
# mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in
# the `validate` job, so it cannot carry shell metacharacters.
VERSION: ${{ needs.validate.outputs.version }}
run: |
VERSION="${{ needs.validate.outputs.version }}"
VERSION_NO_V="${VERSION#v}"
node -e "
const fs = require('fs');
@@ -283,7 +126,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 +153,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 +181,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,71 +191,28 @@ 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
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
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:
# Pass the validated version via env (never interpolate ${{ ... }}
# straight into the run: script body) — zizmor template-injection
# mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in
# the `validate` job, so it cannot carry shell metacharacters.
VERSION: ${{ needs.validate.outputs.version }}
run: |
# Create source code archives (excluding dev dependencies and build artifacts)
export TARBALL="OmniRoute-${VERSION}.source.tar.gz"
export ZIPBALL="OmniRoute-${VERSION}.source.zip"
export TARBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.tar.gz"
export ZIPBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.zip"
# Use git archive for clean source export
git archive --format=tar.gz --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$TARBALL"
git archive --format=zip --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$ZIPBALL"
git archive --format=tar.gz --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$TARBALL"
git archive --format=zip --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$ZIPBALL"
echo "✓ Created source archives:"
ls -lh "release-assets/$TARBALL" "release-assets/$ZIPBALL"
@@ -437,14 +226,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,73 +234,14 @@ 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
uses: ./.github/workflows/npm-publish.yml
with:
version: ${{ needs.validate.outputs.version }}

View File

@@ -1,63 +0,0 @@
name: Mutation Redundancy (disableBail, on-demand)
# One-off measurement to UNBLOCK R1 (test-redundancy prune). The nightly mutation run
# (nightly-mutation.yml) bails on the first kill, so `killedBy` lists only the FIRST
# killer — 🟠 redundant is understated and 🟢 unique overstated (see the caveat in
# scripts/quality/mutation-radiography.mjs). This workflow re-runs the SAME combo +
# chatCore leaf batches with stryker.disablebail.json (disableBail:true, incremental:false)
# so `killedBy` lists EVERY killer. Feed the uploaded reports to
# `node scripts/quality/mutation-radiography.mjs --candidates mutation-nobail-*/mutation.json`
# to get the accurate R1 prune-candidate list (🔴 empty 🟠 redundant) for human review.
#
# Batches mirror the nightly's leaf decomposition (d/e/f/g/h/i) rather than 2 mega-batches:
# disableBail is MORE expensive than bail (it never stops early), and Stryker only writes
# mutation.json on a SUCCESSFUL finish — a batch cancelled at the cap produces NO data — so
# smaller batches each fit the 300min headroom and run in parallel. auth/accountFallback and
# the security quartet are out of scope: R1 targets the combo/chatCore leaves.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
stryker-nobail:
name: Stryker disableBail (batch ${{ matrix.batch.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
batch:
- name: d
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
- name: e
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
- name: f
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
- name: g
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
- name: h
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
- name: i
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
timeout-minutes: 300
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Run Stryker (disableBail)
env:
BATCH_MUTATE: ${{ matrix.batch.mutate }}
run: npx stryker run --config-file stryker.disablebail.json --mutate "$BATCH_MUTATE"
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@v7
with:
name: mutation-nobail-${{ matrix.batch.name }}
path: reports/mutation/
if-no-files-found: warn
retention-days: 14

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

@@ -1,112 +0,0 @@
name: Nightly LLM Security
on:
schedule:
- cron: "53 5 * * *"
workflow_dispatch:
permissions:
contents: read
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' }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
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"
run: npm run build:cli
- name: Start OmniRoute (block mode)
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
PORT: "20128"
INJECTION_GUARD_MODE: block
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
for i in $(seq 1 30); do
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- name: promptfoo guard-validation
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
env:
OMNIROUTE_URL: http://localhost:20128
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true
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' }}
# 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
# the job stays green and simply skips the probes when the secret is absent.
env:
PROMPTFOO_PROVIDER_KEY: ${{ secrets.PROMPTFOO_PROVIDER_KEY }}
steps:
- name: Gate on provider secret
id: gate
run: |
if [ -n "$PROMPTFOO_PROVIDER_KEY" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)."
fi
- uses: actions/checkout@v7
with:
persist-credentials: false
if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@v7
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"
run: npm run build:cli
- name: Start OmniRoute
if: steps.gate.outputs.run == 'true'
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
PORT: "20128"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
for i in $(seq 1 30); do
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- uses: actions/setup-python@v7
if: steps.gate.outputs.run == 'true'
with: { python-version: "3.12" }
- run: pip install garak
if: steps.gate.outputs.run == 'true'
- name: garak limited probes
if: steps.gate.outputs.run == 'true'
env:
OPENAI_API_KEY: ${{ secrets.PROMPTFOO_PROVIDER_KEY }}
OPENAI_BASE_URL: http://localhost:20128/v1
run: garak --model_type openai --model_name gpt-4o-mini --probes promptinject,dan,leakreplay --report_prefix garak-omniroute || true
- name: Stop server
if: always() && steps.gate.outputs.run == 'true'
run: kill "$(cat server.pid)" || true

View File

@@ -1,163 +0,0 @@
name: Nightly Mutation
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
stryker:
name: Stryker mutation (batch ${{ matrix.batch.name }} — advisory)
runs-on: ubuntu-latest
# Mutation testing is expensive. History of the budget:
# - Full 8-module set TIMED OUT at the 180min cap (run 27705123780 = exactly 180min).
# The two god-files chatCore.ts/combo.ts dominated ~2/3 of the mutants and were
# removed from stryker.conf.json `mutate`.
# - The remaining 6 modules in 3 batches: auth.ts and accountFallback.ts are large; the
# a (auth+publicCreds) and b (accountFallback+error) batches still ran near the 180min
# cap (the perTest dry-run over ~130 covering test files is itself costly), so the two
# big modules are now ISOLATED into their own batches (a=auth, b=accountFallback).
# - Onda 3 / Fase 9 T5 re-add: combo.ts was split into 11 leaves; the 8 well-covered
# combo/* leaves are back in `mutate` (covered by the 24 combo-*.test.ts), grouped into
# 2 batches (d=heavy, e=light). After #4204 (D7b) merged, the reset-aware quota pair
# quotaScoring/quotaStrategies was added as batch f. A covering-test audit then added the
# 6 chatCore/* leaves with direct unit coverage as batch g. A follow-up then wrote dedicated
# unit tests for 6 more leaves and added them as batch h. A final follow-up added dedicated
# tests (no mock.module — fetch-override + crafted inputs + temp-DATA_DIR) for telemetryHelpers
# + memorySkillsInjection + semanticCache (its cache-HIT block now has a setCachedResponse
# fixture) as batch i — ALL 15/15 chatCore leaves are now mutated. See
# _mutate_godfiles_excluded_comment in stryker.conf.json.
# 9 PARALLEL batches, each overriding the mutate set via `--mutate` (Stryker 9 CLI:
# `-m, --mutate <comma-list>`; the conf's `mutate[]` remains the local-run default/union).
# - Cold-seeding budget (per-batch `timeout-minutes: ${{ matrix.batch.timeout || 180 }}`):
# a COLD run must COMPLETE once to write stryker-incremental.json (Stryker writes it only on
# a successful finish); a job cancelled at the cap writes nothing, so the next run is cold
# again — an infinite never-seeds loop. actions/cache is also branch-scoped, so each branch
# (incl. release) must seed its OWN cache via a run with enough headroom. Measured cold runs
# Measured cold totals (run 27801802713, extrapolated from the % at the 180/350 cancel point):
# auth ~375min (2301 mutants — EXCEEDS the 360min job max even isolated), accountFallback
# ~358min (1441), the c security quartet ~348min (1163), d ~197min (1316), g=142, h=132, e=66,
# f=45, i=33. The widely-covered modules blow the budget because the tap-runner re-runs every
# covering test file per mutant (a perTest fixed cost over ~138 test files, times thousands of
# mutants). A flat timeout bump cannot rescue auth (>360min max) — so the three over-budget
# batches are SPLIT so each half fits: auth->a1/a2 and accountFallback->b1/b2 by mutation range
# (`file:startLine-endLine`), the c quartet->c1/c2 by module pair. Splitting also seeds each
# sub-batch's own incremental cache, after which nightlies re-test only changed mutants.
# Full coverage every night in parallel; wall-clock = the slowest batch's cold run until seeded.
# Runs at stryker concurrency=4 with per-process DATA_DIR isolation
# (tests/_setup/isolateDataDir.ts) — see _concurrency_comment in stryker.conf.json.
strategy:
fail-fast: false
matrix:
batch:
# Per-batch `timeout` (minutes) tiers the cold-seeding budget by measured cost; batches
# without the key default to 180. Cold-run profiling (run 27801802713) showed the
# widely-covered modules need FAR more than the 180 cap because the tap-runner re-runs every
# covering test file per mutant: auth ~375min (2301 mutants, EXCEEDS the 360min GitHub job
# max even isolated), accountFallback ~358min, the c security quartet ~348min — none fit a
# single job. So auth/accountFallback are split by MUTATION RANGE (`file:startLine-endLine`,
# ~half the mutants each) into a1/a2, b1/b2; the c quartet is split by MODULE pair into
# c1/c2. d (3 combo modules, ~197min) stays whole. Split-heavy batches get 300min headroom
# for their cold seeding run; once each batch completes once and writes
# stryker-incremental.json, later runs re-test only changed mutants and finish far faster.
# g/h (142/132min cold) keep a 240 buffer; e/f/i (33-66min) keep the 180 default.
- name: a1
mutate: "src/sse/services/auth.ts:1-1109"
timeout: 300
- name: a2
mutate: "src/sse/services/auth.ts:1110-2218"
timeout: 300
- name: b1
mutate: "open-sse/services/accountFallback.ts:1-863"
timeout: 300
- name: b2
mutate: "open-sse/services/accountFallback.ts:864-1726"
timeout: 300
- name: c1
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts"
timeout: 300
- name: c2
mutate: "open-sse/utils/error.ts,open-sse/utils/publicCreds.ts"
timeout: 300
- name: d
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
timeout: 300
- name: e
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
- name: f
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
- name: g
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
timeout: 240
- name: h
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
timeout: 240
- name: i
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
# Per-batch budget: split-heavy batches (a1/a2/b1/b2/c1/c2/d) override to 300min, g/h to 240min;
# the rest default to 180min. `matrix.batch.timeout` is null for batches without the key -> `|| 180`.
# NOTE: a1+a2 both mutate auth.ts (disjoint line ranges) and b1+b2 both mutate accountFallback.ts;
# when merging the per-batch mutation.json for radiography/scores, same-file mutants from sibling
# ranges must be UNIONED (scripts/check/check-mutation-ratchet.mjs::measureMutationScores and
# scripts/quality/mutation-radiography.mjs both merge per file).
timeout-minutes: ${{ matrix.batch.timeout || 180 }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Restore Stryker incremental cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: reports/mutation/stryker-incremental.json
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
restore-keys: stryker-incremental-${{ matrix.batch.name }}-
- name: Run Stryker (advisory)
id: stryker
continue-on-error: true
env:
BATCH_MUTATE: ${{ matrix.batch.mutate }}
run: npx stryker run --mutate "$BATCH_MUTATE"
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@v7
with:
name: mutation-report-${{ matrix.batch.name }}
path: reports/mutation/
if-no-files-found: warn
retention-days: 14
# Aggregation gate (T3): each split batch emits a PARTIAL view of a mutated file
# (auth.ts lives in a1+a2, accountFallback in b1+b2), so a PER-BATCH ratchet would
# only ever see half a file vs the whole-file baseline. This job runs AFTER every
# batch, downloads all reports, and ratchets the MERGED per-module scores
# (check-mutation-ratchet UNIONS same-file mutants across reports) against the
# dedicatedGate `mutationScore.*` floors in quality-baseline.json (seeded ~2pt below
# the first full measurement). Blocking: a module dropping below its floor fails the
# run. Missing reports (e.g. an artifact-upload flake) are skipped, never failed.
mutation-ratchet:
name: Mutation score ratchet (blocking)
needs: stryker
if: always()
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
- name: Download all mutation reports
uses: actions/download-artifact@v8
with:
pattern: mutation-report-*
path: reports/all
- name: Ratchet merged per-module mutation scores
run: node scripts/check/check-mutation-ratchet.mjs reports/all/*/mutation.json --ratchet

View File

@@ -1,35 +0,0 @@
name: Nightly Property Discovery
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
property-random-seed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- name: fast-check random seed (high runs)
id: prop
run: FC_SEED=random FC_NUM_RUNS=2000 npm run test:property
- name: Open issue on failure
if: failure()
uses: actions/github-script@v9
with:
script: |
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "Nightly property-test failure (Fase 8 B)",
body: "fast-check found a counterexample with a random seed. Check the run logs for the reproducible seed + minimal case, then add it as a fixture.\n\nRun: " + context.serverUrl + "/" + context.repo.owner + "/" + context.repo.repo + "/actions/runs/" + context.runId,
labels: ["quality-gate-finding"],
});

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

@@ -1,114 +0,0 @@
name: Nightly Resilience
on:
schedule:
- cron: "41 4 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
heap:
name: Heap-growth gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:heap
chaos:
name: Resilience chaos (fault injection)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:chaos
k6-soak:
name: k6 load/soak
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
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"
run: npm run build:cli
- name: Start OmniRoute (background)
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
PORT: "20128"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
for i in $(seq 1 30); do
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
sleep 2
done
- name: Install k6
uses: grafana/setup-k6-action@v1
- name: Run k6 soak
run: k6 run tests/load/k6-soak.js
env:
BASE_URL: http://localhost:20128
SOAK_DURATION: "3m"
SOAK_VUS: "10"
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true
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' }}
# 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,
# so it self-builds — hence the generous job timeout. REQUIRE_AXE=1 makes the suite run
# the real axe analysis (the 4 page tests are gated to nightly so per-PR e2e stays fast)
# and makes the meta-test fail loudly if @axe-core/playwright ever goes missing.
timeout-minutes: 30
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
REQUIRE_AXE: "1"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: playwright-chromium-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- name: Run axe a11y suite (self-building webServer)
run: npx playwright test tests/e2e/a11y.spec.ts

View File

@@ -1,77 +0,0 @@
name: Nightly Schemathesis
on:
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
contents: read
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' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
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"
run: npm run build:cli
- name: Start OmniRoute (background)
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
PORT: "20128"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
for i in $(seq 1 30); do
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
with: { python-version: "3.12" }
- name: Install schemathesis
run: pip install schemathesis
- name: Schemathesis contract fuzz (advisory)
# Advisory gate: never fails the job. `continue-on-error` covers a crash of the
# step itself; `|| true` covers schemathesis exiting non-zero when it finds spec
# violations / upstream 500s — both are expected here (most /v1 endpoints proxy an
# upstream that has no provider configured in CI). The point of the nightly is to
# PROVE the contract is fuzzable and surface regressions, not to gate the build.
continue-on-error: true
run: |
schemathesis run docs/openapi.yaml \
--url http://localhost:20128 \
--max-examples 20 \
--workers 4 \
--checks all \
--max-response-time 30 \
--request-timeout 30 \
--suppress-health-check all \
--report junit \
--report-junit-path schemathesis-report/junit.xml \
--no-color \
|| true
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true
- name: Upload schemathesis report
if: always()
uses: actions/upload-artifact@v7
with:
name: schemathesis-report
path: |
schemathesis-report/
server.log
if-no-files-found: warn
retention-days: 14

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:
@@ -46,44 +37,27 @@ on:
NPM_TOKEN:
required: true
# Least-privilege default: read-only at the top level; each publish job grants the
# id-token (npm provenance) / packages (GitHub Packages) writes it needs (Scorecard
# TokenPermissions).
permissions:
contents: read
id-token: write
packages: write
env:
NPM_PUBLISH_NODE_VERSION: "24"
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 }}
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)
packages: write # publish to npm.pkg.github.com
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
persist-credentials: false
# Need full tag history to compare against highest semver when
# deciding whether this release should claim dist-tag `latest`.
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,207 +131,29 @@ 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
- name: Generate CycloneDX SBOM (npm)
if: steps.resolve.outputs.skip != 'true'
run: npx @cyclonedx/cyclonedx-npm --ignore-npm-errors --output-format JSON --output-file sbom-npm.cdx.json
- name: Upload SBOM (npm) as workflow artifact
if: steps.resolve.outputs.skip != 'true'
uses: actions/upload-artifact@v7
with:
name: sbom-npm
path: sbom-npm.cdx.json
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')
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
# 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 --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'
@@ -374,177 +170,18 @@ 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:
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
uses: actions/checkout@v6
- 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
@@ -571,94 +208,5 @@ jobs:
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}"
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
npm publish --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:
@@ -34,10 +32,8 @@ jobs:
matrix:
node: ["22", "24"]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -46,45 +42,21 @@ 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/checkout@v4
- 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

@@ -32,10 +32,8 @@ jobs:
matrix:
node: ["20", "22", "24"]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -48,10 +46,8 @@ jobs:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm

View File

@@ -1,624 +0,0 @@
name: Quality Gates
on:
pull_request:
branches: ["release/**"]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
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.
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:
fetch-depth: 0
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.
# 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
# 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.
#
# 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)
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
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
# 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
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

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

@@ -1,43 +0,0 @@
name: OpenSSF Scorecard
on:
branch_protection_rule:
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/**"]
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:
# security-events: write removed — Scorecard findings are advisory and no longer
# uploaded to the code-scanning Security tab (they are supply-chain/posture scores,
# not code vulnerabilities, and drowned out real CodeQL alerts). The run still
# produces the OpenSSF badge (publish_results) and a downloadable SARIF artifact.
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@v2.4.4
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: SARIF file
path: results.sarif
retention-days: 5

View File

@@ -1,35 +0,0 @@
name: semgrep
on:
pull_request:
branches: ["main", "release/**"]
push:
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
with:
persist-credentials: false
- name: Run semgrep (advisory)
continue-on-error: true
run: |
semgrep scan --config p/owasp-top-ten --config p/secrets \
--sarif --output semgrep.sarif --metrics off || true
python -c "import json; d=json.load(open('semgrep.sarif')); print('semgrepFindings=%d' % len(d['runs'][0]['results']))" || echo "semgrepFindings=SKIP"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: semgrep-sarif
path: semgrep.sarif
retention-days: 14

View File

@@ -1,69 +0,0 @@
name: Wiki Sync
# Keeps the GitHub wiki in sync with docs/ on every release that lands on main.
# The wiki has no native generator and historically drifts (it sat at "212+ providers /
# 14 strategies / 37 MCP tools" while code was at 226 / 15 / 87, and new docs like
# SUPPLY_CHAIN never appeared). This runs scripts/docs/sync-wiki.mjs, which:
# - ADDS any docs/ page missing from the wiki (curated; internal reports excluded),
# - syncs the four cover-page counts on Home.md.
# 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.
on:
push:
branches: [main]
paths:
- "docs/**"
- "README.md"
- "AGENTS.md"
- "src/shared/constants/routingStrategies.ts"
- "config/i18n.json"
- "open-sse/mcp-server/server.ts"
- "scripts/docs/sync-wiki.mjs"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki-sync
cancel-in-progress: false
jobs:
sync-wiki:
name: Sync wiki with docs
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"
- name: Clone wiki
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
git clone "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.wiki.git" wiki
- name: Sync wiki (add missing pages + cover counts)
run: node scripts/docs/sync-wiki.mjs --wiki-dir wiki
- name: Commit & push if changed
run: |
cd wiki
if [ -n "$(git status --porcelain)" ]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "docs(wiki): auto-sync pages + cover counts with docs"
git push
echo "Wiki updated."
else
echo "Wiki already in sync — nothing to push."
fi

106
.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,23 +12,17 @@ _tasks/
.agents/**
.claude/**
.gemini/**
.code-forge/**
.config/**
.data/**
.logs/**
.tests/**
.coverage/**
/coverage/
coverage/
.dist/**
.next/**
.build/**
.out/**
# Stryker mutation testing — ephemeral sandbox + generated reports (never commit)
.stryker-tmp/
reports/mutation/
stryker-output-*.json
# Memory Bank and Cursor rules (local-only AI agent context)
memory-bank/
@@ -46,20 +38,19 @@ memory-bank/
# Root-level underscore-prefixed directories (private/draft — never commit)
/_*/
/_*
# Draft features documentation (internal only)
docs/new-features/
# dependencies
node_modules/
# Also ignore a root node_modules SYMLINK (worktree setups symlink it from the main
# checkout). The trailing-slash pattern above only matches a directory, so without this
# a symlink named node_modules could be staged by `git add -A` and committed.
/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 +62,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 +75,7 @@ gitleaks-local.json
next-env.d.ts
# data and logs
/data/
data/
.data/
logs/*
test_output.log
@@ -110,7 +97,7 @@ open-sse/test/*
test-results/
playwright-report/
blob-report/
/cloud/
cloud/
.tmp/
# Security Analysis (standalone project with own git)
@@ -124,8 +111,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,22 +146,17 @@ 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
# GitNexus local index
.gitnexus
.worktrees
bin/omniroute.mjs
# Consistent with .dockerignore / .npmignore
.omc/
@@ -205,16 +185,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
@@ -226,31 +203,8 @@ pr_reviews*.json
# internal setup prompts with personal credentials — never commit
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/
# Quality ratchet — métricas efêmeras (baseline é commitado, métricas não)
quality-metrics.json
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260410 .txt
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute.md
@@ -258,41 +212,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

@@ -1,100 +0,0 @@
# .gitleaks.toml — Configuração do gitleaks para OmniRoute
# Task 7.18 — PLANO-QUALITY-GATES-FASE7.md
#
# Estende as regras padrão do gitleaks com allowlists específicas do projeto.
#
# INSTRUÇÕES para allowlists:
# Findings legítimos (fixtures de teste, creds OAuth públicas já cobertas pelo
# check-public-creds.mjs, valores de exemplo em docs) devem ser registrados abaixo
# em [[allowlist]] com um comentário explicativo obrigatório.
#
# NÃO adicione uma entrada de allowlist sem justificativa. Cada entrada é revisada
# a cada release (stale-enforcement). Regra: se o finding é um valor real que o
# sistema usa em produção, é um verdadeiro positivo — não allowliste, corrija.
#
# Referência: docs/security/PUBLIC_CREDS.md (credenciais OAuth públicas conhecidas)
# CLAUDE.md Hard Rule #11 (resolvePublicCred obrigatório)
# Herdar TODAS as regras padrão do gitleaks. ATENÇÃO: um config customizado
# SEM [extend].useDefault = true (e sem [[rules]] próprias) resulta em ZERO
# regras — o gitleaks SUBSTITUI o ruleset padrão pelo arquivo, não o estende
# automaticamente. Sem esta seção, `gitleaks --config .gitleaks.toml` nunca
# detecta nada (todo finding vira 0), tornando o gate inerte. Com useDefault,
# a allowlist abaixo é aplicada POR CIMA das ~170 regras padrão.
[extend]
useDefault = true
# Para desabilitar uma regra específica, usar:
# [[rules]]
# id = "rule-id"
# [rules.allowlist]
# description = "..."
# ---------------------------------------------------------------------------
# Allowlist global do projeto
# Entradas aqui são ignoradas em TODAS as varreduras.
# ---------------------------------------------------------------------------
[allowlist]
description = "OmniRoute project-level allowlist — fixtures, test vectors, public OAuth creds"
# Paths a ignorar completamente (node_modules, builds, etc.)
paths = [
'''node_modules''',
'''\.next''',
'''dist''',
'''\.git''',
'''coverage''',
'''\.nyc_output''',
]
# Commits específicos a ignorar (ex: commit que introduziu fixtures de teste)
# commits = []
# Regexes de stopwords — linhas que contêm estes padrões são ignoradas.
# Usar apenas para falsos positivos comprovados com justificativa abaixo.
# stopwords = []
# Regexes de targets (paths de arquivos) que podem ser allowlistados por regra.
# Ver [[allowlist]] por-regra abaixo para granularidade.
# ---------------------------------------------------------------------------
# Allowlist por-regra (adicionar conforme necessário durante o stale review)
# ---------------------------------------------------------------------------
#
# Exemplo (REMOVER / SUBSTITUIR por entradas reais quando necessário):
#
# [[rules]]
# # Allowlistar fixtures de teste que contêm tokens OAuth de exemplo/inválidos
# # Adicionado: 2026-06-13 | Revisar em: v3.9.0 | Justificativa: valores não-reais de teste
# id = "github-fine-grained-pat"
# [rules.allowlist]
# description = "Test fixture PATs — valores sintéticos, não funcionais"
# paths = [
# '''tests/fixtures/''',
# '''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,8 +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
node scripts/check/check-tracked-artifacts.mjs

View File

@@ -1,15 +1,8 @@
#!/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).
#if ! command -v npm >/dev/null 2>&1; then
# echo "⚠️ npm not found in PATH — skipping pre-push hooks"
# echo " Run 'npm test' manually before pushing."
# exit 0
#fi
if ! command -v npm >/dev/null 2>&1; then
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
echo " Run 'npm run check:any-budget:t11 && npm run check:tracked-artifacts' manually before pushing."
exit 0
fi
# No-op success: real local gates live in pre-commit; CI owns the rest.
exit 0
#npm run test:unit

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>

View File

@@ -1,14 +0,0 @@
{
"_comment": "Advisory markdown lint for docs/ + root *.md. Rules that conflict with the existing doc style (heavy inline HTML, long lines, centered headings) are disabled so the gate stays signal-not-noise. Run: npm run lint:md",
"default": true,
"MD013": false,
"MD033": false,
"MD041": false,
"MD024": { "siblings_only": true },
"MD026": false,
"MD036": false,
"MD040": false,
"MD029": false,
"MD007": { "indent": 2 },
"MD046": false
}

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

View File

@@ -1,22 +0,0 @@
[
{
"name": "CLI entry (omniroute.mjs)",
"path": "bin/omniroute.mjs",
"limit": "15 KB"
},
{
"name": "MCP server entry (mcp-server.mjs)",
"path": "bin/mcp-server.mjs",
"limit": "5 KB"
},
{
"name": "Node runtime support (nodeRuntimeSupport.mjs)",
"path": "bin/nodeRuntimeSupport.mjs",
"limit": "8 KB"
},
{
"name": "Reset password entry (reset-password.mjs)",
"path": "bin/reset-password.mjs",
"limit": "6 KB"
}
]

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"]}});

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

@@ -0,0 +1,23 @@
// source.config.ts
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
var docs = defineDocs({
dir: "docs",
docs: {
files: [
"./getting-started/**/*.md",
"./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.

View File

@@ -1,15 +0,0 @@
# Advisory prose lint for OmniRoute docs (Vale — https://vale.sh).
# Warning-first: surfaces vague language, passive voice, and terminology drift without
# blocking CI. The Microsoft style is pulled by the CI step (vale sync / vale-action).
# Project terms live in the OmniRoute vocabulary so they never read as misspellings.
StylesPath = .vale/styles
MinAlertLevel = warning
Packages = Microsoft
Vocab = OmniRoute
[*.md]
BasedOnStyles = Vale, Microsoft
# Code-heavy reference docs and auto-generated catalogs are noisy under prose rules.
[docs/reference/PROVIDER_REFERENCE.md]
BasedOnStyles = Vale

View File

@@ -1,50 +0,0 @@
OmniRoute
combo
combos
[Cc]ombo
[Aa]uto-[Cc]ombo
MCP
A2A
ACP
RTK
Caveman
Troglodita
Qdrant
FTS5
SQLite
LowDB
OAuth
PKCE
SSE
JSON-RPC
LKGP
P2C
Antigravity
Codex
Kiro
Qoder
Pollinations
LongCat
Cerebras
DeepSeek
Groq
Cline
OpenCode
Termux
Electron
Fumadocs
Notion
Obsidian
WebDAV
IPv4
IPv6
SOCKS5
loopback
backoff
changelog
i18n
locale
locales
roadmap
[Ww]ildcard
upstream

107
.vscode/settings.json vendored
View File

@@ -19,87 +19,30 @@
}
},
"git.ignoreLimitWarning": true,
// ─── Git: não adicionar os ~44 repos aninhados (9 worktrees + _references/* +
// _mono_repo/* + _ideia/_tasks/.agents) ao Source Control. Era a causa do
// "validando muito": um git status + watcher por repo. Só o repo raiz fica. ───
"git.autoRepositoryDetection": false,
"git.repositoryScanMaxDepth": 0,
"git.detectSubmodules": false,
"git.autofetch": false,
"git.autorefresh": true,
// ─── Performance: não seguir o symlink de node_modules criado pelas worktrees ───
// As worktrees em .worktrees/ apontam node_modules para o checkout principal;
// sem isto a busca atravessa o symlink mesmo com node_modules excluído.
"search.followSymlinks": false,
// ─── TypeScript server (monorepo grande: ~6,6k arquivos rastreados, Next.js 16) ───
"typescript.tsserver.maxTsServerMemory": 4096,
"typescript.disableAutomaticTypeAcquisition": true,
"typescript.tsc.autoDetect": "off",
"npm.autoDetect": "off",
// O tsserver tem o próprio watcher (independente de files.watcherExclude).
// excludeDirectories evita que ele vigie as árvores pesadas.
"typescript.tsserver.watchOptions": {
"excludeDirectories": [
"**/node_modules",
"**/.next",
"**/.build",
"**/dist",
"**/coverage",
"**/.worktrees",
"**/.claude/worktrees",
"**/electron",
"**/_references",
"**/_mono_repo",
"**/_tasks"
]
},
// 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,
// "**/dist": true,
// "**/.build": true,
// "**/.next": true,
// "**/_references": true,
// "**/_mono_repo": true,
// "**/_tasks": true,
// "**/omniroute-*.tgz": true
// "**/electron": true,
// "**/node_modules": true,
// "**/.next": true,
// "**/coverage": true,
// "**/omniroute-*.tgz": true,
// "**/_tasks": true
// },
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/_references/**": true,
"**/_mono_repo/**": true,
"**/electron/**": true,
"**/node_modules/**": true,
"**/.next/**": true,
"**/.build/**": true,
"**/coverage/**": true,
"**/_tasks/**": true,
"**/.git/objects/**": true,
"**/dist/**": true,
"**/build/**": true,
"**/out/**": true,
"**/coverage/**": true,
"**/.coverage/**": true,
"**/.nyc_output/**": true,
"**/.cache/**": true,
"**/.turbo/**": true,
"**/.swc/**": true,
"**/.stryker-tmp/**": true,
"**/stryker-output-*/**": true,
"**/playwright-report/**": true,
"**/test-results/**": true,
"**/.worktrees/**": true,
"**/.claude/worktrees/**": true,
"**/electron/**": true,
"**/_references/**": true,
"**/_mono_repo/**": true,
"**/_tasks/**": true,
"**/logs/**": true,
"**/*.tgz": true,
"**/*.tsbuildinfo": true,
"**/OmniRoute-*/**": true,
"**/*-merge-*/**": true,
"**/*-worktree-*/**": true,
@@ -107,32 +50,16 @@
"**/*-reorg*/**": true
},
"search.exclude": {
"**/_references": true,
"**/_mono_repo": true,
"**/electron": true,
"**/node_modules": true,
"**/.next": true,
"**/.build": true,
"**/coverage": true,
"**/_tasks": true,
"**/dist": true,
"**/build": true,
"**/out": true,
"**/coverage": true,
"**/.coverage": true,
"**/.nyc_output": true,
"**/.cache": true,
"**/.turbo": true,
"**/.swc": true,
"**/.stryker-tmp": true,
"**/stryker-output-*": true,
"**/playwright-report": true,
"**/test-results": true,
"**/.worktrees": true,
"**/.claude/worktrees": true,
"**/electron": true,
"**/_references": true,
"**/_mono_repo": true,
"**/_tasks": true,
"**/logs": true,
"**/*.tgz": true,
"**/*.tsbuildinfo": true,
"**/package-lock.json": true,
"**/OmniRoute-*": true,
"**/*-merge-*": true,
"**/*-worktree-*": true,

View File

@@ -1,104 +0,0 @@
# .zizmor.yml — zizmor security audit configuration
# https://github.com/woodruffw/zizmor
#
# zizmor audits GitHub Actions workflows for security anti-patterns:
# • Unpinned third-party actions (supply-chain risk)
# • Script injection via ${{ github.* }} in run: steps
# • pull_request_target misuse (allows untrusted code to reach secrets)
# • Excessive permissions / overly broad GITHUB_TOKEN scopes
# • Cache poisoning via actions/cache with unguarded keys
# • … 20+ additional audits
#
# MOTIVATION (2026-03 incident): the trivy-action/LiteLLM supply-chain attack
# exploited exactly the kind of pull_request_target misconfiguration that zizmor
# detects statically. OmniRoute's npm/Docker/Electron publish workflows are high-
# value targets that warrant proactive audit.
#
# BASELINE STRATEGY: this file starts with an EMPTY ignores list. As zizmor is
# first run against the existing workflows, findings will be reviewed:
# • True positives → fix the workflow (preferred)
# • Accepted risk → add an entry below with a justification comment
# This enforces the same stale-enforcement discipline as other allowlists in
# this project: every ignore requires human sign-off and is subject to review
# at each release cycle.
#
# RATCHET: zizmorFindings metric in quality-baseline.json (direction: down)
# starts advisory; current baseline is frozen at first green run; new findings
# must be remediated or explicitly ignored here.
# ── Global settings ──────────────────────────────────────────────────────────
# Uncomment to pin to a specific minimum severity level.
# min-severity: low # low | medium | high | critical (default: low = all)
# ── Per-rule ignores ─────────────────────────────────────────────────────────
# zizmor ≥1.0 replaced the old top-level `ignores:` list with a `rules:` map
# keyed by audit id. Per-rule config lives under `rules.<audit-id>.ignore`,
# where each entry is `filename` or `filename:line` (line optional, column
# further-optional). See: https://docs.zizmor.sh/configuration/
#
# Format:
# rules:
# <zizmor-audit-id>: # e.g. "unpinned-uses", "template-injection"
# ignore:
# - <workflow-filename> # ignore this audit across the file
# - <workflow-filename>:<line> # or scope to a specific line
#
# Example (do not uncomment without real justification):
#
# rules:
# unpinned-uses:
# # actions/checkout@v6 is pinned at the major-version tag intentionally:
# # GitHub-managed first-party action; SHA pinning buys little against a
# # GitHub-side compromise and adds significant maintenance burden.
# ignore:
# - ci.yml
# Every entry below is an explicit, justified ignore (same stale-review
# discipline as the other allowlists in this project). New ignores require a
# per-rule entry with a justification comment.
rules:
dangerous-triggers:
# deploy-vps.yml uses `on: workflow_run` (after "Publish to Docker Hub").
# zizmor flags workflow_run as "almost always used insecurely", but this one
# is guarded and not exploitable:
# • The deploy job is gated on `github.event.workflow_run.conclusion ==
# 'success'` (deploy-vps.yml job `if:`, L15), so it only runs after a
# successful, trusted upstream run — never on a forked/PR-triggered
# failure.
# • It does NOT checkout or execute untrusted code: the deploy runs over SSH
# using repository secrets; there is no `actions/checkout` of an attacker
# ref in the privileged context.
# Accepted risk, re-review at the next release cycle (added 2026-06-15).
ignore:
- deploy-vps.yml
cache-poisoning:
# zizmor's cache-poisoning audit flags `actions/setup-node` (cache: npm) and
# `actions/cache` steps that populate a cache which a later artifact-publishing
# job could consume — the attack is: a fork PR seeds a poisoned cache that a
# trusted publish run then restores and ships. None of the four entries below
# are exploitable in that way:
#
# • electron-release.yml / npm-publish.yml — PUBLISH workflows that trigger
# ONLY on trusted events: `push: tags v*` + `workflow_dispatch` (electron)
# and `release: [released]` + `workflow_dispatch` + `workflow_call`
# (npm). They NEVER run on a `pull_request` from a fork, so an untrusted
# ref can never populate the npm/node_modules cache they restore. The
# cache key is `hashFiles('package-lock.json')` / `runner.os-node-…`, a
# first-party, lockfile-pinned key on trusted events.
#
# • opencode-plugin-ci.yml / opencode-provider-ci.yml — CI-only workflows
# (lint/test of the two opencode packages). They DO run on `pull_request`,
# but (a) they publish NO artifacts — there is no downstream publish job
# that restores their cache, and (b) GitHub Actions cache is branch-scoped:
# a fork-PR run writes to a PR-isolated cache that base-branch / release
# runs cannot read. The `cache: npm` here only speeds up `npm ci` within
# the same ephemeral CI run.
#
# Accepted risk (first-party caching on trusted/isolated events), re-review at
# the next release cycle (added 2026-06-16).
ignore:
- electron-release.yml
- npm-publish.yml
- opencode-plugin-ci.yml
- opencode-provider-ci.yml

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,70 +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"
},
"overrides": {
"esbuild": "^0.28.1"
}
}

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();
}
});
});

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