mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-07 23:42:10 +03:00
Compare commits
79 Commits
fix/quota-
...
compressio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e52842b926 | ||
|
|
3d444c2209 | ||
|
|
944178475c | ||
|
|
2a3adca1cb | ||
|
|
52775bc958 | ||
|
|
a076376490 | ||
|
|
1c3f6dcc90 | ||
|
|
9d495f7c44 | ||
|
|
904d45e011 | ||
|
|
3eca8d9c0c | ||
|
|
aefab44503 | ||
|
|
c605cabf08 | ||
|
|
9f67e5cc74 | ||
|
|
f579f9b096 | ||
|
|
17c76cf5d6 | ||
|
|
0975bc8ca9 | ||
|
|
2baf2f4820 | ||
|
|
0be4243d2e | ||
|
|
4826385f19 | ||
|
|
988f76c9bf | ||
|
|
439f13b210 | ||
|
|
0515e69a3f | ||
|
|
96b31e3142 | ||
|
|
00059f7bdd | ||
|
|
71750799eb | ||
|
|
b4f0d97f10 | ||
|
|
bf83381794 | ||
|
|
71a8f6f98e | ||
|
|
64dba26398 | ||
|
|
19b58a99ab | ||
|
|
26b058207b | ||
|
|
ea801bbca2 | ||
|
|
77eb184f9d | ||
|
|
869f07b3b0 | ||
|
|
3780d45d62 | ||
|
|
da3c3c9f67 | ||
|
|
a48f256f51 | ||
|
|
b2ce078c92 | ||
|
|
8a3888e510 | ||
|
|
368d1c0e87 | ||
|
|
b21f2d91e7 | ||
|
|
c2c622ad82 | ||
|
|
796b4fefd1 | ||
|
|
cf2055ce3e | ||
|
|
4ef44a53a7 | ||
|
|
2a1c946aa6 | ||
|
|
0d2678360f | ||
|
|
a8fb526e9c | ||
|
|
dcddbe11cd | ||
|
|
0e66f7e566 | ||
|
|
4f97f84dea | ||
|
|
8aa9c6f710 | ||
|
|
5ead198eb4 | ||
|
|
a0f9ad852d | ||
|
|
8941eafcb9 | ||
|
|
b02c586cb4 | ||
|
|
e26fc0a774 | ||
|
|
ecd1114894 | ||
|
|
53066a592a | ||
|
|
6c309c5e5f | ||
|
|
59d5f43d7b | ||
|
|
152a3e13f7 | ||
|
|
ae3d026ad0 | ||
|
|
347bfe257c | ||
|
|
0dcac8bfc3 | ||
|
|
71af74bee4 | ||
|
|
ccbe6bc288 | ||
|
|
98c98856b8 | ||
|
|
3899495f60 | ||
|
|
35797deeab | ||
|
|
c3a024d3be | ||
|
|
b0d4d64e6c | ||
|
|
aa5856376e | ||
|
|
a52d5fb31f | ||
|
|
9ca5a1ad42 | ||
|
|
44ba571521 | ||
|
|
cc8d790262 | ||
|
|
ad94ec491e | ||
|
|
1e629721b9 |
18
.cbmignore
18
.cbmignore
@@ -119,10 +119,11 @@ omnirouteSite/
|
||||
# 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.
|
||||
src/lib/env/
|
||||
src/app/api/agent-skills/coverage/
|
||||
src/app/api/cloud/
|
||||
src/app/api/sync/cloud/
|
||||
src/app/api/system/env/
|
||||
tests/golden-set/data/
|
||||
|
||||
# Logs e saida de teste
|
||||
@@ -141,10 +142,6 @@ 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)
|
||||
@@ -191,9 +188,8 @@ audit-report.json
|
||||
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.
|
||||
# Cli binario local (scratch)
|
||||
bin/omniroute.mjs
|
||||
|
||||
# Deploy / docker backups
|
||||
deploy.sh
|
||||
|
||||
@@ -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
|
||||
@@ -44,19 +38,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 +50,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
|
||||
|
||||
1012
.env.example
1012
.env.example
File diff suppressed because it is too large
Load Diff
11
.gitattributes
vendored
11
.gitattributes
vendored
@@ -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
|
||||
48
.github/actions/npm-ci-retry/action.yml
vendored
48
.github/actions/npm-ci-retry/action.yml
vendored
@@ -1,45 +1,9 @@
|
||||
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"
|
||||
description: Run npm ci with retries for transient registry/network failures.
|
||||
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
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -51,8 +15,7 @@ runs:
|
||||
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
|
||||
if npm ci; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -64,8 +27,3 @@ runs:
|
||||
sleep "$delay_seconds"
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
|
||||
- name: node_modules restored from cache
|
||||
if: steps.node-modules.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
run: echo "node_modules restored from cache (key hit) — npm ci skipped"
|
||||
|
||||
41
.github/dependabot.yml
vendored
41
.github/dependabot.yml
vendored
@@ -39,41 +39,14 @@ updates:
|
||||
# the duplication gate — migrate the gate intentionally, not via dependabot.
|
||||
- dependency-name: "jscpd"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# ioredis is a SOFT/optional dependency loaded through a dynamic import
|
||||
# (src/lib/quota/redisQuotaStore.ts — "Redis driver requires ioredis package"),
|
||||
# so a breaking major never fails at build or typecheck time: the only consumers
|
||||
# are the distributed quota store (redisQuotaStore.ts, storeFactory.ts) and the
|
||||
# `import type Redis` in src/shared/utils/rateLimiter.ts. Nothing in the unit or
|
||||
# vitest suites exercises a live Redis connection, so a v5→v6 API break would ship
|
||||
# green and only surface at runtime for operators running distributed quota — the
|
||||
# exact users least able to absorb it. #9310 grouped that major with 9 harmless
|
||||
# bumps; majors here need their own PR and a deliberate migration review.
|
||||
- dependency-name: "ioredis"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
|
||||
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
|
||||
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
|
||||
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
|
||||
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
|
||||
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
|
||||
# Migrate it intentionally, not via dependabot (#4050).
|
||||
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
|
||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
||||
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
|
||||
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
|
||||
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
|
||||
# every version). Migrate it intentionally, not via dependabot (#4050).
|
||||
- dependency-name: "@huggingface/transformers"
|
||||
# onnxruntime-node is the OTHER HALF of the @huggingface/transformers pair frozen
|
||||
# above: the hoisted copy must equal the exact version transformers pins, or npm
|
||||
# nests a second ABI-incompatible native copy (contract test
|
||||
# tests/unit/onnxruntime-single-copy.test.ts, pair established in #9962). A solo
|
||||
# bump can never be correct — it only ever moves together with transformers, in
|
||||
# the same deliberate migration PR. Freezing it keeps the production group PRs
|
||||
# (e.g. #12219) from being born red on the pair contract.
|
||||
- dependency-name: "onnxruntime-node"
|
||||
# eslint-plugin-react-hooks is pinned to 7.0.1 by a contract test
|
||||
# (tests/unit/eslint-react-hooks-version-pinned.test.ts) until the 7.1.1 rule set
|
||||
# is adopted deliberately — that adoption needs a full cold lint run and its own
|
||||
# PR (the #12146 react-hooks migration finished on 2026-09-01, so the path is
|
||||
# open; the bump still must not ride a dependabot group, where it reds the
|
||||
# development group PRs, e.g. #12220). Remove this ignore in the adoption PR
|
||||
# together with the pin test.
|
||||
- dependency-name: "eslint-plugin-react-hooks"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -10,7 +10,7 @@
|
||||
## Validation
|
||||
|
||||
Choose the change type and focused loop from the
|
||||
[Contribution Golden Path](../docs/ops/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
||||
[Contribution Golden Path](../docs/dev/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
|
||||
@@ -18,7 +18,7 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
|
||||
- [ ] `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.
|
||||
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
|
||||
|
||||
## Tests Added Or Updated
|
||||
|
||||
|
||||
36
.github/workflows/api-route-typecheck.yml
vendored
36
.github/workflows/api-route-typecheck.yml
vendored
@@ -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
|
||||
64
.github/workflows/build.yml
vendored
64
.github/workflows/build.yml
vendored
@@ -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
|
||||
401
.github/workflows/ci.yml
vendored
401
.github/workflows/ci.yml
vendored
@@ -27,7 +27,7 @@ env:
|
||||
jobs:
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
@@ -35,13 +35,10 @@ jobs:
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
testsOnly: ${{ steps.classify.outputs.testsOnly }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
# Refuse a PR that targets its own head branch before spending anything on it. #8912 has
|
||||
# head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with
|
||||
# a full check board attached on every push to that branch. One field comparison.
|
||||
@@ -77,7 +74,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -91,14 +88,9 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
@@ -109,11 +101,8 @@ jobs:
|
||||
.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.
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of
|
||||
# a second cold full-tree pass for eslintWarnings ratchet counts.
|
||||
- name: ESLint (JSON report)
|
||||
@@ -130,8 +119,6 @@ jobs:
|
||||
- run: npm run check:route-validation:t06
|
||||
- run: npm run check:any-budget:t11
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:model-lifecycle
|
||||
- run: npm run check:provider-asset-provenance
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
@@ -177,7 +164,7 @@ jobs:
|
||||
|
||||
quality-gate:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# needs lint so eslint-results artifact is available (same inventory as the
|
||||
# blocking lint step). Allow lint failure so other ratchets still run.
|
||||
needs: [changes, test-coverage, lint]
|
||||
@@ -197,13 +184,9 @@ jobs:
|
||||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
@@ -212,11 +195,8 @@ jobs:
|
||||
.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.
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||
# continue-on-error: o artifact pode não existir se a job test-coverage foi
|
||||
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
|
||||
@@ -298,7 +278,7 @@ jobs:
|
||||
# SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets.
|
||||
quality-extended:
|
||||
name: Quality Gates (Extended)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -308,14 +288,10 @@ jobs:
|
||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
||||
# would lack the base ref and the gate would self-skip (base-unresolved).
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# Dead-code, cognitive-complexity, type-coverage foram promovidos ao job
|
||||
# quality-gate (bloqueante) na Fase 7 INT — não rodam aqui para evitar duplo custo.
|
||||
@@ -418,20 +394,16 @@ jobs:
|
||||
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# Run when docs OR code change: API/route code can break doc/OpenAPI contract gates.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:docs-all
|
||||
# Previously-orphaned contract gates (existed as files, never wired anywhere).
|
||||
@@ -451,7 +423,7 @@ jobs:
|
||||
|
||||
docs-lint:
|
||||
name: Docs Lint (prose — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -461,13 +433,9 @@ jobs:
|
||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- name: markdownlint (docs + root, advisory)
|
||||
run: npx --yes markdownlint-cli2 "docs/**/*.md" "*.md" "!docs/i18n" "!docs/research" || true
|
||||
- name: Vale prose lint (Microsoft style, advisory)
|
||||
@@ -482,7 +450,7 @@ jobs:
|
||||
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -492,21 +460,12 @@ jobs:
|
||||
# fetch-depth: 0 — the value-drift gate diffs en.json against the merge base to
|
||||
# find rewritten English strings. On a shallow clone the base ref is missing and
|
||||
# the gate self-skips (base-unresolved), so it would never actually run.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
# Real-translation ratchet: a leaf copied verbatim from en.json passes key
|
||||
# parity above but is still English to the user (es shipped 55% English).
|
||||
# Advisory in PR-0; flipped to blocking once the backlog is retranslated (PR-4).
|
||||
- name: i18n real-translation ratio (advisory)
|
||||
run: node scripts/i18n/check-translation-ratio.mjs --warn
|
||||
# #8463: a rewritten English value used to leave its 39 translations behind
|
||||
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
|
||||
# Key parity above cannot see it — a stale translation counts as covered.
|
||||
@@ -515,24 +474,18 @@ jobs:
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: node scripts/i18n/check-ui-value-drift.mjs
|
||||
|
||||
# #8038: cheap glossary/protected-terms consistency gate —
|
||||
# #8038: cheap single-locale glossary/protected-terms consistency gate —
|
||||
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
|
||||
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
|
||||
# ko added after the #8224 ko.json mistranslation cleanup so the fixed
|
||||
# terminology cannot silently regress on the next machine-translation run.
|
||||
i18n-glossary-zhcn:
|
||||
name: i18n Glossary (zh-CN, ko)
|
||||
runs-on: ubuntu-latest
|
||||
name: i18n Glossary (zh-CN)
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
|
||||
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-TW
|
||||
@@ -544,7 +497,7 @@ jobs:
|
||||
# idioma (a matrix antiga subia 40 artifacts cujo result.txt colidia no merge-multiple).
|
||||
i18n:
|
||||
name: i18n Validation (all languages)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
@@ -552,7 +505,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.i18n == 'true') }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
@@ -587,15 +540,12 @@ jobs:
|
||||
pr-test-policy:
|
||||
name: PR Test Policy
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- name: Fetch base branch
|
||||
run: git fetch --no-tags origin "${GITHUB_BASE_REF}"
|
||||
- name: Validate source changes include tests
|
||||
@@ -620,30 +570,19 @@ jobs:
|
||||
# Dynamic runner: when the release captain flips the USE_VPS_RUNNER repo var to
|
||||
# 'true' (scripts/vps/release-runner-up.sh does it after the self-hosted VM is
|
||||
# online), the heavy jobs run on the dedicated 32-core VPS runners (label
|
||||
# omni-build) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# omni-release) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# Safety: fork PRs NEVER reach the self-hosted runner — the expression falls
|
||||
# back to ubuntu-latest unless the PR head repo is this repository (push /
|
||||
# back to ubuntu-26.04 unless the PR head repo is this repository (push /
|
||||
# dispatch events are own-origin by definition). Any failure path (VM down,
|
||||
# var unset/false) also falls back to ubuntu-latest.
|
||||
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' }}
|
||||
# var unset/false) also falls back to ubuntu-26.04.
|
||||
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-release"]') || 'ubuntu-26.04' }}
|
||||
needs: changes
|
||||
# The .113 pool runs ONE next-build with room to spare and two at the edge: the
|
||||
# box has 31 GB and a single next-build peaks at 14–16 GB RSS. On 2026-08-28
|
||||
# 13:50Z the kernel OOM-killed main's build while a PR build ran beside it
|
||||
# (five Build jobs had been queued by a burst of PRs). Two lanes: main keeps
|
||||
# its own so a release is never queued behind PR traffic; PR builds serialize
|
||||
# among themselves. docker-publish.yml's amd64 leg joins `heavy-build-main`
|
||||
# so a :next image build waits beside this artefact instead of becoming the
|
||||
# third heavy (#11976). GitHub keeps one running + one pending per group.
|
||||
concurrency:
|
||||
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
|
||||
cancel-in-progress: false
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -671,93 +610,42 @@ jobs:
|
||||
# Keep standalone/node_modules intact: package/electron jobs consume the
|
||||
# Next-traced standalone tree and must not replace it with root node_modules.
|
||||
run: |
|
||||
tar -czf "$RUNNER_TEMP/e2e-build.tar.gz" \
|
||||
tar -czf /tmp/e2e-build.tar.gz \
|
||||
--exclude='.build/next/cache' \
|
||||
.build/next
|
||||
- name: Upload Next.js build for downstream jobs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: next-build
|
||||
path: ${{ runner.temp }}/e2e-build.tar.gz
|
||||
path: /tmp/e2e-build.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
package-artifact:
|
||||
name: Package Artifact
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: build
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
|
||||
# it only rebuilds if the downloaded standalone artifact is missing.
|
||||
- run: npm run build:cli
|
||||
- name: Assert dist/server.js exists
|
||||
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
|
||||
# `build:cli` monta dist/ mas NAO grava dist/BUILD_SHA — so `build:release` faz
|
||||
# isso, chamando write-build-sha.mjs. O guard de proveniencia do #10427, dentro
|
||||
# de check:pack-artifact, rejeita um artefato sem SHA (e rejeita mesmo com
|
||||
# OMNIROUTE_ALLOW_CANARY_BUILD=1: o que nao da para identificar nao da para
|
||||
# vouchear). Sem este passo o par build+validate deste job e estruturalmente
|
||||
# incompativel e falha 100% das vezes.
|
||||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||
# O SHA TEM de vir do head da PR, nao de `git rev-parse HEAD`. Este workflow
|
||||
# roda em `pull_request`, entao o checkout e o MERGE COMMIT efemero que o
|
||||
# GitHub cria — um commit que nao existe em branch nenhuma e portanto nunca e
|
||||
# ancestral da release. O guard de proveniencia (#10427) rejeita exatamente
|
||||
# isso, e com razao: um artefato carimbado com o merge commit nao pode ser
|
||||
# rastreado ate codigo que passou pelos gates.
|
||||
env:
|
||||
OMNIROUTE_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||
node scripts/build/write-build-sha.mjs
|
||||
# O guard de proveniencia checa ancestralidade contra `origin/main` por padrao.
|
||||
# Esse e o ref certo na PUBLICACAO (npm-publish.yml roda em main), mas em
|
||||
# `pull_request` e estruturalmente impossivel: enquanto a PR esta aberta o head
|
||||
# dela NUNCA e ancestral de main — e o checkout raso nem traz `origin/main` para
|
||||
# o grafo local, entao a sonda responde `false` de qualquer jeito. Resultado: o
|
||||
# gate falhava 100% das vezes em PR. Pre-merge o unico invariante checavel e "o
|
||||
# stamp corresponde a branch sob teste", entao apontamos o ref para o head da PR.
|
||||
# Usamos `refs/pull/<N>/head` e nao `head.ref` porque aquele existe no PROPRIO
|
||||
# origin mesmo quando a PR vem de um fork; `head.ref` so existe no repo do autor.
|
||||
- name: Resolve the provenance ref for the pack gate (#10427)
|
||||
id: provenance-ref
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch --no-tags --depth=50 origin \
|
||||
"+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"
|
||||
echo "ref=origin/pr-head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git fetch --no-tags --depth=50 origin \
|
||||
"+refs/heads/$GITHUB_REF_NAME:refs/remotes/origin/$GITHUB_REF_NAME"
|
||||
echo "ref=origin/$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- run: npm run check:pack-artifact
|
||||
env:
|
||||
OMNIROUTE_RELEASE_REF: ${{ steps.provenance-ref.outputs.ref }}
|
||||
# WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and
|
||||
# BOOT it to a healthy /api/monitoring/health — the gate that structure checks
|
||||
# cannot provide (3 releases shipped boot-crashing tarballs with green lists).
|
||||
@@ -769,24 +657,23 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
needs: build
|
||||
# WS1.5 (v3.8.49 plan): the Electron native-module path previously executed for
|
||||
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for
|
||||
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
|
||||
# without shell, CVE-2024-27980 behavior change) could only surface at release.
|
||||
# windows-latest runs prepare:bundle (better-sqlite3 prebuild verification since
|
||||
# v13 — the node-gyp rebuild is gone) per release PR; ubuntu keeps the full
|
||||
# pack + headless smoke.
|
||||
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
|
||||
# PR; ubuntu keeps the full pack + headless smoke.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
os: [ubuntu-26.04, windows-latest]
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -796,14 +683,10 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
@@ -815,7 +698,7 @@ jobs:
|
||||
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in
|
||||
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and
|
||||
# continue-on-error keeps the heavy gate green while we harden it (#7336).
|
||||
- name: Prepare Electron standalone (Windows prebuild verification)
|
||||
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: electron
|
||||
continue-on-error: true
|
||||
@@ -829,14 +712,14 @@ jobs:
|
||||
|
||||
test-unit:
|
||||
name: Unit Tests (${{ matrix.shard }}/8)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-26.04).
|
||||
# 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
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 25
|
||||
# needs: changes (not build) — this job never downloads the next-build artifact;
|
||||
# gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that
|
||||
@@ -854,13 +737,9 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# QW-d (plano mestre): fonte única — o MESMO npm script dos runs locais (adiciona o
|
||||
@@ -868,23 +747,9 @@ jobs:
|
||||
# D3 (plano mestre): a coverage é coletada NESTE mesmo run (c8/NODE_V8_COVERAGE propaga
|
||||
# aos filhos através do npm) — elimina a matrix Coverage Shard ×8, que re-executava a
|
||||
# suíte inteira só para medir o gate. Padrão usado pelo CI do próprio nodejs/node.
|
||||
# Heap: os shards rodam sob instrumentacao de cobertura do V8, que retem muito
|
||||
# mais memoria que a suite crua. Com o teto antigo de 4096 MB os shards passaram
|
||||
# a abortar com SIGABRT (exit 134, "Ineffective mark-compacts near heap limit")
|
||||
# ao redor de 4086 MB conforme o catalogo de providers cresceu no ciclo v3.8.50 —
|
||||
# todos os testes passavam e o processo morria no fim, o que le como falha de
|
||||
# teste sem ser. O teto vive em `test:unit:ci:shard` (package.json) e agora
|
||||
# acompanha os 8192 MB ja usados pelas variantes nao-shardadas; os runners
|
||||
# GitHub-hosted tem 16 GB.
|
||||
- name: Unit tests (shard ${{ matrix.shard }}/8) with V8 coverage
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/8
|
||||
# NODE_OPTIONS (nao so o flag em test:unit:ci:shard) porque quem estoura o
|
||||
# heap e o processo `c8` que embrulha a suite — ele agrega ~577 MB de JSON
|
||||
# de cobertura bruta. Subir o teto so no node filho deixa o pai no default
|
||||
# do V8 (~4 GB) e o OOM continua igual, em ~4083 MB. Mesmo padrao ja usado
|
||||
# pelo job de merge de cobertura mais abaixo.
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: |
|
||||
rm -rf coverage-shard coverage-shard-report
|
||||
npx c8 \
|
||||
@@ -904,45 +769,27 @@ jobs:
|
||||
|
||||
test-bun-sqlite:
|
||||
name: Bun SQLite Compatibility
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.os == 'windows-latest' }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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: Install Bun (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
powershell -c "iwr bun.sh/install.ps1 -useb | iex"
|
||||
echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
- name: Install Bun (non-Windows)
|
||||
if: runner.os != 'Windows'
|
||||
run: npm install -g bun
|
||||
- run: npm run test:bun:db
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-26.04).
|
||||
# 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
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
@@ -952,13 +799,9 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
||||
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
||||
@@ -986,28 +829,20 @@ jobs:
|
||||
# the release gate can still exercise them via workflow_dispatch when needed).
|
||||
test-coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
|
||||
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
|
||||
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
|
||||
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
|
||||
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
|
||||
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
|
||||
# blocking job was green. The upload step now has its own ceiling; this is headroom.
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
needs: test-unit
|
||||
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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: Download all shard coverage
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -1073,10 +908,6 @@ jobs:
|
||||
# (if-no-files-found: warn) — Sonar consumes the same file.
|
||||
- name: Upload coverage to Codecov (informational)
|
||||
if: always()
|
||||
# Informational means informational: its own ceiling and continue-on-error, so a
|
||||
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
@@ -1095,17 +926,14 @@ jobs:
|
||||
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: test-coverage
|
||||
# Temporarily opt-in: the private project currently has no Sonar quota.
|
||||
# Re-enable without another code change by setting the repository Actions
|
||||
# variable SONARQUBE_ENABLED=true after quota/project access is restored.
|
||||
if: ${{ vars.SONARQUBE_ENABLED == 'true' && !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1150,7 +978,7 @@ jobs:
|
||||
|
||||
coverage-pr-comment:
|
||||
name: PR Coverage Comment
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.code == 'true' }}
|
||||
needs:
|
||||
- changes
|
||||
@@ -1229,7 +1057,7 @@ jobs:
|
||||
|
||||
test-e2e:
|
||||
name: E2E Tests (${{ matrix.shard }}/9)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# Build artifact from the `build` job is downloaded instead of rebuilding
|
||||
# (~5min saved per shard). 9 shards (up from 6) reduces tests per shard by
|
||||
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
|
||||
@@ -1251,13 +1079,9 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Playwright browsers
|
||||
@@ -1271,14 +1095,10 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
|
||||
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
|
||||
# critical path. The balancer self-verifies completeness and exits non-zero on
|
||||
@@ -1310,7 +1130,7 @@ jobs:
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
@@ -1326,23 +1146,17 @@ jobs:
|
||||
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Integration tests (shard ${{ matrix.shard }}/2)
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/2
|
||||
run: npm run test:integration:ci
|
||||
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
|
||||
- run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
@@ -1351,77 +1165,16 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@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 test:security
|
||||
|
||||
# Live-server E2E. Both suites boot a real OmniRoute via their own runner and
|
||||
# drive it over HTTP; neither needs provider credentials. They were documented in
|
||||
# AGENTS.md's test matrix but wired to NO workflow, and had additionally been
|
||||
# unrunnable (vitest.config.ts excluded the very files their runners passed as a
|
||||
# positional filter) — so nothing had executed them for as long as that was true.
|
||||
test-ecosystem:
|
||||
name: Ecosystem E2E (live server)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# needs: changes (not build) — the runner boots its own dev server.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
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"
|
||||
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
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:ecosystem
|
||||
|
||||
test-protocols-e2e:
|
||||
name: Protocol Clients E2E (live server, advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
# ADVISORY until #10049 is resolved. Restoring this suite immediately surfaced a
|
||||
# real discrepancy that had been invisible while it could not run: GET
|
||||
# /api/mcp/audit answers 403 over loopback where the suite expects 200|401. That
|
||||
# is a pre-existing contract question, not a defect introduced by wiring the job
|
||||
# up, so it must not block every PR in the meantime. Flip to blocking (drop this
|
||||
# continue-on-error) the moment #10049 lands.
|
||||
continue-on-error: true
|
||||
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"
|
||||
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
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:protocols:e2e
|
||||
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- changes
|
||||
@@ -1441,8 +1194,6 @@ jobs:
|
||||
- test-e2e
|
||||
- test-integration
|
||||
- test-security
|
||||
- test-ecosystem
|
||||
- test-protocols-e2e
|
||||
steps:
|
||||
- name: Download i18n results
|
||||
continue-on-error: true
|
||||
@@ -1501,9 +1252,9 @@ jobs:
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n Glossary (zh-CN, ko) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n Glossary (zh-CN) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| SonarQube (opt-in; disabled without SONARQUBE_ENABLED=true) | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1523,8 +1274,6 @@ jobs:
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Ecosystem E2E | $(status '${{ needs.test-ecosystem.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Protocol Clients E2E (advisory, #10049) | $(status '${{ needs.test-protocols-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
4
.github/workflows/claude.yml
vendored
4
.github/workflows/claude.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -13,7 +13,7 @@ permissions:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (javascript-typescript)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
27
.github/workflows/dast-smoke.yml
vendored
27
.github/workflows/dast-smoke.yml
vendored
@@ -1,15 +1,7 @@
|
||||
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"]
|
||||
branches: ["main", "release/**"]
|
||||
# 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.
|
||||
@@ -26,7 +18,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
dast-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# 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
|
||||
@@ -41,11 +33,7 @@ jobs:
|
||||
- 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
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
@@ -54,7 +42,6 @@ jobs:
|
||||
env:
|
||||
PORT: "20128"
|
||||
INJECTION_GUARD_MODE: block
|
||||
REQUIRE_API_KEY: "false"
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
@@ -73,20 +60,16 @@ jobs:
|
||||
# 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)' \
|
||||
--exclude-path-regex '^/api/auth/oidc/' \
|
||||
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
- name: Install promptfoo
|
||||
run: npm install -g promptfoo@0.122.0
|
||||
- name: promptfoo injection-guard (blocking)
|
||||
env:
|
||||
OMNIROUTE_URL: http://localhost:20128
|
||||
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
|
||||
run: promptfoo eval -c promptfooconfig.yaml --no-cache
|
||||
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
2
.github/workflows/deploy-vps.yml
vendored
2
.github/workflows/deploy-vps.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
|
||||
&& vars.DEPLOY_ENABLED == 'true'
|
||||
name: Deploy OmniRoute to VPS
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Check VPS SSH reachability from runner
|
||||
id: reach
|
||||
|
||||
243
.github/workflows/docker-publish.yml
vendored
243
.github/workflows/docker-publish.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release/v*"
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
@@ -26,14 +25,6 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# One publish per ref. A merge storm used to fan out 8 concurrent hosted builds,
|
||||
# every one OOM-killing `npm run build` inside BuildKit (#11976). The :next
|
||||
# channel only needs the newest SHA; cancel-in-progress is the same pattern as
|
||||
# quality.yml / nightly-release-green.
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege default: read-only at the top level; the build and merge jobs that
|
||||
# push to GHCR grant packages: write themselves (Scorecard TokenPermissions).
|
||||
permissions:
|
||||
@@ -42,7 +33,7 @@ permissions:
|
||||
jobs:
|
||||
prepare:
|
||||
name: Resolve Docker release metadata
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
promote_latest: ${{ steps.version.outputs.promote_latest }}
|
||||
@@ -51,7 +42,7 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
@@ -66,30 +57,39 @@ jobs:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PROMOTE_INPUT: ${{ inputs.promote_latest }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) Resolve version/channel from the trigger. Only the current default
|
||||
# release branch publishes the mutable `next` channel; main keeps `main`.
|
||||
VERSION=$(bash scripts/ci/resolve-docker-publish-version.sh \
|
||||
"$EVENT_NAME" "$REF_TYPE" "$REF_NAME" "$INPUT_VERSION" "$DEFAULT_BRANCH")
|
||||
# 1) Resolve version string from the trigger (all inputs come via env).
|
||||
case "$EVENT_NAME" in
|
||||
workflow_dispatch)
|
||||
VERSION="${INPUT_VERSION#v}"
|
||||
;;
|
||||
push)
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
VERSION="${REF_NAME#v}"
|
||||
else
|
||||
# Push to main → build & tag as `main` only. Never touch :latest.
|
||||
VERSION="main"
|
||||
fi
|
||||
;;
|
||||
release)
|
||||
VERSION="${REF_NAME#v}"
|
||||
;;
|
||||
*)
|
||||
VERSION="${REF_NAME#v}"
|
||||
;;
|
||||
esac
|
||||
# Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth).
|
||||
if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then
|
||||
echo "Refusing to use unsafe VERSION value: $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Frozen release branches keep receiving coordination commits after the
|
||||
# next cycle becomes the default branch. They must not overwrite :next,
|
||||
# but that expected no-op is not a workflow failure.
|
||||
if [ "$VERSION" = "skip" ]; then
|
||||
echo "promote_latest=false" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping Docker publish from non-default release branch: $REF_NAME"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Decide whether to promote :latest. Floating channels are never
|
||||
# eligible, and the helper independently fails closed for non-semver.
|
||||
# 2) Decide whether to promote :latest.
|
||||
PROMOTE="false"
|
||||
if [ "$VERSION" = "main" ] || [ "$VERSION" = "next" ]; then
|
||||
if [ "$VERSION" = "main" ]; then
|
||||
PROMOTE="false"
|
||||
elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
|
||||
echo "Pre-release identifier detected — skipping :latest."
|
||||
@@ -109,10 +109,10 @@ jobs:
|
||||
fi
|
||||
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 3) Skip immutable version tags that already exist. Floating `main`
|
||||
# and `next` channels are intentionally rebuilt on every matching push.
|
||||
# 3) Skip if this exact version is already published in Docker Hub.
|
||||
# `main` is always rebuilt (mutable floating tag).
|
||||
SKIP="false"
|
||||
if [ "$VERSION" != "main" ] && [ "$VERSION" != "next" ]; then
|
||||
if [ "$VERSION" != "main" ]; then
|
||||
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
|
||||
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
|
||||
SKIP="true"
|
||||
@@ -126,23 +126,7 @@ jobs:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
# amd64: the .113 omni-build pool (31 GB / 32 cores, ONE listener since
|
||||
# #12048). Hosted ubuntu-24.04 is ~7 GB and dies ResourceExhausted (#11976).
|
||||
# Falls back to hosted when USE_VPS_RUNNER is off. arm64: no ARM box — stay
|
||||
# on GitHub's ubuntu-24.04-arm.
|
||||
# Webpack on BOTH arches: Turbopack on omniroute-113-6 hit
|
||||
# TurbopackInternalError "there must be a path to a root" after 26 min
|
||||
# (run 33253576569). The same tree's arm64 webpack build on hosted ARM
|
||||
# succeeded (run 33264823398). Dockerfile already documents webpack as the
|
||||
# Docker escape hatch (OMNIROUTE_USE_TURBOPACK=0).
|
||||
runs-on: ${{ matrix.arch == 'amd64' && (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-24.04') || 'ubuntu-24.04-arm' }}
|
||||
# Share the 1-slot omni-build ceiling (#12048) with ci.yml `Build` /
|
||||
# npm-publish. Same group as main's Build so a :next publish waits beside
|
||||
# the artefact instead of sitting next to it. arm64 is hosted — its own
|
||||
# group, cancelled by the workflow-level concurrency.
|
||||
concurrency:
|
||||
group: ${{ matrix.arch == 'amd64' && 'heavy-build-main' || format('docker-publish-arm-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ matrix.arch != 'amd64' }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -151,34 +135,33 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
env:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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 +174,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 +191,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 +205,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,37 +227,13 @@ 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:
|
||||
- prepare
|
||||
- build
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -355,7 +245,7 @@ jobs:
|
||||
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
@@ -365,13 +255,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 +281,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 +296,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 +304,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 +320,6 @@ jobs:
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
if [ -n "$optional" ]; then
|
||||
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -468,8 +328,6 @@ jobs:
|
||||
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||
|
||||
- name: Inspect image
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
@@ -528,21 +386,18 @@ jobs:
|
||||
severity: CRITICAL
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
# Explicit: the advisory scan above already points at it, and the blocking
|
||||
# gate must honour the same accepted-risk list (#12084).
|
||||
trivyignores: .trivyignore
|
||||
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.9
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
- name: Update Docker Hub description
|
||||
# Only refresh README/description when we actually promote :latest
|
||||
# (avoids overwriting from main, next, or back-fill builds).
|
||||
# (avoids overwriting from main pushes or back-fill builds).
|
||||
if: needs.prepare.outputs.promote_latest == 'true'
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
with:
|
||||
|
||||
188
.github/workflows/electron-release.yml
vendored
188
.github/workflows/electron-release.yml
vendored
@@ -4,21 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# A dispatch builds the ref it is dispatched ON (`gh workflow run … --ref v3.8.50` rebuilds
|
||||
# that tag; `--ref main` builds the repaired line). The ref is deliberately NOT an input:
|
||||
# CodeQL flags an input-controlled checkout next to the npm cache on the default branch as
|
||||
# cache poisoning (actions/cache-poisoning/poisonable-step), and `github.ref` is trusted.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g., v1.6.8)"
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# Least-privilege default: read-only at the top level; each job grants the writes it
|
||||
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
|
||||
@@ -29,14 +20,14 @@ permissions:
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate version
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.validate.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -64,78 +55,9 @@ jobs:
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ Valid version: $VERSION"
|
||||
|
||||
web-build:
|
||||
name: Build shared Next standalone
|
||||
needs: validate
|
||||
# Stage 8 (issue #10321): the four desktop legs used to each run the full
|
||||
# `npm run build` (Next standalone) — ~111 runner-minutes per release just to
|
||||
# produce the same platform-independent bundle four times. This job builds it
|
||||
# once on ubuntu; every leg then restores the byte-verified archive and
|
||||
# re-forks its native optionals (scripts/build/standaloneBundle.mjs).
|
||||
#
|
||||
# Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled.
|
||||
# This job then skips, every leg falls back to building its own web bundle
|
||||
# (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 —
|
||||
# no revert needed.
|
||||
if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
||||
|
||||
- name: Build Next.js standalone
|
||||
# webpack, not Turbopack, for the same hosted-runner RAM reason as the
|
||||
# linux leg (see the long comment on the fallback step in `build`).
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
NODE_OPTIONS: "--max_old_space_size=6144"
|
||||
OMNIROUTE_USE_TURBOPACK: "0"
|
||||
run: npm run build
|
||||
|
||||
- name: Pack standalone bundle
|
||||
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
|
||||
# archive's own sha256 so artifact-transfer corruption is caught before
|
||||
# extraction, and every entry is re-verified after extraction.
|
||||
run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz
|
||||
|
||||
- name: Upload shared web bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: web-standalone-bundle
|
||||
# compression-level 0: the payload is already a deterministic tar.gz;
|
||||
# re-zipping would only burn runner CPU without shrinking it further.
|
||||
compression-level: 0
|
||||
# Legs consume this within minutes; no reason to retain it like the
|
||||
# installer artifacts (default 90d).
|
||||
retention-days: 3
|
||||
path: |
|
||||
web-bundle.tar.gz
|
||||
web-bundle.tar.gz.manifest.json
|
||||
|
||||
build:
|
||||
name: Build Electron (${{ matrix.platform }})
|
||||
needs: [validate, web-build]
|
||||
# `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback
|
||||
# mode); legs then run the legacy per-leg web build below. If it ran and
|
||||
# failed, fail closed: legs cannot package without the bundle, and silently
|
||||
# falling back to four per-leg builds would hide exactly the regression the
|
||||
# shared job exists to surface.
|
||||
if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }}
|
||||
needs: validate
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: write # electron-builder may publish artifacts with GH_TOKEN
|
||||
@@ -147,62 +69,43 @@ jobs:
|
||||
runner: windows-latest
|
||||
target: win
|
||||
ext: .exe
|
||||
os: win32
|
||||
arch: x64
|
||||
- platform: macos-intel
|
||||
runner: macos-15-intel
|
||||
target: mac-x64
|
||||
ext: .dmg
|
||||
os: darwin
|
||||
arch: x64
|
||||
- platform: macos-arm64
|
||||
runner: macos-latest
|
||||
target: mac-arm64
|
||||
ext: -arm64.dmg
|
||||
os: darwin
|
||||
arch: arm64
|
||||
- platform: linux
|
||||
runner: ubuntu-latest
|
||||
runner: ubuntu-26.04
|
||||
target: linux
|
||||
ext: .AppImage
|
||||
deb_ext: .deb
|
||||
os: linux
|
||||
arch: x64,arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
||||
|
||||
# The Linux leg produces x64 + arm64 installers from one x64 runner. npm
|
||||
# deliberately installs only host-compatible optional dependencies, so
|
||||
# hydrateNativeDeps cannot source the arm64 fork unless we fetch the exact
|
||||
# package pinned in package-lock before either build path runs.
|
||||
- name: Install Linux arm64 wreq binding for cross-package
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install --no-save --ignore-scripts --force --legacy-peer-deps \
|
||||
@wreq-js/binding-linux-arm64-gnu@3.2.0
|
||||
git diff --exit-code -- package.json package-lock.json
|
||||
mkdir -p "$RUNNER_TEMP/omniroute-wreq-verify"
|
||||
DATA_DIR="$RUNNER_TEMP/omniroute-wreq-verify" node --import tsx/esm --test \
|
||||
--test-name-pattern='wreq-js 3.2 manifest pins all nine' \
|
||||
tests/unit/wreq-native-manifest.test.ts
|
||||
|
||||
- name: Sanitize Windows home directory
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
@@ -213,11 +116,7 @@ jobs:
|
||||
mkdir -p "$RUNNER_TEMP/home"
|
||||
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Next.js standalone (legacy per-leg fallback)
|
||||
# Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled)
|
||||
# or when the shared web-build job was skipped. Otherwise the leg restores
|
||||
# the shared bundle from the `web-build` job below.
|
||||
if: needs.web-build.result == 'skipped'
|
||||
- name: Build Next.js standalone
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
NODE_OPTIONS: "--max_old_space_size=6144"
|
||||
@@ -235,30 +134,6 @@ jobs:
|
||||
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
|
||||
run: npm run build
|
||||
|
||||
- name: Download shared web bundle
|
||||
# Stage 8: inverse of the fallback step above — runs exactly when the
|
||||
# shared `web-build` job produced the bundle.
|
||||
if: needs.web-build.result == 'success'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: web-standalone-bundle
|
||||
|
||||
- name: Restore + hydrate shared web bundle
|
||||
if: needs.web-build.result == 'success'
|
||||
shell: bash
|
||||
# restore: verify the archive's sha256 against the manifest, extract, then
|
||||
# re-verify every entry (existence + size + content hash + symlink
|
||||
# targets, and no unlisted files) byte-for-byte.
|
||||
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
|
||||
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
|
||||
# @wreq-js/binding-*, fsevents) carry linux forks. Replace them with the forks this
|
||||
# leg's own `npm ci` resolved, then assert every bundled native
|
||||
# (better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# can service this leg's platform/arch before packaging starts.
|
||||
run: |
|
||||
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
|
||||
node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Sync version in electron/package.json
|
||||
shell: bash
|
||||
env:
|
||||
@@ -283,7 +158,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 +185,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
|
||||
@@ -369,17 +239,15 @@ jobs:
|
||||
# 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
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
@@ -437,14 +305,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
|
||||
@@ -468,7 +329,7 @@ jobs:
|
||||
# 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
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -502,20 +363,11 @@ jobs:
|
||||
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
|
||||
|
||||
4
.github/workflows/lock-released-branch.yml
vendored
4
.github/workflows/lock-released-branch.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
lock-branch:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Lock release/<tag> branch
|
||||
env:
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
guard-no-push-after-release:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Reject push if matching release tag exists
|
||||
env:
|
||||
|
||||
8
.github/workflows/mutation-redundancy.yml
vendored
8
.github/workflows/mutation-redundancy.yml
vendored
@@ -22,7 +22,7 @@ permissions:
|
||||
jobs:
|
||||
stryker-nobail:
|
||||
name: Stryker disableBail (batch ${{ matrix.batch.name }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,13 +41,9 @@ jobs:
|
||||
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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run Stryker (disableBail)
|
||||
env:
|
||||
|
||||
20
.github/workflows/nightly-compat.yml
vendored
20
.github/workflows/nightly-compat.yml
vendored
@@ -28,11 +28,11 @@ concurrency:
|
||||
jobs:
|
||||
resolve-branch:
|
||||
name: Resolve active release branch
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
target: ${{ steps.branch.outputs.target }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -58,15 +58,15 @@ jobs:
|
||||
|
||||
compat-build-26:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "26"
|
||||
cache: npm
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
# 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
|
||||
# classic OOM kill on the memory-constrained 16 GB hosted 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
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
compat-tests:
|
||||
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
strategy:
|
||||
@@ -102,11 +102,11 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
|
||||
report:
|
||||
name: Open / update tracking issue on failure
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ !cancelled() && (needs.compat-tests.result == 'failure' || needs.compat-build-26.result == 'failure') }}
|
||||
needs: [resolve-branch, compat-build-26, compat-tests]
|
||||
permissions:
|
||||
|
||||
19
.github/workflows/nightly-llm-security.yml
vendored
19
.github/workflows/nightly-llm-security.yml
vendored
@@ -10,16 +10,11 @@ permissions:
|
||||
jobs:
|
||||
promptfoo-guard:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
@@ -49,10 +44,7 @@ jobs:
|
||||
|
||||
garak:
|
||||
name: garak probes (skip without provider secret)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-26.04
|
||||
# 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
|
||||
@@ -69,13 +61,10 @@ jobs:
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)."
|
||||
fi
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
|
||||
15
.github/workflows/nightly-mutation.yml
vendored
15
.github/workflows/nightly-mutation.yml
vendored
@@ -10,7 +10,7 @@ permissions:
|
||||
jobs:
|
||||
stryker:
|
||||
name: Stryker mutation (batch ${{ matrix.batch.name }} — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
# 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
|
||||
@@ -104,13 +104,9 @@ jobs:
|
||||
# scripts/quality/mutation-radiography.mjs both merge per file).
|
||||
timeout-minutes: ${{ matrix.batch.timeout || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
@@ -145,15 +141,12 @@ jobs:
|
||||
name: Mutation score ratchet (blocking)
|
||||
needs: stryker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
|
||||
8
.github/workflows/nightly-property.yml
vendored
8
.github/workflows/nightly-property.yml
vendored
@@ -8,15 +8,11 @@ permissions:
|
||||
issues: write
|
||||
jobs:
|
||||
property-random-seed:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
|
||||
140
.github/workflows/nightly-release-green.yml
vendored
140
.github/workflows/nightly-release-green.yml
vendored
@@ -68,13 +68,13 @@ jobs:
|
||||
# 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' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-26.04' }}
|
||||
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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -193,27 +193,7 @@ jobs:
|
||||
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"
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
@@ -237,19 +217,19 @@ jobs:
|
||||
# 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' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-26.04' }}
|
||||
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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -311,26 +291,7 @@ jobs:
|
||||
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"
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
@@ -370,12 +331,12 @@ jobs:
|
||||
bank-ratchet-shrinks:
|
||||
name: Bank ratchet shrinks
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -410,11 +371,6 @@ jobs:
|
||||
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
|
||||
@@ -423,13 +379,6 @@ jobs:
|
||||
# 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
|
||||
@@ -491,72 +440,3 @@ jobs:
|
||||
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
|
||||
|
||||
35
.github/workflows/nightly-resilience.yml
vendored
35
.github/workflows/nightly-resilience.yml
vendored
@@ -10,43 +10,31 @@ permissions:
|
||||
jobs:
|
||||
heap:
|
||||
name: Heap-growth gate
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
@@ -78,10 +66,7 @@ jobs:
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-26.04
|
||||
# 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,
|
||||
@@ -95,13 +80,9 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
REQUIRE_AXE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
|
||||
9
.github/workflows/nightly-schemathesis.yml
vendored
9
.github/workflows/nightly-schemathesis.yml
vendored
@@ -10,17 +10,12 @@ permissions:
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: Schemathesis — OpenAPI contract fuzz (advisory)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
|
||||
328
.github/workflows/npm-publish.yml
vendored
328
.github/workflows/npm-publish.yml
vendored
@@ -23,12 +23,11 @@ on:
|
||||
- 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)"
|
||||
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
|
||||
required: false
|
||||
default: "auto"
|
||||
default: "staged"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- staged
|
||||
- direct
|
||||
workflow_call:
|
||||
@@ -63,19 +62,15 @@ jobs:
|
||||
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
|
||||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
skip: ${{ steps.resolve.outputs.skip }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-26.04' }}
|
||||
permissions:
|
||||
actions: read # find + download the CI run's next-build artifact for this SHA
|
||||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||||
id-token: write # npm provenance (GitHub Packages step)
|
||||
id-token: write # npm provenance
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Need full tag history to compare against highest semver when
|
||||
@@ -83,7 +78,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -205,11 +200,8 @@ jobs:
|
||||
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 27–32 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
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
|
||||
RUN="$candidate"
|
||||
break
|
||||
fi
|
||||
@@ -219,8 +211,8 @@ jobs:
|
||||
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"
|
||||
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
|
||||
rm -rf /tmp/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
|
||||
@@ -234,28 +226,6 @@ jobs:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
run: npm run build:cli
|
||||
|
||||
# `build:cli` assembles dist/ but does NOT write dist/BUILD_SHA — only
|
||||
# `build:release` does, by calling write-build-sha.mjs. The #10427 provenance
|
||||
# guard inside check:pack-artifact rejects an artifact with no SHA (and rejects
|
||||
# it even under OMNIROUTE_ALLOW_CANARY_BUILD=1: what cannot be identified cannot
|
||||
# be vouched for). Without this step the build+validate pair in this job is
|
||||
# structurally incompatible and fails 100% of the time — the same gap that was
|
||||
# fixed in ci.yml's Package Artifact job.
|
||||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
OMNIROUTE_BUILD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||
node scripts/build/write-build-sha.mjs
|
||||
|
||||
# The guard checks ancestry against origin/main by default, which is correct
|
||||
# here (a release tag is cut from main), but the ref has to exist locally for
|
||||
# `git merge-base` to resolve it.
|
||||
- name: Fetch main for the provenance probe
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
- name: Validate npm package artifact
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-artifact
|
||||
@@ -273,20 +243,11 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach SBOM to GitHub Release
|
||||
# Not only on the `release` event: the v3.8.50 package shipped through a
|
||||
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
|
||||
# GitHub Release carried no SBOM until it was attached by hand from the run's
|
||||
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
|
||||
run: |
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
|
||||
exit 0
|
||||
fi
|
||||
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
|
||||
|
||||
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
|
||||
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
|
||||
@@ -304,12 +265,7 @@ jobs:
|
||||
# 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
|
||||
timeout-minutes: 30
|
||||
run: npm run check:install-upgrade
|
||||
|
||||
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
||||
@@ -331,33 +287,41 @@ jobs:
|
||||
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
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
- name: Publish to npm (staged — owner approves with 2FA)
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
|
||||
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))"
|
||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
||||
npm stage publish --provenance --access public --tag "$TAG"
|
||||
{
|
||||
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: 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
|
||||
- name: Publish to npm (DIRECT — emergency fallback)
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
@@ -374,132 +338,21 @@ 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
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Full history needed for auto-bump: git diff against previous release tag
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -573,92 +426,3 @@ jobs:
|
||||
fi
|
||||
npm publish --provenance --access public --ignore-scripts
|
||||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|
||||
|
||||
publish-opencode-plugin-v2:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Full history needed for auto-bump: git diff against previous release tag
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Auto-bump plugin-v2 version if plugin-v2 changed since last release
|
||||
id: bump
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
env:
|
||||
CURRENT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
# 1) Skip if current version is not yet published (no bump needed)
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
|
||||
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Find the previous release tag (exclude the current one)
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
|
||||
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag to compare — skipping bump."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3) Check if plugin-v2 dir actually changed since that tag
|
||||
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin-v2/"; then
|
||||
echo "⏭️ No plugin-v2 changes since $PREV_TAG — nothing to publish."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4) Auto-bump patch version
|
||||
npm version patch --no-git-tag-version --allow-same-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
|
||||
|
||||
- name: Install plugin-v2 dependencies
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Build plugin-v2
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
run: npm run clean && npm run build
|
||||
|
||||
- name: Test plugin-v2
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
run: npm test
|
||||
|
||||
- name: Publish @omniroute/opencode-plugin-v2 to npm
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
# Same hardened skip-check as the main job (no --silent flag).
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" = "$PKG_VERSION" ]; then
|
||||
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish --provenance --access public --ignore-scripts
|
||||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|
||||
|
||||
54
.github/workflows/opencode-plugin-ci.yml
vendored
54
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -28,16 +26,16 @@ defaults:
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -46,45 +44,23 @@ jobs:
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
||||
test-v2:
|
||||
name: Test v2 (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: "@omniroute/opencode-plugin-v2"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-plugin-v2/package-lock.json"
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, test-v2]
|
||||
runs-on: ubuntu-26.04
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
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
|
||||
|
||||
12
.github/workflows/opencode-provider-ci.yml
vendored
12
.github/workflows/opencode-provider-ci.yml
vendored
@@ -26,16 +26,16 @@ defaults:
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
356
.github/workflows/quality.yml
vendored
356
.github/workflows/quality.yml
vendored
@@ -25,20 +25,17 @@ jobs:
|
||||
# path filters share existence reasons: code / docs / i18n / workflow.
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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 }}
|
||||
@@ -60,77 +57,22 @@ jobs:
|
||||
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
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Fork-safe fallback uses Ubuntu 26.04's bundled Node 24 without setup-node.
|
||||
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-release"]') || 'ubuntu-26.04' }}
|
||||
# #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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: node -e 'if (process.versions.node.split(".")[0] !== process.env.CI_NODE_VERSION) throw new Error("Expected Node " + process.env.CI_NODE_VERSION)'
|
||||
- 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.
|
||||
|
||||
@@ -140,16 +82,12 @@ jobs:
|
||||
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
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
# 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)
|
||||
@@ -163,7 +101,7 @@ jobs:
|
||||
# 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.
|
||||
# or a fork PR falls back to ubuntu-26.04, 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
|
||||
@@ -177,7 +115,7 @@ jobs:
|
||||
#
|
||||
# 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
|
||||
runs-on: ubuntu-26.04
|
||||
# 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:
|
||||
@@ -185,15 +123,11 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -201,11 +135,54 @@ jobs:
|
||||
.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.
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
|
||||
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
|
||||
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
|
||||
- run: npm run check:mutation-test-coverage
|
||||
- run: npm run check:any-budget:t11
|
||||
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
||||
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
||||
- run: npm run check:build-scope
|
||||
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||
# the release PR's heavy Package Artifact job.
|
||||
- run: npm run check:pack-policy
|
||||
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
||||
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||
- run: npm run check:complexity-ratchets
|
||||
# ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ──────────────
|
||||
# The god-file refactor happens in PRs→release/**; without these, the release
|
||||
# rail never sees a new import cycle, dead code, duplication or a security
|
||||
# regression until the release PR to main. Deliberately NOT brought here:
|
||||
# bundle-size (self-skips without a build — this rail's build job is advisory
|
||||
# and uploads nothing, so it would be dead configuration) and the coverage
|
||||
# run (fast-unit already runs the full suite; the coverage ratchet stays on
|
||||
# the main rail via --allow-missing in lint-guard).
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:lockfile
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
- name: Dead-code ratchet (knip)
|
||||
run: npm run check:dead-code
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet
|
||||
run: npm run check:compression-budget
|
||||
# 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
|
||||
@@ -249,111 +226,38 @@ jobs:
|
||||
"$HOME/.local/bin/osv-scanner" --version || true
|
||||
"$HOME/.local/bin/oasdiff" --version || true
|
||||
zizmor --version || true
|
||||
- name: Forgotten sibling tests (advisory)
|
||||
- name: Secret scan (gitleaks, ratchet, blocking)
|
||||
run: npm run check:secrets -- --ratchet
|
||||
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
|
||||
run: npm run check:vuln-ratchet -- --ratchet
|
||||
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
|
||||
run: npm run check:workflows -- --ratchet
|
||||
# BASE_REF is read by the script from the env (never interpolated into a
|
||||
# shell body) — workflow-injection-safe. actions/checkout fetches remote
|
||||
# refs, not a local branch named github.base_ref, so prefix origin/ or this
|
||||
# gate self-skips every PR with reason=base-unresolved.
|
||||
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
|
||||
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
|
||||
run: npm run check:openapi-breaking -- --ratchet
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
||||
- name: Typecheck (dashboard)
|
||||
run: npm run check:dashboard-typecheck
|
||||
# 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.
|
||||
# Promote to the blocking gate after ~1 week of parity with the step above.
|
||||
- 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=$?
|
||||
npx -y -p typescript@7 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
|
||||
@@ -361,7 +265,7 @@ jobs:
|
||||
# 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.
|
||||
# doubled wall time (~16 min extra on the hosted runner) 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).
|
||||
@@ -370,8 +274,7 @@ jobs:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
# The advisory sibling-test step generates the same map earlier in this job.
|
||||
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
# Shadow evidence (#8084): persist every selection so TIA false negatives can
|
||||
# be measured against fast-unit's full-suite verdict across releases BEFORE
|
||||
@@ -426,36 +329,15 @@ jobs:
|
||||
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)
|
||||
# Share fast-gates' checkout + npm ci instead of spending ~80s preparing a
|
||||
# separate runner for a ~13s Vitest invocation. !cancelled() preserves the
|
||||
# independent test signal when an earlier fast gate fails.
|
||||
- name: Vitest
|
||||
if: ${{ !cancelled() }}
|
||||
run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
|
||||
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast path runs on every PR.
|
||||
# Advisory upload, own-origin only.
|
||||
- name: Upload Vitest 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
|
||||
@@ -468,9 +350,9 @@ jobs:
|
||||
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).
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-26.04).
|
||||
# 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
|
||||
# critical path again (~8.5min → ~4.5min hosted; ~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
|
||||
@@ -479,13 +361,7 @@ jobs:
|
||||
# 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
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -495,14 +371,10 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
# 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.
|
||||
@@ -526,10 +398,7 @@ jobs:
|
||||
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.
|
||||
runs-on: ubuntu-26.04
|
||||
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
|
||||
@@ -538,14 +407,10 @@ jobs:
|
||||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -553,16 +418,11 @@ jobs:
|
||||
.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.
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- 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
|
||||
@@ -602,22 +462,18 @@ jobs:
|
||||
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
|
||||
runs-on: ubuntu-26.04
|
||||
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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
- run: npm ci
|
||||
- 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)
|
||||
|
||||
64
.github/workflows/radar-export.yml
vendored
64
.github/workflows/radar-export.yml
vendored
@@ -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
|
||||
9
.github/workflows/scorecard.yml
vendored
9
.github/workflows/scorecard.yml
vendored
@@ -4,17 +4,14 @@ on:
|
||||
schedule:
|
||||
- cron: "27 7 * * 1"
|
||||
push:
|
||||
# Scorecard only accepts the DEFAULT branch — here the active release/vX.Y.Z,
|
||||
# not `main`. The job below guards on it so a push to any other branch skips.
|
||||
branches: ["main", "release/**"]
|
||||
branches: ["main"]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
if: ${{ github.event_name != 'push' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
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,
|
||||
@@ -24,7 +21,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/semgrep.yml
vendored
2
.github/workflows/semgrep.yml
vendored
@@ -14,7 +14,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
|
||||
9
.github/workflows/wiki-sync.yml
vendored
9
.github/workflows/wiki-sync.yml
vendored
@@ -34,15 +34,10 @@ concurrency:
|
||||
jobs:
|
||||
sync-wiki:
|
||||
name: Sync wiki with docs
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Clone wiki
|
||||
env:
|
||||
|
||||
74
.gitignore
vendored
74
.gitignore
vendored
@@ -1,8 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# project-specific directories
|
||||
/output/
|
||||
.slim/deepwork/
|
||||
.omnivscodeagent/
|
||||
omnirouteCloud/
|
||||
omnirouteSite/
|
||||
@@ -14,13 +12,12 @@ _tasks/
|
||||
.agents/**
|
||||
.claude/**
|
||||
.gemini/**
|
||||
.code-forge/**
|
||||
.config/**
|
||||
.data/**
|
||||
.logs/**
|
||||
.tests/**
|
||||
.coverage/**
|
||||
/coverage/
|
||||
coverage/
|
||||
.dist/**
|
||||
.next/**
|
||||
.build/**
|
||||
@@ -46,7 +43,6 @@ memory-bank/
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never commit)
|
||||
/_*/
|
||||
/_*
|
||||
|
||||
# Draft features documentation (internal only)
|
||||
docs/new-features/
|
||||
@@ -60,6 +56,10 @@ node_modules/
|
||||
*.map
|
||||
.DS_Store
|
||||
|
||||
# Obsidian sync plugin — committed for community distribution
|
||||
!obsidian-plugin/
|
||||
obsidian-plugin/node_modules/
|
||||
|
||||
# Serena AI assistant config (local-only tool, not project code)
|
||||
.serena/
|
||||
|
||||
@@ -71,11 +71,9 @@ 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
|
||||
!.env.homolog.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
@@ -88,7 +86,7 @@ gitleaks-local.json
|
||||
next-env.d.ts
|
||||
|
||||
# data and logs
|
||||
/data/
|
||||
data/
|
||||
.data/
|
||||
logs/*
|
||||
test_output.log
|
||||
@@ -110,7 +108,7 @@ open-sse/test/*
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
/cloud/
|
||||
cloud/
|
||||
.tmp/
|
||||
|
||||
# Security Analysis (standalone project with own git)
|
||||
@@ -124,8 +122,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,7 +157,6 @@ vscode-extension/
|
||||
|
||||
# Empty/dangling files
|
||||
typescript
|
||||
/MAX
|
||||
|
||||
# Gemini Antigravity agent data
|
||||
.gemini/
|
||||
@@ -205,16 +200,18 @@ 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/
|
||||
/.source/
|
||||
|
||||
# Temporary local worktrees used to build unpublished npm tarballs
|
||||
/.deploy-build-*/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
.antigravitycli/
|
||||
/.claude/
|
||||
.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/**
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
@@ -229,26 +226,6 @@ CODEX-SETUP-PROMPT.md
|
||||
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
|
||||
config/quality/quality-metrics.json
|
||||
|
||||
# Electron desktop build output unpacked into the repo root.
|
||||
# `electron-builder` (squirrel-windows target) unpacks the packaged app — the
|
||||
# entire Chromium runtime, ~24k files — directly into the repository root.
|
||||
# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/`
|
||||
# or `resources/` would also swallow tracked sources such as the CLI
|
||||
# translations in `bin/cli/locales/*.json`.
|
||||
/OmniRoute.exe
|
||||
/Uninstall OmniRoute.exe
|
||||
/uninstallerIcon.ico
|
||||
/locales/
|
||||
/resources/
|
||||
/*.pak
|
||||
/*.dll
|
||||
/icudtl.dat
|
||||
/snapshot_blob.bin
|
||||
/v8_context_snapshot.bin
|
||||
/vk_swiftshader_icd.json
|
||||
/LICENSE.electron.txt
|
||||
/LICENSES.chromium.html
|
||||
|
||||
# Runtime logs (diretório local, nunca versionado)
|
||||
/logs/
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
|
||||
@@ -267,13 +244,12 @@ _artifacts/ # release-green artifacts
|
||||
# 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/
|
||||
# Isolated Devin bridge workspaces, evidence, and test databases
|
||||
.sandbox/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
@@ -281,18 +257,4 @@ 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/
|
||||
.playwright-cli/
|
||||
|
||||
@@ -87,14 +87,9 @@
|
||||
'''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;
|
||||
# - chave de localStorage do banner de patrocinio (#8723), 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+''',
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v1''',
|
||||
'''SunbreakWebUI1''',
|
||||
]
|
||||
|
||||
@@ -7,7 +7,6 @@ fi
|
||||
|
||||
# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift,
|
||||
# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast.
|
||||
sh scripts/check/check-git-identity.sh
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
|
||||
5867
.i18n-state.json
5867
.i18n-state.json
File diff suppressed because it is too large
Load Diff
40
.mailmap
40
.mailmap
@@ -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>
|
||||
29
.mergify.yml
29
.mergify.yml
@@ -17,13 +17,6 @@
|
||||
# • 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
|
||||
@@ -41,26 +34,14 @@ queue_rules:
|
||||
# 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-failure=0"
|
||||
- "#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.
|
||||
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
|
||||
# don't hold a lone PR hostage waiting for siblings.
|
||||
batch_size: 10
|
||||
batch_max_wait_time: 5 min
|
||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||
merge_method: squash
|
||||
|
||||
|
||||
19
.npmignore
19
.npmignore
@@ -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/
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -14,11 +9,3 @@ open-sse/config/freeModelCatalog.data.ts
|
||||
# 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
|
||||
|
||||
@@ -18,3 +18,5 @@
|
||||
#
|
||||
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
|
||||
# patched base / bump the dep) over suppressing. Stale entries are debt.
|
||||
#
|
||||
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)
|
||||
|
||||
4
@omniroute/opencode-plugin-v2/.gitignore
vendored
4
@omniroute/opencode-plugin-v2/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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/...`.
|
||||
2364
@omniroute/opencode-plugin-v2/package-lock.json
generated
2364
@omniroute/opencode-plugin-v2/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin-v2",
|
||||
"version": "0.1.0",
|
||||
"description": "OmniRoute OpenCode plugin (v2 Promise API): catalog transform with models, combos, enrichment, and naming.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/*.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "1.18.29",
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.3"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "OmniRoute contributors",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute.git",
|
||||
"directory": "@omniroute/opencode-plugin-v2"
|
||||
},
|
||||
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin-v2#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"omniroute",
|
||||
"opencode",
|
||||
"opencode-plugin",
|
||||
"opencode-v2",
|
||||
"ai-sdk",
|
||||
"openai-compatible",
|
||||
"provider",
|
||||
"catalog",
|
||||
"combos",
|
||||
"gemini"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.18.29 <2"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}.`
|
||||
);
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
@@ -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 (0–100, advisory at LCD time). */
|
||||
weight?: number;
|
||||
/** Step-local label, distinct from the parent combo's display name. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface OmniRouteRawCombo {
|
||||
id: string;
|
||||
name?: string;
|
||||
/** Routing strategy. Surfaced for forward-compat but not consumed by LCD. */
|
||||
strategy?: string;
|
||||
/** Member step list. Only `kind: "model"` steps participate in LCD. */
|
||||
models?: OmniRouteRawComboMemberRef[];
|
||||
/** Hidden combos are excluded from the OC model picker. */
|
||||
isHidden?: boolean;
|
||||
/** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */
|
||||
release_date?: string;
|
||||
/**
|
||||
* Server-computed context window for this combo (aggregated from member
|
||||
* models using the same logic as /v1/models). When present, the client
|
||||
* uses this value directly instead of re-aggregating from member models.
|
||||
*
|
||||
* Added in 3.9.x — old servers do not send it.
|
||||
*/
|
||||
computed_context_length?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher contract for `/api/combos`. Same DI shape as
|
||||
* `OmniRouteModelsFetcher` so unit tests can inject a stub instead of
|
||||
* monkey-patching global `fetch`.
|
||||
*/
|
||||
export type OmniRouteCombosFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number
|
||||
) => Promise<OmniRouteRawCombo[]>;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let i = value.length;
|
||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
|
||||
return i === value.length ? value : value.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default fetcher: `GET <baseURL>/api/combos` with bearer auth +
|
||||
* AbortController timeout. Accepts both the `{combos: [...]}` envelope the
|
||||
* gateway emits today and a bare-array envelope (defensive — keeps the
|
||||
* plugin working if a future OmniRoute build trims the wrapper).
|
||||
*
|
||||
* Differences from `defaultOmniRouteModelsFetcher`:
|
||||
* - URL is `/api/combos`, NOT `/v1/combos`. The `/v1/...` namespace is the
|
||||
* OpenAI-compatible surface (chat completions, models); combo discovery
|
||||
* lives on the management plane under `/api/...`. We tolerate both
|
||||
* `https://host` and `https://host/v1` baseURL forms by stripping the
|
||||
* trailing `/v1` segment before appending `/api/combos`.
|
||||
* - Combos endpoint requires a management-scoped API key when
|
||||
* `REQUIRE_API_KEY` is enabled. We don't enforce that here; the
|
||||
* gateway returns 401/403 with an actionable error which we propagate.
|
||||
*
|
||||
* Anything that isn't an object with a string `id` is filtered out silently.
|
||||
*/
|
||||
export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000
|
||||
) => {
|
||||
if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /api/combos");
|
||||
if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /api/combos");
|
||||
|
||||
// Strip trailing slashes, then strip a trailing `/v1` so we land on the
|
||||
// management plane. Models live under `/v1/models`; combos live under
|
||||
// `/api/combos` from the same gateway root.
|
||||
const trimmed = trimTrailingSlashes(baseURL);
|
||||
const root = trimmed.replace(/\/v\d+$/, "");
|
||||
const url = `${root}/api/combos`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const rawList: unknown[] = Array.isArray(body)
|
||||
? body
|
||||
: body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos)
|
||||
? ((body as { combos: unknown[] }).combos as unknown[])
|
||||
: [];
|
||||
const out: OmniRouteRawCombo[] = [];
|
||||
for (const r of rawList) {
|
||||
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
|
||||
out.push(r as OmniRouteRawCombo);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a raw combo entry → `ModelV2` by computing the lowest-common-denominator
|
||||
* (LCD) of its underlying member models. The LCD policy is the only way to
|
||||
* surface a single capability vector to OpenCode without lying: if any member
|
||||
* lacks a capability, the combo as a whole cannot guarantee it.
|
||||
*
|
||||
* LCD rules:
|
||||
* - `limit.context` = `min(...members.context_length)`.
|
||||
* - `limit.output` = `min(...members.max_output_tokens)`.
|
||||
* - `limit.input` = `min(...members.max_input_tokens)` ONLY when every
|
||||
* member declares one (ModelV2.limit.input is optional — better to
|
||||
* omit than to fabricate a min over partial data).
|
||||
* - `capabilities.toolcall` / `reasoning` / `attachment` / `temperature`:
|
||||
* `every(member ⇒ supports?)`. The `reasoning` axis ORs across
|
||||
* `reasoning` and `thinking` per member before AND-ing across the
|
||||
* combo (mirrors `mapRawModelToModelV2`). The `attachment` axis ORs
|
||||
* across `attachment` and `vision` per member. The `temperature` axis
|
||||
* uses default-true semantics: a member supports temperature unless
|
||||
* it explicitly declares `temperature: false`.
|
||||
* - `capabilities.input.*` / `output.*`: flattened AND across members'
|
||||
* modality flags. Missing arrays default to `["text"]` (same default
|
||||
* as `mapRawModelToModelV2`).
|
||||
*
|
||||
* Defensive: empty members array → ALL capabilities `false`, limits zero.
|
||||
* That's an intentional safety posture (you can't route through an empty
|
||||
* combo, so OC should grey it out in the picker).
|
||||
*
|
||||
* Spec mapping: `cost` zeroed; `status = "active"`;
|
||||
* `release_date = combo.release_date ?? ""`;
|
||||
* `api = LCD (all-anthropic else openai-compatible)`;
|
||||
* `name = combo.name ?? combo.id`.
|
||||
*
|
||||
* @param combo Raw `/api/combos` entry.
|
||||
* @param members Raw `/v1/models` entries for THIS combo's member ids.
|
||||
* Caller resolves `combo.models[].model` ids; unknown ids
|
||||
* are silently dropped before this call.
|
||||
* @param providerId OpenCode provider id (multi-instance aware).
|
||||
* @param baseURL Resolved gateway base URL for ModelV2.api.url.
|
||||
*/
|
||||
export function mapComboToModelV2(
|
||||
combo: OmniRouteRawCombo,
|
||||
members: OmniRouteRawModelEntry[],
|
||||
providerId: string,
|
||||
baseURL: string,
|
||||
apiFormat?: ApiFormatV2
|
||||
): ModelV2 {
|
||||
// `every` over an empty array returns true (would lie about an empty
|
||||
// combo's capabilities) — short-circuit to all-false when no members.
|
||||
const hasMembers = members.length > 0;
|
||||
|
||||
const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"]));
|
||||
const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"]));
|
||||
|
||||
const modalityAllHave = (sets: Array<Set<string>>, key: string): boolean =>
|
||||
hasMembers && sets.every((s) => s.has(key));
|
||||
|
||||
const contextValues = members
|
||||
.map((m) => m.context_length)
|
||||
.filter((v): v is number => typeof v === "number" && v > 0);
|
||||
const outputValues = members
|
||||
.map((m) => m.max_output_tokens)
|
||||
.filter((v): v is number => typeof v === "number" && v > 0);
|
||||
const inputValues = members
|
||||
.map((m) => m.max_input_tokens)
|
||||
.filter((v): v is number => typeof v === "number" && v > 0);
|
||||
|
||||
const everyDeclaresInput = hasMembers && inputValues.length === members.length;
|
||||
|
||||
const capabilities: ModelV2["capabilities"] = {
|
||||
temperature:
|
||||
hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
|
||||
reasoning:
|
||||
hasMembers &&
|
||||
members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)),
|
||||
attachment:
|
||||
hasMembers &&
|
||||
members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)),
|
||||
toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)),
|
||||
input: {
|
||||
text: modalityAllHave(memberInMods, "text"),
|
||||
audio: modalityAllHave(memberInMods, "audio"),
|
||||
image: modalityAllHave(memberInMods, "image"),
|
||||
video: modalityAllHave(memberInMods, "video"),
|
||||
pdf: modalityAllHave(memberInMods, "pdf"),
|
||||
},
|
||||
output: {
|
||||
text: modalityAllHave(memberOutMods, "text"),
|
||||
audio: modalityAllHave(memberOutMods, "audio"),
|
||||
image: modalityAllHave(memberOutMods, "image"),
|
||||
video: modalityAllHave(memberOutMods, "video"),
|
||||
pdf: modalityAllHave(memberOutMods, "pdf"),
|
||||
},
|
||||
interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)),
|
||||
};
|
||||
|
||||
// Combos span multiple providers. Use Anthropic format only when ALL
|
||||
// members resolve to Anthropic — otherwise fall back to OpenAI-compat
|
||||
// (lowest common denominator that every upstream understands).
|
||||
const comboApiBlock = (() => {
|
||||
if (!hasMembers) return resolveApiBlockV2(combo.id, baseURL, apiFormat);
|
||||
const allAnthropic = members.every(
|
||||
(m) => resolveApiBlockV2(m.id, baseURL, apiFormat).id === "anthropic"
|
||||
);
|
||||
return allAnthropic
|
||||
? resolveApiBlockV2(members[0].id, baseURL, apiFormat)
|
||||
: resolveApiBlockV2(combo.id, baseURL, apiFormat);
|
||||
})();
|
||||
|
||||
return {
|
||||
id: combo.id,
|
||||
providerID: providerId,
|
||||
api: comboApiBlock,
|
||||
name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id,
|
||||
capabilities,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context:
|
||||
typeof combo.computed_context_length === "number" && combo.computed_context_length > 0
|
||||
? combo.computed_context_length
|
||||
: contextValues.length > 0
|
||||
? Math.min(...contextValues)
|
||||
: 0,
|
||||
...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}),
|
||||
output: outputValues.length > 0 ? Math.min(...outputValues) : 0,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: combo.release_date ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
|
||||
import { parsePluginOptions } from "../src/options.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
|
||||
const GW = "https://gw.example.com";
|
||||
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
|
||||
|
||||
describe("deprecated anthropicPrefixes", () => {
|
||||
it("routes a default prefix id to anthropic with a deprecation warning", async () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("cc/claude-x", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
|
||||
});
|
||||
|
||||
it("warns only once per process for the same prefix list", async () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
resolveApiBlockV2("cc/a", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
|
||||
resolveApiBlockV2("cc/b", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.filter((w) => w.includes("deprecated")).length, 1);
|
||||
});
|
||||
|
||||
it("documents the known claude/openai-compatible-model edge: prefix wins", async () => {
|
||||
// v1 parity keeps prefix matching, so a model literally named
|
||||
// `claude/openai-compatible-model` routes to anthropic when its prefix
|
||||
// is listed. Documented (not silently fixed): operators hitting this
|
||||
// rare name should move the id to the allowlist instead.
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("claude/openai-compatible-model", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
const mapped = mapRawModelToModelV2(
|
||||
{ id: "claude/openai-compatible-model" },
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
apiFormat: {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(mapped.api.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("prefix OR allowlist routes to anthropic; neither means openai-compatible", async () => {
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
assert.equal(
|
||||
resolveApiBlockV2("kiro/m", GW, { allowAnthropic: true, anthropicPrefixes: ["kiro"] }).id,
|
||||
"anthropic"
|
||||
);
|
||||
assert.equal(
|
||||
resolveApiBlockV2("anthropic/claude-x", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
}).id,
|
||||
"anthropic"
|
||||
);
|
||||
assert.equal(resolveApiBlockV2("gpt-x", GW).id, "openai-compatible");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("copied v1 config routes anthropic and warns deprecation through publishCatalog", async () => {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const parsed = parsePluginOptions({
|
||||
baseURL: GW,
|
||||
apiFormat: { allowAnthropic: true, anthropicPrefixes: [...PREFIXES, "v1copy"] },
|
||||
});
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
apiFormat: parsed.apiFormat,
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "cc/claude-x" }],
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
}
|
||||
);
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
const m = models.get("omniroute/cc/claude-x");
|
||||
assert.ok(m);
|
||||
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
assert.equal(m?.api.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
defaultOmniRouteAutoCombosFetcher,
|
||||
mapAutoComboToModelV2,
|
||||
} from "../src/shared/auto-combos.js";
|
||||
|
||||
// A fetch stub that hangs until the caller aborts: proves the AbortController
|
||||
// wiring fires. Rejects with an AbortError like undici does on abort.
|
||||
function hangingFetch(): typeof fetch {
|
||||
return ((url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
});
|
||||
}) as Promise<Response>;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function stubFetch(res: { status: number; statusText?: string; body?: unknown }): typeof fetch {
|
||||
return (async () => ({
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
statusText: res.statusText ?? (res.status === 404 ? "Not Found" : "Error"),
|
||||
json: async () => res.body,
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function silenceWarn(): { warns: string[]; restore: () => void } {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
return {
|
||||
warns,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteAutoCombosFetcher", () => {
|
||||
it("404 returns [] with a warn (old gateway without the endpoint stays fail-open)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ status: 404 });
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
|
||||
assert.deepEqual(res, []);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("404")),
|
||||
`expected a 404 warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("500 throws (with warn + token-hint report) so the caller keeps last-known", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ status: 500, statusText: "Internal Server Error" });
|
||||
const guard = silenceWarn();
|
||||
const reported: string[] = [];
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000, undefined, (e, r) =>
|
||||
reported.push(`${e} ${r}`)
|
||||
),
|
||||
/HTTP 500/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("500")),
|
||||
`expected a 500 warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
assert.ok(
|
||||
reported.some((r) => r.includes("/api/combos/auto")),
|
||||
`expected a per-endpoint report, got: ${JSON.stringify(reported)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("a network throw rejects instead of resolving empty", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 20),
|
||||
/abort|aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto")),
|
||||
`expected an abort warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the {combos:[...]} envelope and filters entries without a string id", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({
|
||||
status: 200,
|
||||
body: { combos: [{ id: "auto/coding", name: "Auto Coding" }, { name: "no-id" }] },
|
||||
});
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
|
||||
assert.deepEqual(res, [{ id: "auto/coding", name: "Auto Coding" }]);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapAutoComboToModelV2", () => {
|
||||
it("falls back to 128k context / 8k output when the server omits limits (never 0)", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", name: "Auto Coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(m.id, "auto/coding");
|
||||
assert.equal(m.limit.context, 128_000);
|
||||
assert.equal(m.limit.output, 8_192);
|
||||
assert.equal(m.capabilities.toolcall, true);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
it("uses server limits when positive, falls back when zero or negative", () => {
|
||||
const served = mapAutoComboToModelV2(
|
||||
{ id: "auto", name: "Auto", context_length: 200000, max_output_tokens: 16000 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(served.limit.context, 200000);
|
||||
assert.equal(served.limit.output, 16000);
|
||||
const zeroed = mapAutoComboToModelV2(
|
||||
{ id: "auto", name: "Auto", context_length: 0, max_output_tokens: -1 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(zeroed.limit.context, 128_000);
|
||||
assert.equal(zeroed.limit.output, 8_192);
|
||||
});
|
||||
|
||||
it("defaults the model id to the auto variant key (auto, auto/coding)", () => {
|
||||
const def = mapAutoComboToModelV2({ id: "whatever" }, "omniroute", "https://gw.example.com");
|
||||
assert.equal(def.id, "auto");
|
||||
const coding = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(coding.id, "auto/coding");
|
||||
});
|
||||
|
||||
it("stamps the openai-compatible api block by default", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto", candidateCount: 3 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes an allowlisted auto id to anthropic (same rule as models)", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com",
|
||||
{ allowAnthropic: true, anthropicModels: ["auto/coding"] }
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapComboToModelV2 } from "../src/shared/combos-map.js";
|
||||
|
||||
const member = (id: string, caps = {}) => ({
|
||||
id,
|
||||
context_length: 100000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: caps,
|
||||
});
|
||||
|
||||
describe("mapComboToModelV2", () => {
|
||||
it("rolls up LCD: min context/output, AND of toolcall", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{
|
||||
id: "combo-a",
|
||||
name: "A",
|
||||
models: [
|
||||
{ kind: "model", model: "a" },
|
||||
{ kind: "model", model: "b" },
|
||||
],
|
||||
},
|
||||
[
|
||||
{
|
||||
...member("a"),
|
||||
context_length: 100000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: { tool_calling: true },
|
||||
},
|
||||
{
|
||||
...member("b"),
|
||||
context_length: 50000,
|
||||
max_output_tokens: 1000,
|
||||
capabilities: { tool_calling: false },
|
||||
},
|
||||
],
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(m.limit.context, 50000);
|
||||
assert.equal(m.limit.output, 1000);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
});
|
||||
it("empty members short-circuit to all-false capabilities", () => {
|
||||
const m = mapComboToModelV2({ id: "combo-empty" }, [], "omniroute", "https://gw.example.com");
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
});
|
||||
it("stamps the api block via resolveApiBlockV2 (openai-compatible by default)", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-a", models: [{ kind: "model", model: "a" }] },
|
||||
[{ ...member("a") }],
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
it("routes an allowlisted combo id to anthropic even with mixed members", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-a", models: [{ kind: "model", model: "gpt-x" }] },
|
||||
[{ ...member("gpt-x") }],
|
||||
"omniroute",
|
||||
"https://gw.example.com",
|
||||
{ allowAnthropic: true, anthropicModels: ["combo-a"] }
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyEnrichment,
|
||||
buildCanonicalToAliasMap,
|
||||
canonicalDedupSet,
|
||||
lookupEnrichment,
|
||||
type OmniRouteEnrichmentMap,
|
||||
} from "../src/shared/enrich.js";
|
||||
import { mapRawModelToModelV2 } from "../src/shared/models-map.js";
|
||||
|
||||
const enrichment: OmniRouteEnrichmentMap = new Map([
|
||||
["cc/model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
|
||||
["model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
|
||||
]);
|
||||
|
||||
describe("lookupEnrichment", () => {
|
||||
it("resolves a canonical id via the alias fallback chain", () => {
|
||||
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
|
||||
assert.equal(canonicalToAlias.get("claude"), "cc");
|
||||
const found = lookupEnrichment("claude/model-x", enrichment, canonicalToAlias);
|
||||
assert.equal(found?.name, "Model X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyEnrichment", () => {
|
||||
it("overlays the enrichment display name onto the model", () => {
|
||||
const model = mapRawModelToModelV2(
|
||||
{ id: "cc/model-x" },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
applyEnrichment(model, { name: "Model X" });
|
||||
assert.equal(model.name, "Model X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalDedupSet", () => {
|
||||
it("drops the canonical twin when the alias row exists", () => {
|
||||
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
|
||||
const drop = canonicalDedupSet(
|
||||
[{ id: "cc/model-x" }, { id: "claude/model-x" }],
|
||||
canonicalToAlias
|
||||
);
|
||||
assert.ok(drop.has("claude/model-x"));
|
||||
assert.ok(!drop.has("cc/model-x"));
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
function stubFetch(handler: (url: string) => unknown): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const body = handler(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteEnrichmentFetcher", () => {
|
||||
it("merges display names and pricing from the two catalog sources", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch((url) =>
|
||||
url.endsWith("/api/pricing/models")
|
||||
? {
|
||||
cc: {
|
||||
id: "claude",
|
||||
alias: "cc",
|
||||
name: "Claude",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
}
|
||||
: url.endsWith("/api/pricing")
|
||||
? { cc: { m1: { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 } } }
|
||||
: { perModel: [] }
|
||||
);
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
const namespaced = map.get("cc/m1");
|
||||
assert.ok(namespaced);
|
||||
assert.equal(namespaced?.name, "Model One");
|
||||
assert.equal(namespaced?.providerAlias, "cc");
|
||||
assert.equal(namespaced?.providerCanonical, "claude");
|
||||
assert.deepEqual(namespaced?.pricing, {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cacheRead: 0.3,
|
||||
cacheWrite: 3.75,
|
||||
});
|
||||
assert.deepEqual(map.get("m1")?.pricing, namespaced?.pricing);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails per source: a dead pricing endpoint still returns catalog names", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.endsWith("/api/pricing")) throw new Error("boom");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] },
|
||||
}),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.equal(map.get("cc/m1")?.name, "Model One");
|
||||
assert.equal(map.get("cc/m1")?.pricing, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails per source: a dead catalog endpoint still returns pricing", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.endsWith("/api/pricing/models")) {
|
||||
return { ok: false, status: 500, statusText: "err", json: async () => ({}) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ cc: { m1: { input: 1, output: 2 } } }),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.deepEqual(map.get("cc/m1")?.pricing, { input: 1, output: 2 });
|
||||
assert.equal(map.get("cc/m1")?.name, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches free-tier budgets from the third source", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch((url) =>
|
||||
url.endsWith("/api/pricing/models")
|
||||
? { cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] } }
|
||||
: url.endsWith("/api/pricing")
|
||||
? {}
|
||||
: {
|
||||
perModel: [
|
||||
{ provider: "cc", modelId: "m1", freeType: "monthly", monthlyTokens: 100 },
|
||||
],
|
||||
}
|
||||
);
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.equal(map.get("cc/m1")?.freeType, "monthly");
|
||||
assert.equal(map.get("cc/m1")?.monthlyTokens, 100);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty map without credentials instead of throwing", async () => {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "", 1000);
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("enrichment source failures are reported", () => {
|
||||
it("names each endpoint a gateway refuses, then throws so the caller keeps last-known", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("forbidden", { status: 403 })) as unknown as typeof globalThis.fetch;
|
||||
const seen: Array<[string, string]> = [];
|
||||
await assert.rejects(
|
||||
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (endpoint, reason) =>
|
||||
seen.push([endpoint, reason])
|
||||
),
|
||||
/enrichment (catalog source|sources) failed/
|
||||
);
|
||||
assert.deepEqual(seen.map(([endpoint]) => endpoint).sort(), [
|
||||
"/api/free-tier/summary",
|
||||
"/api/pricing",
|
||||
"/api/pricing/models",
|
||||
]);
|
||||
for (const [, reason] of seen) assert.match(reason, /403/);
|
||||
});
|
||||
|
||||
it("reports a transport failure with its cause, then throws", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("connect ECONNREFUSED");
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
await assert.rejects(
|
||||
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (_e, reason) =>
|
||||
seen.push(reason)
|
||||
),
|
||||
/enrichment (catalog source|sources) failed/
|
||||
);
|
||||
assert.equal(seen.length, 3);
|
||||
for (const reason of seen) assert.match(reason, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it("stays silent when every source answers", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (e) =>
|
||||
seen.push(e)
|
||||
);
|
||||
assert.deepEqual(seen, []);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteModelsFetcher } from "../src/shared/models-map.js";
|
||||
import { defaultOmniRouteCombosFetcher } from "../src/shared/combos-map.js";
|
||||
|
||||
// A fetch stub that hangs until the caller aborts: proves the AbortController
|
||||
// wiring fires. Rejects with an AbortError like undici does on abort.
|
||||
function hangingFetch(): typeof fetch {
|
||||
return ((url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
});
|
||||
}) as Promise<Response>;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("shared default fetchers abort on timeout", () => {
|
||||
it("models fetcher aborts a hanging request after timeoutMs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteModelsFetcher("https://gw.example.com", "k", 20),
|
||||
/aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("combos fetcher aborts a hanging request after timeoutMs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteCombosFetcher("https://gw.example.com", "k", 20),
|
||||
/aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { catalogContentFingerprint, optionalTierFingerprint } from "../src/shared/fingerprint.js";
|
||||
|
||||
describe("catalogContentFingerprint", () => {
|
||||
it("returns the same hash for two identical catalogs", () => {
|
||||
const models = [
|
||||
{ id: "b", release_date: "2026-01-02" },
|
||||
{ id: "a", release_date: "2026-01-01" },
|
||||
];
|
||||
const combos = [{ id: "combo-b" }, { id: "combo-a" }];
|
||||
assert.equal(
|
||||
catalogContentFingerprint(models, combos),
|
||||
catalogContentFingerprint([...models].reverse(), [...combos].reverse())
|
||||
);
|
||||
});
|
||||
it("returns a different hash when one model id changes", () => {
|
||||
const combos = [{ id: "combo-a" }];
|
||||
const before = catalogContentFingerprint([{ id: "a" }], combos);
|
||||
const after = catalogContentFingerprint([{ id: "a2" }], combos);
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
it("returns a different hash when the auto-combos set changes", () => {
|
||||
const models = [{ id: "a" }];
|
||||
const combos = [{ id: "combo-a" }];
|
||||
const before = catalogContentFingerprint(models, combos, [{ id: "auto" }]);
|
||||
const after = catalogContentFingerprint(models, combos, [
|
||||
{ id: "auto" },
|
||||
{ id: "auto/coding" },
|
||||
]);
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionalTierFingerprint", () => {
|
||||
it("moves on a pricing-only change, so stale prices reach the picker", () => {
|
||||
const priced = (input: number) =>
|
||||
new Map([["cc/m1", { name: "M1", pricing: { input, output: 1 } }]]);
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], [], priced(3)),
|
||||
optionalTierFingerprint([], [], priced(99))
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a combo loses a member without changing id", () => {
|
||||
const one = [{ id: "combo-a", name: "A", models: [{ model: "m1" }] }];
|
||||
const two = [{ id: "combo-a", name: "A", models: [{ model: "m1" }, { model: "m2" }] }];
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], [], undefined, one),
|
||||
optionalTierFingerprint([], [], undefined, two)
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a provider goes quiet or gets renamed", () => {
|
||||
const active = [{ id: "c1", testStatus: "active", isActive: true }];
|
||||
const quiet = [{ id: "c1", testStatus: "active", isActive: false }];
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], active, undefined),
|
||||
optionalTierFingerprint([], quiet, undefined)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isGeminiModelId, sanitizeToolInputSchemas } from "../src/shared/gemini.js";
|
||||
|
||||
describe("Gemini tool-schema sanitising", () => {
|
||||
it("recognises the routing forms a Gemini model arrives under", () => {
|
||||
for (const id of [
|
||||
"gemini-2.5-flash",
|
||||
"models/gemini-1.5-pro",
|
||||
"google-vertex/gemini-2.0",
|
||||
"GEMINI-PRO",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), true, id);
|
||||
}
|
||||
for (const id of ["gpt-5", "claude-opus-5", "", "gemma-2-9b"]) {
|
||||
assert.equal(isGeminiModelId(id), false, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips the keywords Gemini rejects, however deep they sit", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "edit",
|
||||
inputSchema: {
|
||||
$schema: "https://json-schema.org/draft-07/schema",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
nested: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: { deep: { type: "string" } },
|
||||
},
|
||||
list: { type: "array", items: [{ type: "object", additionalProperties: false }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const out = sanitizeToolInputSchemas(tools);
|
||||
assert.notEqual(out, undefined, "a payload that needed cleaning must come back changed");
|
||||
const schema = out![0]!.inputSchema as Record<string, any>;
|
||||
assert.equal("$schema" in schema, false);
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.equal("additionalProperties" in schema["properties"]["nested"], false);
|
||||
assert.equal("additionalProperties" in schema["properties"]["list"]["items"][0], false);
|
||||
// What the schema means must survive the cleaning.
|
||||
assert.equal(schema["properties"]["path"]["type"], "string");
|
||||
assert.equal(schema["properties"]["nested"]["properties"]["deep"]["type"], "string");
|
||||
});
|
||||
|
||||
it("leaves the caller's tools untouched", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "t",
|
||||
inputSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
];
|
||||
sanitizeToolInputSchemas(tools);
|
||||
assert.equal((tools[0]!.inputSchema as Record<string, unknown>)["additionalProperties"], false);
|
||||
});
|
||||
|
||||
it("reports nothing to do rather than cloning a clean payload", () => {
|
||||
assert.equal(
|
||||
sanitizeToolInputSchemas([
|
||||
{ type: "function" as const, name: "t", inputSchema: { type: "object", properties: {} } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
assert.equal(sanitizeToolInputSchemas(undefined), undefined);
|
||||
assert.equal(sanitizeToolInputSchemas([]), undefined);
|
||||
});
|
||||
|
||||
it("walks past a provider tool it cannot read without dropping it", () => {
|
||||
const tools = [
|
||||
{ type: "provider" as const, id: "p.search", name: "search", args: {} },
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "t",
|
||||
inputSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
];
|
||||
const out = sanitizeToolInputSchemas(tools as never);
|
||||
assert.equal(out?.length, 2);
|
||||
assert.deepEqual(out![0], tools[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sanitiser repairs schemas without mangling them", () => {
|
||||
it("keeps a tool parameter that happens to be named like a keyword", () => {
|
||||
const out = sanitizeToolInputSchemas([
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "checkout",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ref"],
|
||||
properties: {
|
||||
ref: { type: "string", description: "a git ref" },
|
||||
additionalProperties: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
const schema = out![0]!.inputSchema as Record<string, any>;
|
||||
// The keyword goes; the parameters keep their names, or `required` would
|
||||
// point at a property the model can no longer send.
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.deepEqual(Object.keys(schema["properties"]).sort(), ["additionalProperties", "ref"]);
|
||||
assert.deepEqual(schema["required"], ["ref"]);
|
||||
});
|
||||
|
||||
it("forwards a $ref schema untouched rather than widening it to anything", () => {
|
||||
assert.equal(
|
||||
sanitizeToolInputSchemas([
|
||||
{ type: "function" as const, name: "t", inputSchema: { $ref: "#/$defs/x" } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises the Gemini families, and only those", () => {
|
||||
for (const id of [
|
||||
"gemini",
|
||||
"gemini-2.5-flash",
|
||||
"models/gemini-1.5-pro",
|
||||
"google-vertex/gemini-2.0",
|
||||
"GEMINI-3-PRO",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), true, id);
|
||||
}
|
||||
for (const id of [
|
||||
"gemini-compatible-proxy",
|
||||
"my-gemini-wrapper",
|
||||
"openai/gpt-5-gemini-eval",
|
||||
"gemma-2-9b",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), false, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLogger, getLogLevel } from "../src/shared/logger.js";
|
||||
|
||||
function capture(): {
|
||||
warns: string[];
|
||||
errors: string[];
|
||||
restore: () => void;
|
||||
} {
|
||||
const warns: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(args.map(String).join(" "));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
return {
|
||||
warns,
|
||||
errors,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shared leveled logger (v1 parity)", () => {
|
||||
it("default level is warn", () => {
|
||||
assert.equal(getLogLevel(), "warn");
|
||||
});
|
||||
|
||||
it("warn logger emits error+warn, suppresses info+debug", () => {
|
||||
const log = createLogger("warn");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 1);
|
||||
});
|
||||
|
||||
it("error logger emits only error", () => {
|
||||
const log = createLogger("error");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 0);
|
||||
});
|
||||
|
||||
it("info logger emits error+warn+info, suppresses debug", () => {
|
||||
const log = createLogger("info");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 2);
|
||||
});
|
||||
|
||||
it("debug logger emits everything", () => {
|
||||
const log = createLogger("debug");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 3);
|
||||
});
|
||||
|
||||
it("lines carry the namespace tag and the uppercased level", () => {
|
||||
const log = createLogger("debug");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.warn("hello");
|
||||
log.error("boom");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.match(cap.warns[0], /\[omniroute-plugin\] \[WARN\] hello/);
|
||||
assert.match(cap.errors[0], /\[omniroute-plugin\] \[ERROR\] boom/);
|
||||
});
|
||||
|
||||
it("child loggers append their tag after the namespace", () => {
|
||||
const log = createLogger("debug").child("[v2]");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.info("hello");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.match(cap.warns[0], /\[omniroute-plugin\]\[v2\] \[INFO\] hello/);
|
||||
});
|
||||
|
||||
it("always() emits regardless of level", () => {
|
||||
const log = createLogger("error");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.always("breadcrumb");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.warns.length, 1);
|
||||
assert.match(cap.warns[0], /breadcrumb/);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
|
||||
|
||||
describe("resolveApiBlockV2", () => {
|
||||
it("routes unknown ids to openai-compatible with /v1 suffix", () => {
|
||||
assert.deepEqual(resolveApiBlockV2("gpt-x", "https://gw.example.com"), {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
it("does not double the /v1 suffix", () => {
|
||||
assert.equal(
|
||||
resolveApiBlockV2("gpt-x", "https://gw.example.com/v1").url,
|
||||
"https://gw.example.com/v1"
|
||||
);
|
||||
});
|
||||
it("routes allowlisted full id to anthropic even with claude-like prefix elsewhere", () => {
|
||||
const out = resolveApiBlockV2("anthropic/claude-x", "https://gw.example.com/", {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
assert.deepEqual(out, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
it("keeps a non-allowlisted id on openai-compatible when prefixes are emptied", () => {
|
||||
const out = resolveApiBlockV2("claude/openai-compatible-model", "https://gw.example.com", {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
anthropicPrefixes: [],
|
||||
});
|
||||
assert.equal(out.id, "openai-compatible");
|
||||
});
|
||||
it("routes a deprecated-prefix id to anthropic with a deprecation warning", () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("cc/claude-x", "https://gw.example.com", {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: ["cc"],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRawModelToModelV2", () => {
|
||||
it("prefixes bare ids, keeps slashed ids, maps capabilities", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{
|
||||
id: "gpt-x",
|
||||
context_length: 128000,
|
||||
max_output_tokens: 4096,
|
||||
input_modalities: ["text", "image"],
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.equal(m.id, "omniroute/gpt-x");
|
||||
assert.equal(m.providerID, "omniroute");
|
||||
assert.equal(m.capabilities.toolcall, true);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
assert.equal(m.capabilities.input.image, true);
|
||||
assert.equal(m.limit.context, 128000);
|
||||
assert.equal(m.limit.output, 4096);
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
assert.equal(m.status, "active");
|
||||
});
|
||||
it("maps effort_tiers to variants, omits key when absent", () => {
|
||||
const withTiers = mapRawModelToModelV2(
|
||||
{ id: "r1", capabilities: { effort_tiers: ["low", "high"] } },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.deepEqual(withTiers.variants, {
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
});
|
||||
const without = mapRawModelToModelV2(
|
||||
{ id: "r2" },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.ok(!("variants" in without));
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Tests for `formatFreeBudget` (@omniroute/opencode-plugin/src/naming.ts):
|
||||
* formats a free-tier model's budget info into a short human-readable
|
||||
* suffix, branching on `freeType`.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatFreeBudget, type FreeModelFreeType } from "../src/shared/naming.js";
|
||||
|
||||
test("formatFreeBudget: recurring-daily formats tokens/day", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 25_000_000 }),
|
||||
"25M tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-monthly formats tokens/month", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 1_000_000 }),
|
||||
"1M tokens/month"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-credit formats credits", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 10_000_000 }),
|
||||
"10M credits"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: one-time-initial formats credits with (one-time) suffix", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 1_000_000 }),
|
||||
"1M credits (one-time)"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: keyless has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "keyless" }), "(keyless)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: discontinued has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "discontinued" }), "(discontinued)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: missing token/credit counts default to 0", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "recurring-daily" }), "0 tokens/day");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: unrecognised freeType falls through to the default branch", () => {
|
||||
// `freeType` is populated from catalog data at runtime, so a value the
|
||||
// build doesn't know about is reachable even though TypeScript treats the
|
||||
// `default:` arm as dead code for a well-typed caller.
|
||||
assert.equal(formatFreeBudget({ freeType: "some-future-type" as FreeModelFreeType }), "");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: sub-1K token count is not abbreviated", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 500 }),
|
||||
"500 tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: the 999_999 rounding wart is fixed — promotes to 1M", () => {
|
||||
// `toFixed(1)` rounds 999999/1e3 up to "1000.0" before the `>= 1e6` threshold
|
||||
// check has a chance to apply. fmtTokens now promotes a rounded-up "1000" in
|
||||
// any unit to the next unit up, so this correctly reads "1M" instead of the
|
||||
// old "1000K" wart.
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 999_999 }),
|
||||
"1M tokens/day"
|
||||
);
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
defaultOmniRouteProvidersFetcher,
|
||||
isUsableCombo,
|
||||
isUsableRawModelId,
|
||||
usableProviderAliasSet,
|
||||
} from "../src/shared/usable.js";
|
||||
import type { OmniRouteEnrichmentMap } from "../src/shared/enrich.js";
|
||||
|
||||
function entry(
|
||||
alias: string,
|
||||
canonical: string
|
||||
): [string, { providerAlias: string; providerCanonical: string }] {
|
||||
return [`${alias}/model-x`, { providerAlias: alias, providerCanonical: canonical }];
|
||||
}
|
||||
|
||||
function enrichmentOf(...pairs: Array<[string, string]>): OmniRouteEnrichmentMap {
|
||||
return new Map(pairs.map(([alias, canonical]) => entry(alias, canonical)));
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: unknown) => unknown): typeof fetch {
|
||||
return (async (url: unknown) => handler(url)) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteProvidersFetcher envelopes", () => {
|
||||
it("reads the { connections: [...] } envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
globalThis.fetch = stubFetch((url) => {
|
||||
seen.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
connections: [{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
}),
|
||||
};
|
||||
});
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com/v1/", "k");
|
||||
assert.equal(seen[0], "https://gw.example.com/api/providers");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["claude"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a bare array envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => [{ id: "c1", provider: "gemini", isActive: true, testStatus: "active" }],
|
||||
}));
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["gemini"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the { data: [...] } envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
data: [{ id: "c1", provider: "kiro", isActive: true, testStatus: "active" }],
|
||||
}),
|
||||
}));
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["kiro"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on non-2xx so the caller keeps last-known instead of unfiltering", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Error",
|
||||
json: async () => ({}),
|
||||
}));
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"),
|
||||
/HTTP 500/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on network failure so the caller keeps last-known", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => {
|
||||
throw new Error("down");
|
||||
});
|
||||
try {
|
||||
await assert.rejects(defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"), /down/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails to [] without baseURL or apiKey", async () => {
|
||||
assert.deepEqual(await defaultOmniRouteProvidersFetcher("", "k"), []);
|
||||
assert.deepEqual(await defaultOmniRouteProvidersFetcher("https://gw.example.com", ""), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usableProviderAliasSet", () => {
|
||||
it("maps usable canonicals to aliases via enrichment", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"])
|
||||
);
|
||||
assert.ok(usable.aliases.has("cc"));
|
||||
assert.ok(usable.aliases.has("claude"));
|
||||
assert.ok(usable.canonicals.has("claude"));
|
||||
assert.ok(usable.knownAliases.has("cc"));
|
||||
});
|
||||
|
||||
it("ignores inactive or unhealthy connections", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[
|
||||
{ id: "c1", provider: "claude", isActive: false, testStatus: "active" },
|
||||
{ id: "c2", provider: "gemini", isActive: true, testStatus: "error" },
|
||||
],
|
||||
enrichmentOf(["cc", "claude"], ["gm", "gemini"])
|
||||
);
|
||||
assert.equal(usable.aliases.size, 0);
|
||||
assert.equal(usable.canonicals.size, 0);
|
||||
assert.ok(usable.knownAliases.has("cc"));
|
||||
assert.ok(usable.knownAliases.has("gm"));
|
||||
});
|
||||
|
||||
it("treats a missing toggle as no opinion, not disabled", () => {
|
||||
// Older gateways predate the `isActive` field: reading its absence as a
|
||||
// veto would hide every model behind a filter the operator never asked
|
||||
// to tighten. Only an explicit `false` disables.
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"])
|
||||
);
|
||||
assert.ok(usable.aliases.has("cc"));
|
||||
assert.ok(usable.canonicals.has("claude"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsableRawModelId subtract-filter", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
|
||||
);
|
||||
|
||||
it("keeps ids with a usable alias prefix", () => {
|
||||
assert.equal(isUsableRawModelId("cc/claude-opus-4-7", usable), true);
|
||||
});
|
||||
|
||||
it("keeps ids with a usable canonical prefix", () => {
|
||||
assert.equal(isUsableRawModelId("claude/sonnet-4", usable), true);
|
||||
});
|
||||
|
||||
it("keeps ids with an unknown prefix", () => {
|
||||
assert.equal(isUsableRawModelId("agentrouter/mystery", usable), true);
|
||||
});
|
||||
|
||||
it("drops ids with a known-but-not-provisioned prefix", () => {
|
||||
assert.equal(isUsableRawModelId("dead/legacy-model", usable), false);
|
||||
});
|
||||
|
||||
it("keeps ids without a prefix", () => {
|
||||
assert.equal(isUsableRawModelId("bare-model", usable), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsableCombo", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
|
||||
);
|
||||
|
||||
it("keeps a combo with one usable member", () => {
|
||||
assert.equal(
|
||||
isUsableCombo(
|
||||
{
|
||||
id: "c",
|
||||
models: [
|
||||
{ kind: "model", model: "dead/legacy" },
|
||||
{ kind: "model", model: "cc/x" },
|
||||
],
|
||||
},
|
||||
usable
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a combo whose members are all known-but-not-provisioned", () => {
|
||||
assert.equal(
|
||||
isUsableCombo(
|
||||
{
|
||||
id: "c",
|
||||
models: [
|
||||
{ kind: "model", model: "dead/a" },
|
||||
{ kind: "model", model: "dead/b" },
|
||||
],
|
||||
},
|
||||
usable
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a combo with an unknown-prefix member", () => {
|
||||
assert.equal(
|
||||
isUsableCombo({ id: "c", models: [{ kind: "model", model: "agentrouter/mystery" }] }, usable),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps combos with no resolvable members", () => {
|
||||
assert.equal(isUsableCombo({ id: "c", models: [] }, usable), true);
|
||||
assert.equal(isUsableCombo({ id: "c" }, usable), true);
|
||||
assert.equal(
|
||||
isUsableCombo({ id: "c", models: [{ kind: "combo-ref", comboName: "nested" }] }, usable),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft, PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
describe("v2 contract smoke", () => {
|
||||
it("default export has string id and function setup", () => {
|
||||
assert.equal(typeof (plugin as { id: unknown }).id, "string");
|
||||
assert.equal(typeof (plugin as { setup: unknown }).setup, "function");
|
||||
});
|
||||
it("setup registers transforms against a structurally-real ctx", async () => {
|
||||
const seen: string[] = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
|
||||
catalog: {
|
||||
transform: async () => {
|
||||
seen.push("catalog.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: {
|
||||
transform: async () => {
|
||||
seen.push("integration.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
connection: { active: async () => undefined, resolve: async () => undefined },
|
||||
},
|
||||
agent: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
command: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
reference: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
skill: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
aisdk: {
|
||||
sdk: async () => ({ dispose: async () => {} }),
|
||||
language: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
plugin: { add: async () => {}, remove: async () => {} },
|
||||
} satisfies PluginContext;
|
||||
await (plugin as { setup: (c: PluginContext) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(seen, ["catalog.transform", "integration.transform"]);
|
||||
});
|
||||
it("publishCatalog writes into a real CatalogDraft without proxy breakage", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const written: { provider?: string; models: string[] } = { models: [] };
|
||||
const draft: CatalogDraft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: () => undefined,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
written.provider = id;
|
||||
const p = {
|
||||
id,
|
||||
name: "",
|
||||
api: { type: "aisdk", package: "" },
|
||||
request: { headers: {}, body: {} },
|
||||
} as ProviderV2Info;
|
||||
fn(p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (providerID: string, modelID: string, fn: (d: ModelV2Info) => void) => {
|
||||
written.models.push(providerID + "/" + modelID);
|
||||
const d = { id: modelID, providerID } as ModelV2Info;
|
||||
fn(d);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
};
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{
|
||||
models: async () => [{ id: "m1", context_length: 1000 }],
|
||||
combos: async () => [],
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 1);
|
||||
assert.equal(written.provider, "omniroute");
|
||||
assert.deepEqual(written.models, ["omniroute/m1"]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user