mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
Compare commits
73 Commits
fix/audio-
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26f03dfa7e | ||
|
|
0d5d498206 | ||
|
|
19229bb817 | ||
|
|
7b7470fcdd | ||
|
|
57d9f64b88 | ||
|
|
fbcc43ce89 | ||
|
|
f124806372 | ||
|
|
7ef34c2246 | ||
|
|
09d8a4aa8c | ||
|
|
548f1fe56f | ||
|
|
4b34a75fe3 | ||
|
|
69c35bd342 | ||
|
|
05c0c4e400 | ||
|
|
3ffd6ffff1 | ||
|
|
9746b206db | ||
|
|
20099f7ff2 | ||
|
|
3ff08e2e14 | ||
|
|
66218036fd | ||
|
|
39b2bbfea2 | ||
|
|
6b5590e8f4 | ||
|
|
cf6546e6d3 | ||
|
|
448741fe5f | ||
|
|
9447de08b7 | ||
|
|
8fc331513d | ||
|
|
0b402e9bf3 | ||
|
|
8273dfd370 | ||
|
|
059b7efeb3 | ||
|
|
eb47b92f22 | ||
|
|
4d3a9fd3df | ||
|
|
2b21bb60d9 | ||
|
|
f91cf72c16 | ||
|
|
9ade3e5f52 | ||
|
|
9cf96d0e9f | ||
|
|
948213899f | ||
|
|
2601a557b8 | ||
|
|
b28f6e9346 | ||
|
|
aa019bbb95 | ||
|
|
96d7f2ac62 | ||
|
|
34d9335c5f | ||
|
|
840413faa2 | ||
|
|
bb16f88524 | ||
|
|
d83e0a4fab | ||
|
|
4cf73428f8 | ||
|
|
89606a2bfe | ||
|
|
247e578778 | ||
|
|
dac9f7de16 | ||
|
|
bb8104f454 | ||
|
|
3a0c40bb5e | ||
|
|
a37ee2fac5 | ||
|
|
cd45943015 | ||
|
|
4279751abd | ||
|
|
e1546c0118 | ||
|
|
482433e44f | ||
|
|
81016bdaeb | ||
|
|
aee1f7445d | ||
|
|
58d1cc8f03 | ||
|
|
185c474ee0 | ||
|
|
f3253ee706 | ||
|
|
bb4aa0d793 | ||
|
|
5b15e0192e | ||
|
|
cda583114f | ||
|
|
c5f3d5fb56 | ||
|
|
694adf7e13 | ||
|
|
d3f1b23a93 | ||
|
|
69f63898de | ||
|
|
39592420e4 | ||
|
|
296b72d070 | ||
|
|
75a84b055f | ||
|
|
335df93d85 | ||
|
|
0299907a87 | ||
|
|
ac65652ed9 | ||
|
|
84626f6b04 | ||
|
|
d2088ba97c |
201
.cbmignore
201
.cbmignore
@@ -1,201 +0,0 @@
|
||||
# codebase-memory-mcp ignore list
|
||||
#
|
||||
# Padrão gitignore-style. Linhas começando com `#` são comentários.
|
||||
# Barra final (`/`) = só diretório. Sem barra = casa arquivo OU diretório.
|
||||
#
|
||||
# O CBM também lê `.gitignore` automaticamente — esta lista deixa explícito o que
|
||||
# os hooks do CBM vão pular. Se uma regra entrar em conflito entre os dois arquivos,
|
||||
# vale a união. Editar este arquivo é mais barato do que confiar na herança implícita.
|
||||
#
|
||||
# Última reconciliação: 2026-07-31, status `ready` (513k nodes / 689k edges),
|
||||
# `auto_index_limit=50000`, total indexável medido ≈11.546 arquivos (folga 4,3×).
|
||||
#
|
||||
# Fontes cruzadas:
|
||||
# - `codebase-memory-mcp cli index_status --project home-diegosouzapw-dev-proxys-OmniRoute`
|
||||
# → `not_indexed.dirs` (27) + `not_indexed.files` (336), todos `BY DESIGN`.
|
||||
# - `.gitignore` deste repo (5.691 B) — fonte canônica secundária.
|
||||
#
|
||||
# Como auditar mudanças: depois de editar este arquivo, rodar `index_repository`
|
||||
# (ou esperar `auto_watch` re-indexar) e re-checar `cli index_status` → comparar
|
||||
# contagens em `not_indexed.dirs_count` e `not_indexed.files_count`.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Diretorios de runtime / pacote — nao sao codigo-fonte
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
node_modules
|
||||
|
||||
# Builds e artefatos reproduziveis (Layer 1 Next.js / Electron)
|
||||
.build/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Electron especifico
|
||||
electron/dist-electron/
|
||||
electron/node_modules/
|
||||
icon.iconset/
|
||||
|
||||
# Workspaces internos que tem proprio node_modules
|
||||
@omniroute/opencode-plugin/dist/
|
||||
@omniroute/opencode-plugin/node_modules/
|
||||
@omniroute/opencode-provider/dist/
|
||||
@omniroute/opencode-provider/node_modules/
|
||||
|
||||
# Recursos nativos compilados (C/JNI/wasm)
|
||||
src/mitm/tproxy/native/build/
|
||||
|
||||
# Artefatos locais do Stryker / Playwright / coverage
|
||||
.stryker-tmp/
|
||||
reports/mutation/
|
||||
stryker-output-*.json
|
||||
.playwright-mcp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Analise / linters / caches
|
||||
.analysis/
|
||||
.sisyphus/
|
||||
.plans/
|
||||
.gitnexus
|
||||
.worktrees
|
||||
.codegraph/
|
||||
|
||||
# Quality artifacts (gerados por npm run lint --cache etc)
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
|
||||
# Claude Code local state
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/scheduled_tasks/
|
||||
.claude/sessions/
|
||||
.claude/state.json
|
||||
.claude/settings.local.json
|
||||
|
||||
# Serena / Antigravity / outras tools locais
|
||||
.serena/
|
||||
.antigravitycli/
|
||||
.gemini/
|
||||
.config/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Diretorios com prefixo `_` — locais / privados (regra global do .gitignore)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_*/
|
||||
_artifacts/
|
||||
_cache/
|
||||
_mono_repo/
|
||||
_references/
|
||||
_tasks/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. Diretorios de tooling IA (state local, nao codigo)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
.agents/
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
.junie/
|
||||
.omc/
|
||||
.data/
|
||||
.data-dev/
|
||||
.local-data/
|
||||
.logs/
|
||||
.artifacts/
|
||||
.source/
|
||||
.superpowers/
|
||||
.claude-flow/
|
||||
.omnivscodeagent/
|
||||
omnirouteCloud/
|
||||
omnirouteSite/
|
||||
.omniroute/
|
||||
.stent/
|
||||
|
||||
# Subpaths especificos do Claude Code que nao estao em .claude/ (criados sob repo)
|
||||
.claude/worktrees/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. Diretorios de dados / runtime locais (storage, env, secrets, scratch)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
data/
|
||||
# NOTA: src/lib/env/, src/app/api/{cloud,sync/cloud,system/env,agent-skills/coverage}/
|
||||
# foram removidos daqui (2026-08-05). Os nomes sugerem dados/segredos locais, mas os
|
||||
# 8 arquivos sao route handlers e modulos rastreados no git — escondia-los do grafo
|
||||
# criava pontos cegos em buscas e em analise de impacto.
|
||||
tests/golden-set/data/
|
||||
|
||||
# Logs e saida de teste
|
||||
logs/*
|
||||
test_output.log
|
||||
home-diegosouzapw-dev-automacoes-*.txt
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. Diretorios do monorepo por subprojeto (nao fazem parte do app principal)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
security-analysis/
|
||||
vscode-extension/
|
||||
obsidian-plugin/node_modules/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 6. Diretorios de documentacao interna / workflow
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
docs/superpowers/
|
||||
# Docs traduzidas: 1.215 arquivos / 94 MB (inclui 20+ copias do CHANGELOG).
|
||||
# Sao traducoes do tree em ingles, ja indexado — no grafo so geram ruido em
|
||||
# search_code e consomem o auto_index_limit.
|
||||
docs/i18n/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 7. Arquivos especificos (nao diretorios inteiros)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Segredos e env — NUNCA indexar
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.homolog.example
|
||||
|
||||
# TypeScript build info e next env declaration
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
typescript
|
||||
|
||||
# SQLite transient files (WAL/SHM/journal)
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.sqlite-journal
|
||||
|
||||
# Mapas e source maps
|
||||
*.map
|
||||
|
||||
# Bun / npm lockfiles ruidosos
|
||||
bun.lock
|
||||
|
||||
# `cheaper-inference-gateway.svg` e arquivos de midia na raiz/asset ja cobertos
|
||||
# pelos `ignored-suffix` do indexador (svg/png/jpg/ico/etc >50kB ou >500linhas);
|
||||
# manter a regra explicita aqui ajuda a auditar:
|
||||
cheaper-inference-gateway.svg
|
||||
cheaper-inference-gateway-*.svg
|
||||
|
||||
# Husky internals
|
||||
.husky/_/
|
||||
|
||||
# CI / quality metric artifacts
|
||||
config/quality/quality-metrics.json
|
||||
config/quality/test-impact-map.json
|
||||
audit-report.json
|
||||
.gh-discussions.json
|
||||
|
||||
# i18n audit (gerado por npm run scripts)
|
||||
scripts/i18n/_audit.json
|
||||
scripts/i18n/_pending-keys.json
|
||||
|
||||
# NOTA: bin/omniroute.mjs foi removido daqui (2026-08-05). Estava marcado como
|
||||
# "scratch", mas e o entrypoint real do CLI publicado (package.json -> bin.omniroute)
|
||||
# e consta em PACK_ARTIFACT_REQUIRED_PATHS. Precisa estar no grafo.
|
||||
|
||||
# Deploy / docker backups
|
||||
deploy.sh
|
||||
docker-compose.yml.bak
|
||||
docker-compose.minimal.yml
|
||||
@@ -7,13 +7,7 @@
|
||||
**/.vscode
|
||||
|
||||
# Dependencies and build output
|
||||
# `node_modules` alone matches the ROOT only — Docker's matcher does not cross
|
||||
# `/` like .gitignore does. Without the `**/` form, nested installs ship in the
|
||||
# build context (e.g. @omniroute/opencode-provider/node_modules, ~79 MB of
|
||||
# devDependencies). Both forms are kept: the bare one is the documented root
|
||||
# rule, the `**/` one covers every nested package.
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
.build
|
||||
out
|
||||
@@ -24,7 +18,6 @@ coverage
|
||||
# Runtime data and logs
|
||||
data
|
||||
logs
|
||||
.sandbox
|
||||
|
||||
# Local env files (inject at runtime via --env-file or -e)
|
||||
.env
|
||||
@@ -44,19 +37,6 @@ tests
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
output
|
||||
.playwright-cli
|
||||
.playwright-mcp
|
||||
.stryker-tmp
|
||||
reports/mutation
|
||||
|
||||
# Local caches and quality-gate artifacts (all gitignored). `_*` does not match
|
||||
# dot-prefixed names, so these need explicit entries.
|
||||
.artifacts
|
||||
.eslintcache*
|
||||
.fakebin-*
|
||||
MAX
|
||||
quality-ratchet/
|
||||
|
||||
# Documentation
|
||||
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
|
||||
@@ -69,10 +49,6 @@ quality-ratchet/
|
||||
# (English) sources at runtime, so translations are not required in the
|
||||
# container image.
|
||||
docs/i18n/**
|
||||
# Internal planning artifacts (gitignored). `*.md` above only matches the root,
|
||||
# so without this rule these land in /app/docs and become readable through the
|
||||
# dashboard's Docs viewer at runtime.
|
||||
docs/superpowers/**
|
||||
docs/diagrams/**/*.png
|
||||
docs/diagrams/**/*.jpg
|
||||
docs/diagrams/**/*.jpeg
|
||||
@@ -101,8 +77,6 @@ bun.lock
|
||||
# Agent config
|
||||
.agents
|
||||
.gemini
|
||||
.claude
|
||||
.source
|
||||
|
||||
# Misc
|
||||
llm.txt
|
||||
@@ -149,4 +123,3 @@ app.__qa_backup/
|
||||
.worktrees
|
||||
.next-playwright/
|
||||
cloud/
|
||||
electron/dist-electron
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.sh]
|
||||
indent_size = 4
|
||||
@@ -1,6 +0,0 @@
|
||||
ENABLE_LIVE_DEVIN_TESTS=0
|
||||
DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
|
||||
DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7
|
||||
1124
.env.example
1124
.env.example
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real)
|
||||
HOMOLOG_BASE_URL=http://192.168.0.15:20128
|
||||
# Senha de management do dashboard da VPS (a mesma do /login)
|
||||
HOMOLOG_ADMIN_PASSWORD=
|
||||
# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim.
|
||||
# Só preencha para depurar uma camada isolada com uma key fixa.
|
||||
HOMOLOG_API_KEY=
|
||||
# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo.
|
||||
HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter
|
||||
20
.github/dependabot.yml
vendored
20
.github/dependabot.yml
vendored
@@ -24,32 +24,12 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "eslint-config-next"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# typescript majors are peer-blocked by typescript-eslint, which pins a hard
|
||||
# upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7
|
||||
# bump therefore violates the peer and takes down the whole toolchain at once —
|
||||
# #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet
|
||||
# + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking
|
||||
# the innocuous updates riding along with it. Un-ignore once typescript-eslint
|
||||
# widens the peer, and migrate TS majors intentionally (own PR, own CI run).
|
||||
- dependency-name: "typescript"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
|
||||
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
|
||||
# parses jscpd-report.json against a frozen baseline). A v5 major would break
|
||||
# the duplication gate — migrate the gate intentionally, not via dependabot.
|
||||
- dependency-name: "jscpd"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# ioredis is a SOFT/optional dependency loaded through a dynamic import
|
||||
# (src/lib/quota/redisQuotaStore.ts — "Redis driver requires ioredis package"),
|
||||
# so a breaking major never fails at build or typecheck time: the only consumers
|
||||
# are the distributed quota store (redisQuotaStore.ts, storeFactory.ts) and the
|
||||
# `import type Redis` in src/shared/utils/rateLimiter.ts. Nothing in the unit or
|
||||
# vitest suites exercises a live Redis connection, so a v5→v6 API break would ship
|
||||
# green and only surface at runtime for operators running distributed quota — the
|
||||
# exact users least able to absorb it. #9310 grouped that major with 9 harmless
|
||||
# bumps; majors here need their own PR and a deliberate migration review.
|
||||
- dependency-name: "ioredis"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is 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)
|
||||
|
||||
13
.github/pull_request_template.md
vendored
13
.github/pull_request_template.md
vendored
@@ -9,15 +9,10 @@
|
||||
|
||||
## Validation
|
||||
|
||||
Choose the change type and focused loop from the
|
||||
[Contribution Golden Path](../docs/ops/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
||||
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
|
||||
|
||||
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
|
||||
- [ ] Focused tests and category gates from the golden path
|
||||
- [ ] `npm run lint`
|
||||
- [ ] Reconciled with the current active release base; focused checks rerun afterward
|
||||
- [ ] Production-code changes include a new or updated automated test in this PR
|
||||
- [ ] `npm run test:unit`
|
||||
- [ ] `npm run test:coverage`
|
||||
- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches
|
||||
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
|
||||
|
||||
## Tests Added Or Updated
|
||||
@@ -32,4 +27,4 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
|
||||
|
||||
## Reviewer Notes
|
||||
|
||||
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.
|
||||
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.
|
||||
71
.github/workflows/build-fork.yml
vendored
Normal file
71
.github/workflows/build-fork.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Publish Fork Image to GHCR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
# Least-privilege default: read-only at the top level; the build job that pushes to
|
||||
# GHCR grants packages: write itself (Scorecard TokenPermissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/kang-heewon/omniroute
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Push Fork Image
|
||||
if: github.repository == 'kang-heewon/OmniRoute'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-
|
||||
type=ref,event=tag
|
||||
labels: |
|
||||
org.opencontainers.image.title=omniroute
|
||||
org.opencontainers.image.description=Unified AI proxy/router — fork image
|
||||
org.opencontainers.image.url=https://github.com/kang-heewon/OmniRoute
|
||||
org.opencontainers.image.source=https://github.com/kang-heewon/OmniRoute
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
57
.github/workflows/build.yml
vendored
57
.github/workflows/build.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: Build App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["**"]
|
||||
|
||||
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
|
||||
774
.github/workflows/ci.yml
vendored
774
.github/workflows/ci.yml
vendored
File diff suppressed because it is too large
Load Diff
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,13 +19,13 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
33
.github/workflows/dast-smoke.yml
vendored
33
.github/workflows/dast-smoke.yml
vendored
@@ -2,45 +2,28 @@ name: DAST smoke (PR)
|
||||
on:
|
||||
pull_request:
|
||||
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.
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Superseded runs on the same PR must not stack 25-minute advisory builds
|
||||
# (force-push storms were holding 2-3 runners each). Same group rule as quality.yml.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
dast-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
|
||||
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
|
||||
continue-on-error: true
|
||||
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive
|
||||
# timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis
|
||||
# mid-run) — 25min leaves real headroom for the actual DAST steps.
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute
|
||||
env:
|
||||
@@ -53,20 +36,14 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install schemathesis
|
||||
- name: Schemathesis smoke (high-risk endpoints, blocking)
|
||||
run: |
|
||||
# /api/auth/oidc/* is a BROWSER redirect flow (302 to the IdP, 302 back to
|
||||
# /login?oidc_error=... on every failure), not a REST endpoint: Schemathesis reads
|
||||
# those 302s as "the API accepted a schema-violating request" and the configured-off
|
||||
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
|
||||
# still right (operators need it); fuzzing it is not what this smoke is for.
|
||||
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
|
||||
schemathesis run docs/reference/openapi.yaml --url http://localhost:20128 \
|
||||
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
|
||||
--exclude-path-regex '^/api/auth/oidc/' \
|
||||
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
- name: promptfoo injection-guard (blocking)
|
||||
|
||||
77
.github/workflows/docker-publish.yml
vendored
77
.github/workflows/docker-publish.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release/v*"
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
@@ -58,20 +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"
|
||||
|
||||
# 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."
|
||||
@@ -80,21 +98,19 @@ jobs:
|
||||
PROMOTE="${PROMOTE_INPUT:-false}"
|
||||
else
|
||||
git fetch --tags --quiet || true
|
||||
# Decide via the extracted helper, which folds VERSION into the
|
||||
# candidate set so the result is independent of git-tag sync timing
|
||||
# on `release` events (#5301). Without that, the freshly-created tag
|
||||
# is often not yet visible here and :latest stays a release behind.
|
||||
PROMOTE=$(git tag -l 'v[0-9]*' | bash scripts/ci/should-promote-latest.sh "$VERSION")
|
||||
if [ "$PROMOTE" != "true" ]; then
|
||||
echo "Version $VERSION is not the highest stable semver. Not promoting :latest."
|
||||
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
|
||||
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
|
||||
PROMOTE="true"
|
||||
else
|
||||
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Not promoting :latest."
|
||||
fi
|
||||
fi
|
||||
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 3) Skip immutable version tags that already exist. Floating `main`
|
||||
# and `next` channels are intentionally rebuilt on every matching push.
|
||||
# 3) Skip if this exact version is already published in Docker Hub.
|
||||
# `main` is always rebuilt (mutable floating tag).
|
||||
SKIP="false"
|
||||
if [ "$VERSION" != "main" ] && [ "$VERSION" != "next" ]; then
|
||||
if [ "$VERSION" != "main" ]; then
|
||||
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
|
||||
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
|
||||
SKIP="true"
|
||||
@@ -137,13 +153,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 }}
|
||||
@@ -237,13 +253,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -328,15 +344,6 @@ jobs:
|
||||
|
||||
# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
|
||||
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
|
||||
#
|
||||
# ignore-unfixed mirrors the blocking gate: the Security tab must surface only
|
||||
# ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding
|
||||
# on a patched base or bumping the dep. Without it the advisory upload floods the
|
||||
# tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream
|
||||
# patch yet, overwhelmingly local-only and not reachable from the proxy request
|
||||
# surface), which is noise an operator cannot act on. trivyignores points at the
|
||||
# repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home.
|
||||
# See docs/security/SUPPLY_CHAIN.md.
|
||||
- name: Trivy image scan (SARIF, advisory)
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
@@ -346,8 +353,6 @@ jobs:
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
ignore-unfixed: true
|
||||
trivyignores: .trivyignore
|
||||
exit-code: "0"
|
||||
|
||||
# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the
|
||||
@@ -372,14 +377,14 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.4
|
||||
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:
|
||||
|
||||
100
.github/workflows/electron-release.yml
vendored
100
.github/workflows/electron-release.yml
vendored
@@ -88,13 +88,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v6.1.0
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
@@ -120,18 +120,6 @@ jobs:
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
NODE_OPTIONS: "--max_old_space_size=6144"
|
||||
# Linux builds with webpack, not Turbopack. Turbopack's production build
|
||||
# allocates natively (Rust, off the V8 heap), so --max_old_space_size does
|
||||
# not bound it, and on this module graph it peaks above what the hosted
|
||||
# runner can give — the VM is reclaimed mid-compile with "The runner has
|
||||
# received a shutdown signal", no exit code. That is what silently took the
|
||||
# whole desktop channel out of v3.8.49: the linux leg died, `release` was
|
||||
# skipped, and the release shipped with ZERO assets. Measured on a 32 GB
|
||||
# box the same build passes and peaks past 14 GB. The webpack fallback is
|
||||
# the project's documented escape hatch for RAM-constrained machines
|
||||
# (docs/reference/ENVIRONMENT.md, #6409) and is the same remedy already
|
||||
# applied to nightly-compat's Node 26 build (#8090).
|
||||
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
|
||||
run: npm run build
|
||||
|
||||
- name: Sync version in electron/package.json
|
||||
@@ -213,12 +201,6 @@ jobs:
|
||||
[ -f "$file" ] && cp "$file" "../../release-assets/OmniRoute.exe" && break
|
||||
done
|
||||
fi
|
||||
# electron-updater manifests (latest.yml / latest-mac.yml / latest-linux.yml)
|
||||
# must be published alongside the installers, or autoUpdater fails with
|
||||
# "Cannot find latest.yml in the latest release artifacts" (#6766).
|
||||
for file in latest*.yml; do
|
||||
[ -f "$file" ] && cp "$file" ../../release-assets/
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -229,16 +211,6 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
needs: [validate, build]
|
||||
# Fail-partial, not fail-closed. `build` is a 4-leg matrix with `fail-fast: false`,
|
||||
# so the legs that succeed still upload their artifacts — but a default `needs:`
|
||||
# gate skips this job the moment ANY leg fails, discarding all of them. That is
|
||||
# exactly what happened to v3.8.49: the linux leg died and the release shipped with
|
||||
# ZERO assets, throwing away 1.7 GB of good Windows/macOS installers **and** the
|
||||
# source archives + SBOM, which do not depend on a build at all. The result was
|
||||
# indistinguishable from "this version has no desktop channel".
|
||||
# Now: attach everything that did build, then fail the job loudly (see the last
|
||||
# step) so an incomplete channel is visible instead of silent.
|
||||
if: ${{ !cancelled() && needs.validate.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
@@ -249,33 +221,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
|
||||
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
|
||||
# own dmg (measured: 338 and 350 bytes, different content, identical name). One silently
|
||||
# overwrote the other — arm64 won in the published v3.8.48, and since the Intel dmg
|
||||
# carries no arch suffix in its name, electron-updater's
|
||||
# `files.find(url includes process.arch) ?? files.shift()` sends every Intel Mac to the
|
||||
# ARM dmg. Downloading into per-artifact subdirectories keeps both, so they can be
|
||||
# merged on purpose instead of by luck.
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
# Writes release-assets/latest-mac.yml with BOTH dmgs, un-suffixed entry first (that is
|
||||
# the one electron-updater can only reach through its fallback). Refuses to write when the
|
||||
# inputs disagree on version — a manifest stitched from two builds is worse than none.
|
||||
- name: Merge the per-arch macOS updater manifests
|
||||
run: node scripts/release/merge-mac-update-manifest.mjs artifacts release-assets
|
||||
|
||||
# Everything else moves across as-is. The partial latest-mac.yml files are excluded so
|
||||
# they cannot clobber the merged one; -n is a second belt on the same braces.
|
||||
- name: Collect the remaining artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find artifacts -type f ! -name latest-mac.yml -exec cp -n {} release-assets/ \;
|
||||
echo "release-assets:"
|
||||
ls -la release-assets/
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create source archives
|
||||
env:
|
||||
@@ -313,53 +263,11 @@ jobs:
|
||||
release-assets/*.AppImage
|
||||
release-assets/*.deb
|
||||
release-assets/*.blockmap
|
||||
release-assets/*.yml
|
||||
release-assets/*.source.tar.gz
|
||||
release-assets/*.source.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
verify-desktop-assets:
|
||||
name: Verify desktop assets landed
|
||||
needs: [validate, release]
|
||||
# Deliberately a SEPARATE job, not a final step of `release`: failing inside
|
||||
# `release` would cascade into `publish-npm` (which gates on `needs: release`) and
|
||||
# block the npm channel over a desktop-only gap. Here the assets are attached, npm
|
||||
# still publishes, and an incomplete desktop channel shows up as a red job instead
|
||||
# of passing unnoticed — the v3.8.49 release had ZERO assets and every gate was
|
||||
# green, because nothing ever asserted the release HAS binaries.
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Assert every platform is present on the release
|
||||
env:
|
||||
# Regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in the `validate` job, and
|
||||
# passed via env rather than interpolated into the script body.
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
names=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets --jq '.assets[].name')
|
||||
echo "Assets on $VERSION:"
|
||||
echo "$names" | sed 's/^/ /'
|
||||
|
||||
missing=""
|
||||
# `[ ... ] && missing=...` as the last command in a branch returns 1 and
|
||||
# would abort the whole script under Actions' default `set -e`. Use if/fi.
|
||||
for want in '\.exe$' '\.dmg$' '\.AppImage$' '\.deb$' '^latest.*\.yml$' '\.source\.tar\.gz$'; do
|
||||
if ! echo "$names" | grep -qE "$want"; then
|
||||
missing="$missing $want"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::Desktop channel incomplete on $VERSION — no asset matching:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ every platform present on $VERSION"
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
|
||||
2
.github/workflows/mutation-redundancy.yml
vendored
2
.github/workflows/mutation-redundancy.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
139
.github/workflows/nightly-compat.yml
vendored
139
.github/workflows/nightly-compat.yml
vendored
@@ -1,139 +0,0 @@
|
||||
name: Nightly Node Compat
|
||||
|
||||
# Plano mestre testes+CI (Eixo D2, aprovado 2026-07-04): as matrizes de compatibilidade
|
||||
# Node 24/26 custavam ~28% de CADA run do CI pesado (2 execuções completas da suíte por
|
||||
# sync da release-PR) para pegar uma classe de quebra que raramente nasce num PR típico.
|
||||
# Elas rodam aqui 1×/dia contra o tip da release ativa (mesmo alvo do nightly-release-green)
|
||||
# e continuam obrigatórias no gate de release via workflow_dispatch do ci.yml se preciso.
|
||||
# fail-fast desligado: numa quebra queremos saber TODAS as versões afetadas de uma vez.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 6 * * *" # 06:47 UTC diário — slot distinto dos demais nightlies
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to validate (default: highest release/vX.Y.Z)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: nightly-compat
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
resolve-branch:
|
||||
name: Resolve active release branch
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
target: ${{ steps.branch.outputs.target }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Resolve active release branch
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
else
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
| sed 's#origin/##' \
|
||||
| sort -t/ -k2 -V \
|
||||
| tail -1)
|
||||
fi
|
||||
case "$TARGET" in
|
||||
release/v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Refusing non-canonical branch name: $TARGET"; exit 1 ;;
|
||||
esac
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
|
||||
compat-build-26:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "26"
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# #8090 — this is the ONLY Node 26 build in the whole CI matrix (ci.yml pins
|
||||
# CI_NODE_VERSION=24). It failed every nightly with the runner-reclaimed
|
||||
# signature ("The runner has received a shutdown signal" / "The operation was
|
||||
# canceled", no exit code) always at the same Turbopack compile phase — a
|
||||
# classic OOM kill on the memory-constrained ubuntu-latest runner. Turbopack's
|
||||
# native (Rust, off-V8-heap) allocation is NOT bounded by --max-old-space-size
|
||||
# and peaks far higher than webpack on this large module graph (#6409), and is
|
||||
# heavier still under Node 26. Use the documented webpack fallback here: it still
|
||||
# validates that the app *builds* on Node 26 (the point of this compat job) at a
|
||||
# much lower memory peak. Turbopack-on-Node-24 stays covered by ci.yml's build
|
||||
# job. See docs/reference/ENVIRONMENT.md (OMNIROUTE_USE_TURBOPACK) and #6409.
|
||||
- run: npm run build
|
||||
env:
|
||||
OMNIROUTE_USE_TURBOPACK: "0"
|
||||
|
||||
compat-tests:
|
||||
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24, 26]
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:unit:ci:shard
|
||||
|
||||
report:
|
||||
name: Open / update tracking issue on failure
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (needs.compat-tests.result == 'failure' || needs.compat-build-26.result == 'failure') }}
|
||||
needs: [resolve-branch, compat-build-26, compat-tests]
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Open or update issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ needs.resolve-branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🌙 nightly-compat: Node 24/26 failures on $TARGET"
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "$TITLE in:title" --json number --jq '.[0].number')
|
||||
BODY="Nightly Node-compat run failed on \`$TARGET\`: $RUN_URL — triage which Node version/shard broke (fail-fast off, all versions reported)."
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY"
|
||||
fi
|
||||
14
.github/workflows/nightly-llm-security.yml
vendored
14
.github/workflows/nightly-llm-security.yml
vendored
@@ -15,13 +15,11 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (block mode)
|
||||
env:
|
||||
@@ -67,16 +65,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- name: Build CLI bundle
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
@@ -90,7 +86,7 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install garak
|
||||
|
||||
8
.github/workflows/nightly-mutation.yml
vendored
8
.github/workflows/nightly-mutation.yml
vendored
@@ -107,13 +107,13 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore Stryker incremental cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: reports/mutation/stryker-incremental.json
|
||||
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
|
||||
@@ -148,10 +148,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
|
||||
2
.github/workflows/nightly-property.yml
vendored
2
.github/workflows/nightly-property.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
447
.github/workflows/nightly-release-green.yml
vendored
447
.github/workflows/nightly-release-green.yml
vendored
@@ -1,447 +0,0 @@
|
||||
name: Release-Green (continuous)
|
||||
|
||||
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
|
||||
#
|
||||
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
|
||||
# accrue silently on release/** and explode — in layers — at release time. This
|
||||
# workflow reproduces the release-equivalent validation on the release branch and,
|
||||
# when there are HARD failures, opens/updates a single tracking issue.
|
||||
#
|
||||
# WS5.1 (v3.8.49 quality plan) — two modes:
|
||||
# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the
|
||||
# captain's direct pushes (sync-back — the one ungated write path) AND the merged
|
||||
# COMBINATION right after every PR merge, attributing the offending push range in
|
||||
# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push.
|
||||
# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites).
|
||||
#
|
||||
# It is NOT a required status check and never touches a contributor PR — it only
|
||||
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
|
||||
# expected mid-cycle and is reported but never raises the alarm on its own; only
|
||||
# real defects (typecheck / lint errors / unit / vitest / db-rules / public-creds /
|
||||
# package-artifact) flip the issue open.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["release/v*", "main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "open-sse/**"
|
||||
- "bin/**"
|
||||
- "electron/**"
|
||||
- "scripts/**"
|
||||
- "tests/**"
|
||||
- "config/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "tsconfig*.json"
|
||||
schedule:
|
||||
- cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies
|
||||
- cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×)
|
||||
- cron: "23 18 * * *" # full sweep — evening
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Release branch to validate (default: highest release/vX.Y.Z)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
# push storms during merge campaigns collapse to the newest commit per branch;
|
||||
# scheduled full sweeps keep their own single lane.
|
||||
group: release-green-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
|
||||
|
||||
jobs:
|
||||
release-green:
|
||||
name: Validate active release branch
|
||||
# On a push, only run for release/* pushes — a push to main is handled by the
|
||||
# main-green job below. Schedule/dispatch always run (they validate the highest release).
|
||||
if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }}
|
||||
# Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight)
|
||||
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
|
||||
# no local noauth CLIs => zero machine-specific false positives) and no contention.
|
||||
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve active release branch
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PUSHED_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
elif [ "$EVENT_NAME" = "push" ]; then
|
||||
# validate exactly what was pushed, not the highest branch
|
||||
TARGET="$PUSHED_REF"
|
||||
else
|
||||
# highest release/vX.Y.Z by semver among remote branches
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
| sed 's#origin/##' \
|
||||
| sort -t/ -k2 -V \
|
||||
| tail -1)
|
||||
fi
|
||||
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
|
||||
# Strict format guard — reject anything that isn't release/vX.Y.Z (blocks
|
||||
# ref/command injection via the workflow_dispatch input).
|
||||
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Refusing non-canonical branch name: $TARGET"; exit 1
|
||||
fi
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
echo "Active release branch: $TARGET"
|
||||
|
||||
- name: Checkout the release branch
|
||||
env:
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Release-green validation (full)
|
||||
id: validate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set +e
|
||||
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
|
||||
# operator env; hosted ignores the unknown flag before #6300 lands).
|
||||
# push → --quick: fast HARD gates only (~5-8min), per-merge signal.
|
||||
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from
|
||||
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict,
|
||||
# pr-test-policy) + build + full suites. PRs into release/** only get the
|
||||
# fast-gates, so these accrue silently and explode in layers on the release PR
|
||||
# (v3.8.46: 11 static base-reds leaked).
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
MODE="--quick"
|
||||
else
|
||||
MODE="--with-build --full-ci"
|
||||
fi
|
||||
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
|
||||
# MODE is an intentional flag list, so word-splitting is wanted here
|
||||
# shellcheck disable=SC2086
|
||||
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
|
||||
1> release-green.json 2> release-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
cat release-green.log
|
||||
|
||||
- name: Open / update tracking issue on HARD failure
|
||||
if: steps.validate.outputs.exit != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
AFTER_SHA: ${{ github.event.after }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🔴 Release branch not green: ${TARGET}"
|
||||
{
|
||||
echo "The **release-green** validation found HARD failures on \`${TARGET}\`."
|
||||
echo "These are real defects that would block the release PR — fix them in the"
|
||||
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
|
||||
# WS5.1 attribution: on push events the offending change IS this push's range
|
||||
# (one merge per push in the normal queue), so name it — no bisect needed.
|
||||
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
|
||||
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
|
||||
echo ""
|
||||
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
|
||||
echo '```'
|
||||
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
|
||||
echo '```'
|
||||
fi
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._"
|
||||
} > issue-body.md
|
||||
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-green-report
|
||||
path: |
|
||||
release-green.json
|
||||
release-green.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Companion arm for `main`. Under the parallel-cycle model, main only receives merged
|
||||
# work at the release squash — so a gate/infra fix that lands only on release leaves
|
||||
# main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines)
|
||||
# turn EVERY PR into main red on a check unrelated to its diff. This detects that and
|
||||
# opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex
|
||||
# (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop.
|
||||
main-green:
|
||||
name: Validate main branch
|
||||
# On a push, only run for a push to main — a push to release/* is handled by
|
||||
# release-green above. Schedule/dispatch always run (they also sweep main).
|
||||
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Main-green validation
|
||||
id: validate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set +e
|
||||
# push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep.
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
MODE="--quick"
|
||||
else
|
||||
MODE="--with-build --full-ci"
|
||||
fi
|
||||
echo "[main-green] mode: $MODE (event: $EVENT_NAME)"
|
||||
# MODE is an intentional flag list, so word-splitting is wanted here
|
||||
# shellcheck disable=SC2086
|
||||
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
|
||||
1> main-green.json 2> main-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
cat main-green.log
|
||||
|
||||
- name: Open / update tracking issue on HARD failure
|
||||
if: steps.validate.outputs.exit != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🔴 main branch not green"
|
||||
{
|
||||
echo "The **main-green** validation found HARD failures on \`main\`."
|
||||
echo ""
|
||||
echo "Because \`main\` only receives merged work at the release squash, a gate/infra"
|
||||
echo "fix that landed only on the release branch leaves \`main\` broken for the whole"
|
||||
echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn"
|
||||
echo "**every open PR into main** red on a check unrelated to its diff. The fix is a"
|
||||
echo "companion PR \`--base main\` carrying the release-side fix (see"
|
||||
echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR."
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._"
|
||||
} > issue-body.md
|
||||
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: main-green-report
|
||||
path: |
|
||||
main-green.json
|
||||
main-green.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ── Banking lane (#8584) ──────────────────────────────────────────────────
|
||||
# The ratchet is asymmetric: RAISING a cap is a ten-second manual JSON edit made
|
||||
# under merge pressure, LOWERING one requires someone to run `--update` and commit
|
||||
# — which no workflow does. Grep `.github/workflows/` for `--update`: only
|
||||
# wiki-sync.yml (unrelated) and ci.yml's check-quality-ratchet.mjs --require-tighten
|
||||
# (a different script, a different metric). So a cap outlives the code that earned
|
||||
# it and every completed decomposition silently becomes a growth allowance for
|
||||
# whoever touches the file next. Measured on 2026-07-25: 18 frozen files already at
|
||||
# or under the 800-line new-file cap, up to 132x (schemas.ts, 19 lines / 2523 cap),
|
||||
# and 31 unfulfilled "tighten via --update next cycle" notes honoured exactly once
|
||||
# (-1 unit) in six weeks.
|
||||
#
|
||||
# This job closes that loop by making the DOWNWARD direction as automatic as the
|
||||
# upward one is easy. It measures the active release branch, runs the shrink-only
|
||||
# `--update` paths, and opens ONE always-current PR with the result. It never
|
||||
# pushes to release/* — a human still merges, so a bad measurement cannot land
|
||||
# unreviewed. verify-ratchet-bank.mjs is the hard guarantee that the automation can
|
||||
# only ever write in the shrink direction; if anything was raised, added, or a
|
||||
# rebaseline note was touched, the job aborts and opens nothing.
|
||||
#
|
||||
# Schedule/dispatch only, deliberately NOT on push: banking has no latency
|
||||
# requirement (a shrink banked within 8h is fine) and a per-merge run would rebuild
|
||||
# the PR branch repeatedly during merge campaigns while paying for a full ESLint
|
||||
# walk each time. Detection stays on push (release-green above); banking is batched.
|
||||
bank-ratchet-shrinks:
|
||||
name: Bank ratchet shrinks
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve active release branch
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
else
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
| sed 's#origin/##' \
|
||||
| sort -t/ -k2 -V \
|
||||
| tail -1)
|
||||
fi
|
||||
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
|
||||
# Same strict guard as the validation job — blocks ref/command injection
|
||||
# through the workflow_dispatch input.
|
||||
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Refusing non-canonical branch name: $TARGET"; exit 1
|
||||
fi
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
echo "bank_branch=chore/bank-ratchet-${TARGET#release/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout the release branch
|
||||
env:
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Ratchet the baselines down
|
||||
run: |
|
||||
# Both --update paths are shrink-only by construction (file-size writes only
|
||||
# on `improvements`, complexity-ratchets only when `.improved`), and both exit
|
||||
# non-zero while the branch is over baseline — which is exactly when there is
|
||||
# nothing to bank. Their exit code is not the signal; the verifier below is.
|
||||
set +e
|
||||
node scripts/check/check-file-size.mjs --update
|
||||
node scripts/check/check-complexity-ratchets.mjs --update
|
||||
exit 0
|
||||
|
||||
- name: Verify the write only went downward
|
||||
id: verify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Exits 1 if ANYTHING was raised/added or a rebaseline note was touched.
|
||||
# `set -e` then aborts the job before a commit exists — no PR is opened.
|
||||
node scripts/quality/verify-ratchet-bank.mjs > bank-summary.md
|
||||
if [ -n "$(git status --porcelain config/quality/)" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Nothing to bank — baselines already match the code."
|
||||
fi
|
||||
|
||||
- name: Open / update the banking PR
|
||||
if: steps.verify.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
BANK_BRANCH: ${{ steps.branch.outputs.bank_branch }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$BANK_BRANCH"
|
||||
git add config/quality/
|
||||
git commit -m "chore(quality): bank ratchet shrinks measured on ${TARGET}"
|
||||
git push --force origin "$BANK_BRANCH"
|
||||
|
||||
{
|
||||
echo "Automated banking of quality-ratchet **shrinks** already present in"
|
||||
echo "\`${TARGET}\` — the downward half of the ratchet, which nothing else runs (#8584)."
|
||||
echo ""
|
||||
echo "Produced by \`check:file-size --update\` + \`check:complexity-ratchets --update\`,"
|
||||
echo "then verified by \`scripts/quality/verify-ratchet-bank.mjs\`: **nothing was raised,"
|
||||
echo "nothing was added, no rebaseline note was touched** — the job aborts without"
|
||||
echo "opening a PR if any of those is violated."
|
||||
echo ""
|
||||
echo "No product code changes. Merging retires growth allowances that the code no"
|
||||
echo "longer needs; not merging leaves them available to whoever edits those files next."
|
||||
echo ""
|
||||
cat bank-summary.md
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL}"
|
||||
} > pr-body.md
|
||||
|
||||
EXISTING=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BANK_BRANCH" \
|
||||
--state open --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh pr edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file pr-body.md
|
||||
echo "Updated existing PR #$EXISTING"
|
||||
else
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET" --head "$BANK_BRANCH" \
|
||||
--title "chore(quality): bank ratchet shrinks (${TARGET})" --body-file pr-body.md
|
||||
fi
|
||||
11
.github/workflows/nightly-resilience.yml
vendored
11
.github/workflows/nightly-resilience.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (background)
|
||||
env:
|
||||
@@ -95,13 +94,13 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6.1.0
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
10
.github/workflows/nightly-schemathesis.yml
vendored
10
.github/workflows/nightly-schemathesis.yml
vendored
@@ -16,13 +16,11 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (background)
|
||||
env:
|
||||
@@ -35,7 +33,7 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with: { python-version: "3.12" }
|
||||
- name: Install schemathesis
|
||||
run: pip install schemathesis
|
||||
@@ -47,7 +45,7 @@ jobs:
|
||||
# PROVE the contract is fuzzable and surface regressions, not to gate the build.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
schemathesis run docs/openapi.yaml \
|
||||
schemathesis run docs/reference/openapi.yaml \
|
||||
--url http://localhost:20128 \
|
||||
--max-examples 20 \
|
||||
--workers 4 \
|
||||
|
||||
200
.github/workflows/npm-publish.yml
vendored
200
.github/workflows/npm-publish.yml
vendored
@@ -22,14 +22,6 @@ on:
|
||||
- latest
|
||||
- next
|
||||
- historic
|
||||
publish_mode:
|
||||
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
|
||||
required: false
|
||||
default: "staged"
|
||||
type: choice
|
||||
options:
|
||||
- staged
|
||||
- direct
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
@@ -56,15 +48,8 @@ env:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Same dynamic-runner rule as ci.yml's `build`/`test-unit`: `build:cli` falls back to a
|
||||
# full `next build`, whose working set outgrew the 16 GB hosted runner during the
|
||||
# v3.8.49 cycle — the publish died with "The runner has received a shutdown signal"
|
||||
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
|
||||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read # find + download the CI run's next-build artifact for this SHA
|
||||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||||
id-token: write # npm provenance
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
@@ -78,7 +63,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -152,74 +137,6 @@ jobs:
|
||||
run: |
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
|
||||
# Fast path: CI already built the standalone tree for THIS commit and uploaded it as
|
||||
# `next-build`. `build:cli` (scripts/build/prepublish.ts) only shells out to a full
|
||||
# `next build` when `.build/next/standalone/server.js` is missing — restoring the
|
||||
# artifact turns the heaviest step of the publish into a download. Matching on
|
||||
# `head_sha` is the tree-equality guarantee: same commit, same tree.
|
||||
# Best-effort by design (retention is 1 day): every miss falls through to the build
|
||||
# step below, which is why the dynamic runner above matters as the backstop.
|
||||
#
|
||||
# The `head_repository.full_name == env.REPO` clause is a supply-chain guard, not a
|
||||
# filter refinement. This artifact becomes the published npm tarball. `pull_request`
|
||||
# runs from forks execute in THIS repository's context and upload their own
|
||||
# `next-build` built from fork-controlled source, and the runs API returns them for a
|
||||
# matching `head_sha` — 57 such runs exist in this repo today. Without the clause,
|
||||
# anything that made a fork's head commit coincide with the publish commit could put
|
||||
# attacker-built bytes on npm. Requiring the run to originate from this repository
|
||||
# excludes every fork run while keeping the fast path intact (verified: the same
|
||||
# single run is selected either way for the current tip).
|
||||
# CodeQL: actions/artifact-poisoning/critical.
|
||||
- name: Reuse CI's next-build artifact (skips the heavy rebuild)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# The question is "which run HAS the artifact", not "which run passed" (gap 16).
|
||||
# Requiring `conclusion == "success"` on the whole run discarded a perfectly good tree
|
||||
# whenever any unrelated shard went red — one flaky test then pushed the publish into
|
||||
# the 40-minute build this step exists to avoid. The artifact is only uploaded if the
|
||||
# Build job itself succeeded, so its PRESENCE is the accurate signal; the run's overall
|
||||
# conclusion is noise from jobs that have nothing to do with the tree.
|
||||
#
|
||||
# `head_repository.full_name == env.REPO` stays, and it is not a filter refinement:
|
||||
# this tree becomes the published npm tarball, and fork `pull_request` runs execute in
|
||||
# THIS repository's context uploading their own next-build. That clause is the
|
||||
# supply-chain guard (CodeQL actions/artifact-poisoning).
|
||||
CANDIDATES=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
|
||||
--jq '[.workflow_runs[]
|
||||
| select(.name == "CI"
|
||||
and .head_repository.full_name == env.REPO)]
|
||||
| sort_by(.run_started_at) | reverse | .[0:5] | .[].id') || CANDIDATES=""
|
||||
if [ -z "$CANDIDATES" ]; then
|
||||
echo "::notice::no CI run from this repository for $HEAD_SHA — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
RUN=""
|
||||
for candidate in $CANDIDATES; do
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
|
||||
RUN="$candidate"
|
||||
break
|
||||
fi
|
||||
echo " run $candidate carries no usable next-build — trying the next"
|
||||
done
|
||||
if [ -z "$RUN" ]; then
|
||||
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
tar -xzf /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
|
||||
echo "::notice::extract did not yield .build/next/standalone — falling back to a full build"
|
||||
rm -rf .build
|
||||
fi
|
||||
|
||||
- name: Build CLI bundle (standalone app)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -249,46 +166,8 @@ jobs:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
|
||||
|
||||
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
|
||||
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
|
||||
# real tarball and fails the publish before anything reaches the registry.
|
||||
- name: Boot-smoke the tarball before ANY publish
|
||||
- name: Publish to npm
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-boot
|
||||
|
||||
# The boot-smoke above proves a CLEAN install boots. It does not prove the path that
|
||||
# actually broke us: installing over an existing version, where ~110 SQLite migrations
|
||||
# run against a populated database. v3.8.48 shipped as a hotfix because the published
|
||||
# 3.8.47 crashed on boot, and the v3.8.49 upgrade path was first exercised end-to-end
|
||||
# by hand on a real 3.8.48 box (VPS .16) — after publishing, which is exactly backwards.
|
||||
# Runs BEFORE `npm stage publish` so a broken upgrade never reaches the registry at all;
|
||||
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
|
||||
- name: Prove clean-install AND upgrade-over-previous both boot
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
timeout-minutes: 30
|
||||
run: npm run check:install-upgrade
|
||||
|
||||
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
||||
# parks the exact bytes on the registry WITHOUT making them installable; the
|
||||
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
|
||||
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
|
||||
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
|
||||
# fallback (legacy immediate publish) via workflow_dispatch.
|
||||
- name: Ensure npm supports staged publishing
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CUR=$(npm --version)
|
||||
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
|
||||
# Pinned exact version (supply-chain: never float @latest in the publish
|
||||
# job); bump deliberately when a newer npm is required.
|
||||
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
|
||||
npm install -g --ignore-scripts npm@11.15.0
|
||||
fi
|
||||
npm --version
|
||||
|
||||
- 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 }}
|
||||
@@ -296,32 +175,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 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: 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
|
||||
# accidentally an older release, `npm publish --tag historic` will
|
||||
# NOT promote it to `@latest`.
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
@@ -348,56 +205,13 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Full history needed for auto-bump: git diff against previous release tag
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Auto-bump plugin version if plugin changed since last release
|
||||
id: bump
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
env:
|
||||
CURRENT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
# 1) Skip if current version is not yet published (no bump needed)
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
|
||||
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Find the previous release tag (exclude the current one)
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
|
||||
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag to compare — skipping bump."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3) Check if plugin dir actually changed since that tag
|
||||
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin/"; then
|
||||
echo "⏭️ No plugin changes since $PREV_TAG — nothing to publish."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4) Auto-bump patch version
|
||||
npm version patch --no-git-tag-version --allow-same-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
|
||||
|
||||
- name: Install plugin dependencies
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
4
.github/workflows/opencode-provider-ci.yml
vendored
4
.github/workflows/opencode-provider-ci.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
528
.github/workflows/quality.yml
vendored
528
.github/workflows/quality.yml
vendored
@@ -14,117 +14,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow
|
||||
# test installed a fake PEM on a persistent self-hosted runner and broke all
|
||||
# system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts.
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
|
||||
CI_NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
# Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release
|
||||
# path filters share existence reasons: code / docs / i18n / workflow.
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
{
|
||||
echo "code=true"
|
||||
echo "docs=true"
|
||||
echo "i18n=true"
|
||||
echo "workflow=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build (advisory)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — same fork-safe rule as ci.yml / fast-gates.
|
||||
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-latest' }}
|
||||
# #7307: advisory for the first week of release-PR runs; remove
|
||||
# continue-on-error after the production-build signal is stable.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
env:
|
||||
OMNIROUTE_USE_TURBOPACK: "1"
|
||||
# No artifact upload here: the PR-to-release quality workflow has no
|
||||
# downstream package/e2e jobs that consume the Next.js build output.
|
||||
|
||||
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
|
||||
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
|
||||
docs-gates:
|
||||
name: Docs Gates (fast-path)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- 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)
|
||||
run: npm run check:docs-all
|
||||
|
||||
fast-gates:
|
||||
name: Fast Quality Gates
|
||||
needs: changes
|
||||
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
|
||||
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
|
||||
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
|
||||
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
|
||||
# PINNED to hosted (gap 19). This job carried the USE_VPS_RUNNER expression, and that
|
||||
# expression was DEAD CONFIGURATION: across 160 quality.yml runs the job never once landed on
|
||||
# a self-hosted runner — every non-skipped sample is `GitHub Actions NNNN`. The classifier is
|
||||
# not at fault: in the same window ci.yml's Build demonstrably ran on omniroute-113-7 and
|
||||
# omniroute-113-6, so self-hosted runs are visible when they happen.
|
||||
#
|
||||
# And if it ever HAD fired it would have inherited the measured penalty, because this job's
|
||||
# first two steps are exactly the bottleneck: actions/setup-node + npm ci took 20m06s on .113
|
||||
# with 4 concurrent runners versus 16s hosted (npm cache restore saturating the link). Median
|
||||
# here is 5.6 min hosted across 72 successful runs.
|
||||
#
|
||||
# With this pinned, USE_VPS_RUNNER governs ONLY build-like jobs — one variable, one coherent
|
||||
# purpose. That is what gap 19 asked for; a second variable turned out to be unnecessary.
|
||||
runs-on: ubuntu-latest
|
||||
# tsx gates (known-symbols, route-guard-membership) import modules that open
|
||||
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
@@ -137,414 +31,54 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# Security scanners — same hardened install as ci.yml quality-extended
|
||||
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
|
||||
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
|
||||
# gates below SKIP (exit 0) when their binary is absent — only a measured
|
||||
# regression vs config/quality/quality-baseline.json blocks.
|
||||
- name: Install security scanners (gitleaks/osv/actionlint/zizmor/oasdiff)
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
# Ratchets compare scanner COUNTS across runs. Pin every auditor: a rule-set
|
||||
# update must be an explicit PR that re-measures/rebaselines, never a random
|
||||
# red (or green) caused by whatever "latest" served that morning.
|
||||
GITLEAKS_VERSION=v8.30.1
|
||||
OSV_SCANNER_VERSION=v2.3.8
|
||||
ACTIONLINT_VERSION=v1.7.12
|
||||
ZIZMOR_VERSION=1.25.2
|
||||
OASDIFF_VERSION=v1.19.1
|
||||
# gitleaks — pinned linux x64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/gl && mkdir -p /tmp/gl
|
||||
gh release download "$GITLEAKS_VERSION" --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl
|
||||
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks
|
||||
# osv-scanner — pinned linux amd64 bare binary via gh (authed)
|
||||
rm -rf /tmp/osv && mkdir -p /tmp/osv
|
||||
gh release download "$OSV_SCANNER_VERSION" --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv
|
||||
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
|
||||
# actionlint — official installer from a pinned release tag (never main)
|
||||
bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" "$HOME/.local/bin"
|
||||
# zizmor — pinned PyPI package (same version as ci.yml quality-extended)
|
||||
pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION"
|
||||
# oasdiff — pinned linux amd64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
|
||||
gh release download "$OASDIFF_VERSION" --repo Tufin/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
|
||||
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
|
||||
# ALWAYS export the bin dir (even if any step above failed)
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/gitleaks" version || true
|
||||
"$HOME/.local/bin/actionlint" -version || true
|
||||
"$HOME/.local/bin/osv-scanner" --version || true
|
||||
"$HOME/.local/bin/oasdiff" --version || true
|
||||
zizmor --version || true
|
||||
- name: Forgotten sibling tests (advisory)
|
||||
env:
|
||||
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/check/check-forgotten-sibling-tests.mjs \
|
||||
--summary-file forgotten-sibling-tests.md \
|
||||
--json-file forgotten-sibling-tests.json
|
||||
cat forgotten-sibling-tests.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Upload forgotten sibling report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: forgotten-sibling-tests
|
||||
path: |
|
||||
forgotten-sibling-tests.md
|
||||
forgotten-sibling-tests.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
# Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps,
|
||||
# 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation
|
||||
# step. Each gate runs in a loop with ::group::; failures are collected and
|
||||
# reported at the end. set -uo pipefail (NOT set -e) so one failing gate does
|
||||
# not abort the job and mask every later gate. Release-added gates are folded
|
||||
# in: open-sse typecheck (#8781) and file-size base-relative mode (#8522).
|
||||
- name: Quality gates (all, non-fail-fast)
|
||||
env:
|
||||
# #8522: base-relative file-size mode on PR events — inherited drift (base
|
||||
# already over frozen cap) must not red an innocent PR. Unset on
|
||||
# workflow_dispatch (no PR base) → absolute comparison.
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
gates=(
|
||||
provider-consistency 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
|
||||
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.
|
||||
if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
|
||||
else
|
||||
npm run "check:$g" || failed+=("$g")
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
for g in "${ratchet_gates[@]}"; do
|
||||
echo "::group::check:$g (ratchet)"
|
||||
npm run "check:$g" -- --ratchet || failed+=("$g")
|
||||
echo "::endgroup::"
|
||||
done
|
||||
echo "::group::typecheck:core"
|
||||
npm run typecheck:core || failed+=("typecheck:core")
|
||||
echo "::endgroup::"
|
||||
echo "::group::check:dashboard-typecheck"
|
||||
npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck")
|
||||
echo "::endgroup::"
|
||||
# #10134: TS7 zero-new-diagnostics ratchet — folded into this non-fail-fast
|
||||
# loop (never a separate blocking step) so an earlier red gate cannot abort
|
||||
# the job and mask it (#8542 mechanism). PR-only: the base-relative
|
||||
# comparison needs the PR base SHA (empty on workflow_dispatch).
|
||||
if [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
echo "::group::check:ts7-diagnostics-ratchet"
|
||||
npm run check:ts7-diagnostics-ratchet -- --base-ref "$PR_BASE_SHA" || failed+=("ts7-diagnostics-ratchet")
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
if (( ${#failed[@]} )); then
|
||||
printf '::error::%d gate(s) failed: %s\n' "${#failed[@]}" "${failed[*]}"
|
||||
exit 1
|
||||
fi
|
||||
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
|
||||
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
|
||||
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
|
||||
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
|
||||
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
|
||||
# The full result stays advisory while #8484 has a backlog. The blocking
|
||||
# base-relative ratchet (folded into the non-fail-fast gates step above)
|
||||
# rejects only diagnostics added by the PR, so existing release debt does
|
||||
# not block unrelated work.
|
||||
- name: Typecheck (core) — TS7 native shadow (advisory)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
RC=0
|
||||
START=$(date +%s)
|
||||
npm exec --yes --package=typescript@7.0.2 -- tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
||||
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
|
||||
exit $RC
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:openapi-routes
|
||||
- run: npm run check:docs-symbols
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:any-budget:t11
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
|
||||
# selector returns __RUN_ALL__ — full-suite authority is the parallel
|
||||
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
|
||||
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
|
||||
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
|
||||
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
|
||||
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
|
||||
#
|
||||
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
|
||||
# coverage remains required via `Unit Tests fast-path` (fast-unit).
|
||||
- name: Impacted unit tests (TIA subset; blocking)
|
||||
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
|
||||
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
|
||||
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
|
||||
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
|
||||
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
|
||||
# now blocks on unit-test regressions in the impacted set (typecheck:core already
|
||||
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
|
||||
- name: Impacted unit tests (TIA, fail-safe full; blocking)
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
# The advisory sibling-test step generates the same map earlier in this job.
|
||||
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
# Shadow evidence (#8084): persist every selection so TIA false negatives can
|
||||
# be measured against fast-unit's full-suite verdict across releases BEFORE
|
||||
# any gate authority moves off ordinary PRs. Artifact uploaded below.
|
||||
printf '%s\n' "$SEL" > tia-selection.txt
|
||||
if [ -z "$SEL" ]; then
|
||||
echo "TIA selection: empty (no source/test changes)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "No source/test changes — skipping unit tests"; exit 0
|
||||
fi
|
||||
if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi
|
||||
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
|
||||
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
|
||||
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
|
||||
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
||||
# run race-free regardless of concurrency.
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "TIA selection: __RUN_ALL__ (fail-safe) — full-suite authority stays with fast-unit" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
|
||||
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
|
||||
exit 0
|
||||
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
|
||||
fi
|
||||
echo "TIA selection: $(grep -c . tia-selection.txt) impacted test file(s) — full suite still runs in fast-unit (shadow-evidence phase, #8084)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Running impacted tests:"; echo "$SEL"
|
||||
mapfile -t FILES <<< "$SEL"
|
||||
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
|
||||
# under `--import tsx` (CJS transform — required for ESM-only deep imports like
|
||||
# @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under
|
||||
# `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
|
||||
# module-shape test the impact map selects ("Unexpected token 'export'").
|
||||
DASH=(); REST=()
|
||||
for f in "${FILES[@]}"; do
|
||||
case "$f" in
|
||||
tests/unit/dashboard/*) DASH+=("$f") ;;
|
||||
*) REST+=("$f") ;;
|
||||
esac
|
||||
done
|
||||
RC=0
|
||||
if [ ${#REST[@]} -gt 0 ]; then
|
||||
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$?
|
||||
fi
|
||||
if [ ${#DASH[@]} -gt 0 ]; then
|
||||
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
|
||||
fi
|
||||
exit $RC
|
||||
# #8084 shadow evidence: keep the raw selection downloadable so TIA misses can be
|
||||
# audited against fast-unit failures on the same run (gate moves need this data).
|
||||
- name: Upload TIA selection (shadow evidence)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: tia-selection
|
||||
path: tia-selection.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
fast-vitest:
|
||||
name: Vitest (fast-path)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
|
||||
# which is where flaky-detection volume actually comes from (ci.yml's heavy
|
||||
# jobs only run on the release PR). Advisory upload, own-origin only.
|
||||
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
|
||||
- name: Upload test results to Trunk (advisory)
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
continue-on-error: true
|
||||
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
|
||||
with:
|
||||
junit-paths: trunk-junit/**/*.xml
|
||||
org-slug: omniroute
|
||||
token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
fast-unit:
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
|
||||
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
|
||||
# runner box). Node's native --test-shard=N/total takes any denominator — only
|
||||
# this matrix and the TEST_SHARD env below encode the shard count.
|
||||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- 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.
|
||||
- run: npm run test:unit:ci:shard
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
|
||||
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
|
||||
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
|
||||
# config/quality/eslint-suppressions.json congela as violações EXISTENTES por
|
||||
# arquivo+regra; qualquer warning NOVO aparece e o --max-warnings 0 falha o job — o
|
||||
# drift de +41/+88 warnings por ciclo passa a morrer no PR que o introduz, em vez de
|
||||
# ser rebaselinado às cegas na release. Aperto do baseline (na reconciliação da
|
||||
# release): npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json
|
||||
#
|
||||
# Princípio Zero: bloqueante SÓ para branches internas (as campanhas/sessões são a
|
||||
# origem do drift). PR de FORK roda em modo report (continue-on-error → o job fica
|
||||
# verde com anotação; a campanha /green-prs aplica o fix via co-autoria — o
|
||||
# contribuidor NUNCA é bloqueado nem cobrado).
|
||||
lint-guard:
|
||||
name: No new ESLint warnings
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
# G0 (trilho .50): security-events:read lets the CodeQL ratchet below read open
|
||||
# code-scanning alerts via `gh api .../code-scanning/alerts` (same as ci.yml's
|
||||
# quality-gate job). contents: read keeps checkout working.
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
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
|
||||
# ── G0 (trilho .50): motor de ratchet também no trilho B ─────────────────────
|
||||
# This job just wrote .artifacts/eslint-results.json — collect-metrics prefers
|
||||
# that file, so the ratchet engine lands here at ZERO extra ESLint cost (one
|
||||
# inventory, two consumers; same reason ci.yml chains lint → quality-gate).
|
||||
# The coverage-report artifact does not exist on this rail, so both ratchet
|
||||
# invocations run --allow-missing: coverage.* metrics skip gracefully while
|
||||
# the deterministic ones (eslint / openapi-coverage / i18n-ui) stay BLOCKING.
|
||||
# Coverage authority remains on the main rail (ci.yml test-coverage → quality-gate).
|
||||
- run: npm run quality:collect
|
||||
- name: Ratchet check (blocking)
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md
|
||||
- name: Require-tighten (blocking)
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten
|
||||
# CodeQL alerts ratchet — same semantics as ci.yml quality-gate: exits 1 ONLY
|
||||
# on a real regression (open alerts > baseline in quality-baseline.json);
|
||||
# a measurement failure (gh/auth/api) self-skips with exit 0.
|
||||
- name: CodeQL alerts ratchet (blocking)
|
||||
run: npm run check:codeql-ratchet
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Append ratchet summary
|
||||
if: always()
|
||||
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" || true
|
||||
|
||||
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
|
||||
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
|
||||
# bullets vizinhos/seções inteiras (incidente #6193, 2026-07-05: 212 linhas /
|
||||
# 130 bullets); o checkout de PR é refs/pull/N/merge, então comparar contra a
|
||||
# base detecta o eat ANTES do merge. (2) SKILL.md gerado stale vs o catálogo de
|
||||
# agent-skills (#6186 mergeou um id de catálogo sem rodar o gerador → 8 reds de
|
||||
# integration invisíveis até a release).
|
||||
#
|
||||
# Princípio Zero: bloqueante SÓ para branches internas; PR de FORK roda em modo
|
||||
# report (continue-on-error) — a campanha corrige via co-autoria, o contribuidor
|
||||
# nunca é bloqueado.
|
||||
merge-integrity:
|
||||
name: Merge integrity (changelog + generated skills)
|
||||
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- 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)
|
||||
run: npm run check:agent-skills-sync
|
||||
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}"
|
||||
|
||||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@v2.4.4
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
||||
8
.github/workflows/semgrep.yml
vendored
8
.github/workflows/semgrep.yml
vendored
@@ -6,19 +6,13 @@ on:
|
||||
branches: ["main"]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel superseded PR runs (same rule as quality.yml). No paths filter on purpose:
|
||||
# p/secrets must keep scanning docs-only diffs too — credentials leak in .md files.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run semgrep (advisory)
|
||||
|
||||
4
.github/workflows/wiki-sync.yml
vendored
4
.github/workflows/wiki-sync.yml
vendored
@@ -9,7 +9,7 @@ name: Wiki Sync
|
||||
# It does NOT overwrite existing wiki pages by default: several docs sources still carry
|
||||
# stale counts (e.g. ARCHITECTURE.md says "177 providers" while the wiki cover is 226),
|
||||
# so blind overwrite would regress the wiki. Full content parity (--update-existing) is
|
||||
# gated on regenerating those sources first.
|
||||
# gated on regenerating those sources — see docs/ops/DOCUMENTATION_AUDIT_REPORT.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
|
||||
76
.gitignore
vendored
76
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# project-specific directories
|
||||
.slim/deepwork/
|
||||
.omnivscodeagent/
|
||||
omnirouteCloud/
|
||||
omnirouteSite/
|
||||
@@ -18,7 +17,7 @@ _tasks/
|
||||
.logs/**
|
||||
.tests/**
|
||||
.coverage/**
|
||||
/coverage/
|
||||
coverage/
|
||||
.dist/**
|
||||
.next/**
|
||||
.build/**
|
||||
@@ -44,7 +43,6 @@ memory-bank/
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never commit)
|
||||
/_*/
|
||||
/_*
|
||||
|
||||
# Draft features documentation (internal only)
|
||||
docs/new-features/
|
||||
@@ -58,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/
|
||||
|
||||
@@ -70,8 +72,6 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.homolog.example
|
||||
!.env.devin-bridge.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
@@ -84,7 +84,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# data and logs
|
||||
/data/
|
||||
data/
|
||||
.data/
|
||||
logs/*
|
||||
test_output.log
|
||||
@@ -106,7 +106,7 @@ open-sse/test/*
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
/cloud/
|
||||
cloud/
|
||||
.tmp/
|
||||
|
||||
# Security Analysis (standalone project with own git)
|
||||
@@ -120,8 +120,6 @@ app.log
|
||||
deploy.sh
|
||||
docker-compose.minimal.yml
|
||||
|
||||
# Docker Compose override (local-only, never commit)
|
||||
docker-compose.override.yml
|
||||
|
||||
# Backup directories
|
||||
app.__qa_backup/
|
||||
@@ -157,15 +155,12 @@ vscode-extension/
|
||||
|
||||
# Empty/dangling files
|
||||
typescript
|
||||
/MAX
|
||||
|
||||
# Gemini Antigravity agent data
|
||||
.gemini/
|
||||
|
||||
# Superpowers plans/specs (internal tooling, not project code)
|
||||
docs/superpowers/
|
||||
# Superpowers visual-companion brainstorm mockups (ephemeral)
|
||||
.superpowers/
|
||||
|
||||
# TIA test-impact map — generated at runtime in CI (build-test-impact-map.mjs), never committed (~21MB)
|
||||
config/quality/test-impact-map.json
|
||||
@@ -173,6 +168,7 @@ config/quality/test-impact-map.json
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
.worktrees
|
||||
bin/omniroute.mjs
|
||||
|
||||
# Consistent with .dockerignore / .npmignore
|
||||
.omc/
|
||||
@@ -201,16 +197,13 @@ scripts/i18n/_pending-keys.json
|
||||
.claude/worktrees/
|
||||
.codegraph/
|
||||
|
||||
# Test executable shims belong in the OS temporary directory, not the repository root
|
||||
/.fakebin-*/
|
||||
|
||||
# Fumadocs generated source
|
||||
.source/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
.antigravitycli/
|
||||
/.claude/
|
||||
.claude/
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
@@ -225,26 +218,6 @@ CODEX-SETUP-PROMPT.md
|
||||
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
|
||||
config/quality/quality-metrics.json
|
||||
|
||||
# Electron desktop build output unpacked into the repo root.
|
||||
# `electron-builder` (squirrel-windows target) unpacks the packaged app — the
|
||||
# entire Chromium runtime, ~24k files — directly into the repository root.
|
||||
# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/`
|
||||
# or `resources/` would also swallow tracked sources such as the CLI
|
||||
# translations in `bin/cli/locales/*.json`.
|
||||
/OmniRoute.exe
|
||||
/Uninstall OmniRoute.exe
|
||||
/uninstallerIcon.ico
|
||||
/locales/
|
||||
/resources/
|
||||
/*.pak
|
||||
/*.dll
|
||||
/icudtl.dat
|
||||
/snapshot_blob.bin
|
||||
/v8_context_snapshot.bin
|
||||
/vk_swiftshader_icd.json
|
||||
/LICENSE.electron.txt
|
||||
/LICENSES.chromium.html
|
||||
|
||||
# Runtime logs (diretório local, nunca versionado)
|
||||
/logs/
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
|
||||
@@ -254,34 +227,3 @@ docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md
|
||||
omniroute.md
|
||||
|
||||
# mise configuration
|
||||
mise.toml
|
||||
_artifacts/ # release-green artifacts
|
||||
.claude-flow/
|
||||
|
||||
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
/.eslintcache-*
|
||||
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.artifacts/
|
||||
/perf-audit*.md
|
||||
/quality-ratchet/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
tests/homolog/.auth/
|
||||
tests/homolog/ui/.auth/
|
||||
homolog-report/
|
||||
docker-compose.yml.bak
|
||||
|
||||
# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO
|
||||
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
|
||||
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
|
||||
/_tasks
|
||||
|
||||
# CLI local cache/state
|
||||
.playwright-cli
|
||||
|
||||
@@ -74,23 +74,3 @@
|
||||
# '''tests/unit/''',
|
||||
# ]
|
||||
#
|
||||
|
||||
[[rules]]
|
||||
# Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3,
|
||||
# plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO
|
||||
# de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API
|
||||
# da Anthropic (documentado publicamente, não é segredo).
|
||||
id = "generic-api-key"
|
||||
[rules.allowlist]
|
||||
description = "Field names + public Anthropic beta-header value (não são segredos)"
|
||||
regexes = [
|
||||
'''latencyP\d{2}Ms''',
|
||||
'''interleaved-thinking-2025-05-14''',
|
||||
# v3.8.49 pre-flight (2026-07-28). Nenhum dos dois e credencial:
|
||||
# - chave de localStorage do banner de patrocinio (#8723; #10200 bumpou v1->v2,
|
||||
# generalizado para -v\d+ no round 3 de base-reds #9985), so um identificador de UI;
|
||||
# - x-api-key PUBLICO do Firefly web (documentado em open-sse/utils/publicCreds.ts:207);
|
||||
# as duas ocorrencias sinalizadas estao em COMENTARIOS JSDoc, o runtime le de resolvePublicCred().
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v\d+''',
|
||||
'''SunbreakWebUI1''',
|
||||
]
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
# .husky/pre-push — intentionally light.
|
||||
# any-budget + tracked-artifacts already run on pre-commit; re-running them on
|
||||
# every push only doubles local wall time for the same existence reason (CI still
|
||||
# enforces both). Keep this hook as a PATH/npm sanity check + reminder.
|
||||
# Intentionally excludes test:unit / typecheck (slow; covered by CI).
|
||||
# .husky/pre-push — fast deterministic gates (<10s total)
|
||||
# Intentionally excludes test:unit (slow; covered by CI pre-push remote run).
|
||||
# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub)
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
|
||||
@@ -11,5 +9,4 @@ if ! command -v npm >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No-op success: real local gates live in pre-commit; CI owns the rest.
|
||||
exit 0
|
||||
npm run check:any-budget:t11 && npm run check:tracked-artifacts
|
||||
|
||||
17
.mcp.json.example
Normal file
17
.mcp.json.example
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$comment_purpose": "OPT-IN agent-lsp / LSP-in-the-loop (Quality Gates Fase 7 Task 15). Copy this file to `.mcp.json` to enable. It exposes a TypeScript language server to coding agents (Claude Code, etc.) so they get diagnostics / hover / go-to-definition / blast-radius BEFORE writing code — turning 'invented symbol' review-catches into impossible-at-edit-time. Pairs with `npm run typecheck:core` as a compile-before-claim check.",
|
||||
"$comment_safety": "Shipped as `.example` (NOT `.mcp.json`) on purpose so it never auto-loads an unvetted server into everyone's session. Pick an MCP<->LSP bridge you trust and have verified locally, then drop in its package + args below. A broken MCP entry only logs a connection error; it does not break agent sessions. The underlying language server is `typescript-language-server` (npm, mature) — install via `npm i -g typescript-language-server typescript` or rely on npx.",
|
||||
"mcpServers": {
|
||||
"typescript-lsp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"<your-mcp-lsp-bridge>",
|
||||
"--lsp",
|
||||
"typescript-language-server",
|
||||
"--stdio"
|
||||
],
|
||||
"$note": "Replace <your-mcp-lsp-bridge> with the concrete MCP<->LSP adapter you chose. It must speak MCP on stdio and proxy to `typescript-language-server --stdio`. Scope it to this repo's tsconfig (open-sse/tsconfig.json / tsconfig.json) for accurate diagnostics."
|
||||
}
|
||||
}
|
||||
}
|
||||
74
.mergify.yml
74
.mergify.yml
@@ -1,74 +0,0 @@
|
||||
# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan.
|
||||
#
|
||||
# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE
|
||||
# identity. The manual merge-train validated batches by hand; this queue automates
|
||||
# it with batching + automatic batch bisection (a red batch of N costs ~log2(N)
|
||||
# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo.
|
||||
#
|
||||
# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's
|
||||
# pre-merge ⭐ gate):
|
||||
# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a
|
||||
# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label
|
||||
# IS the merge approval; Mergify only executes it.
|
||||
# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs
|
||||
# targeting the frozen branch — the freeze is a human-honored coordination signal
|
||||
# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21).
|
||||
# • Never label a PR another session is actively working (Hard Rule #22b).
|
||||
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
|
||||
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
|
||||
|
||||
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
|
||||
# merge_protections_settings — the rules-based queue action / autoqueue path is
|
||||
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
|
||||
merge_protections_settings:
|
||||
auto_merge_conditions:
|
||||
- label = queue
|
||||
|
||||
queue_rules:
|
||||
- name: release
|
||||
# Any current or future release branch — the reason GitHub's native queue was
|
||||
# rejected (no wildcard support on personal-account repos).
|
||||
queue_conditions:
|
||||
- base~=^release/v\d+\.\d+\.\d+$
|
||||
- label=queue
|
||||
- -draft
|
||||
- -conflict
|
||||
# "Everything that ran is green, nothing still running, AND the always-on
|
||||
# anchor check succeeded" — robust to the path-filtered fast-gates (docs-only
|
||||
# PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with
|
||||
# zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY
|
||||
# non-draft PR (quality.yml) and must be an affirmative success. Review approval
|
||||
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
|
||||
# approval in this repo's single-maintainer model (see governance header).
|
||||
merge_conditions:
|
||||
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
|
||||
# continue-on-error by design, and its GH-hosted Turbopack build hangs
|
||||
# recurrently mid-"Creating an optimized production build" (100% failure rate
|
||||
# across every sampled PR since the job was added 2026-07-27, always killed by
|
||||
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
|
||||
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
|
||||
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
|
||||
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
|
||||
# carrying its tolerance forward would mask problems it no longer causes.
|
||||
- or:
|
||||
- "#check-failure=0"
|
||||
- and:
|
||||
- "#check-failure=1"
|
||||
- check-failure=Build (advisory)
|
||||
- "#check-pending=0"
|
||||
- "#check-success>=1"
|
||||
- check-success=Merge integrity (changelog + generated skills)
|
||||
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
|
||||
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
|
||||
# the free plan). Serial queue (1 PR at a time) still automates the train.
|
||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||
merge_method: squash
|
||||
|
||||
pull_request_rules:
|
||||
- name: clean up the queue label after merge
|
||||
conditions:
|
||||
- merged
|
||||
actions:
|
||||
label:
|
||||
remove:
|
||||
- queue
|
||||
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/
|
||||
|
||||
10
.npmrc
10
.npmrc
@@ -2,13 +2,3 @@
|
||||
# Keeping peer auto-install disabled prevents npm from pulling @lobehub/ui/mermaid
|
||||
# back into the tree and reopening npm audit findings for unused packages.
|
||||
legacy-peer-deps=true
|
||||
|
||||
# Network resilience: enlarge npm's fetch retry budget so a transient registry
|
||||
# socket reset (ECONNRESET) mid-download retries instead of failing the job.
|
||||
# npm defaults to only 2 retries with short timeouts; `npm ci` in
|
||||
# electron-release.yml hit ECONNRESET during v3.8.41 publish. Applies to every
|
||||
# CI workflow (electron / docker / unit) and local installs.
|
||||
fetch-retries=5
|
||||
fetch-retry-factor=4
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=120000
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
|
||||
docs/reference/ENVIRONMENT.md
|
||||
|
||||
# Generated by `npm run gen:provider-reference`; the generator aligns the tables and
|
||||
# is their formatter of record. Without this, lint-staged reformats the file whenever
|
||||
# it is staged and the next generator run reverts it — a diff ping-pong.
|
||||
docs/reference/PROVIDER_REFERENCE.md
|
||||
|
||||
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
|
||||
open-sse/config/freeModelCatalog.data.ts
|
||||
|
||||
# Generated by scripts/skills/generate-agent-skills.mjs; the generator is their
|
||||
# formatter of record and check:agent-skills-sync diffs its output byte-for-byte.
|
||||
# Prettier reformats the frontmatter (blank line after ---), which makes the gate
|
||||
# fail on any skill that happens to pass through lint-staged.
|
||||
skills/*/SKILL.md
|
||||
8
.source/dynamic.ts
Normal file
8
.source/dynamic.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// @ts-nocheck
|
||||
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
|
||||
import * as Config from '../source.config';
|
||||
|
||||
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
|
||||
DocData: {
|
||||
}
|
||||
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});
|
||||
22
.source/source.config.mjs
Normal file
22
.source/source.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
// source.config.ts
|
||||
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
|
||||
var docs = defineDocs({
|
||||
dir: "docs",
|
||||
docs: {
|
||||
files: [
|
||||
"./architecture/**/*.md",
|
||||
"./guides/**/*.md",
|
||||
"./reference/**/*.md",
|
||||
"./frameworks/**/*.md",
|
||||
"./routing/**/*.md",
|
||||
"./security/**/*.md",
|
||||
"./compression/**/*.md",
|
||||
"./ops/**/*.md"
|
||||
]
|
||||
}
|
||||
});
|
||||
var source_config_default = defineConfig();
|
||||
export {
|
||||
source_config_default as default,
|
||||
docs
|
||||
};
|
||||
22
.trivyignore
22
.trivyignore
@@ -1,22 +0,0 @@
|
||||
# .trivyignore — accepted-risk suppressions for the container image scan
|
||||
#
|
||||
# Policy (see docs/security/SUPPLY_CHAIN.md):
|
||||
# - The Trivy steps in .github/workflows/docker-publish.yml run with
|
||||
# `ignore-unfixed: true`, so vulnerabilities WITHOUT a published fix are
|
||||
# already excluded from both the blocking CRITICAL gate and the advisory
|
||||
# Security-tab upload. You do NOT need an entry here for an unfixable
|
||||
# base-image OS CVE — it will not be reported.
|
||||
# - This file is the single auditable home for the rare case where a *fixable*
|
||||
# CVE must be temporarily accepted (e.g. the upstream fix is not yet in the
|
||||
# pinned base tag, or the affected package/binary is provably unreachable
|
||||
# from the proxy request surface and rebuilding now is not justified).
|
||||
#
|
||||
# Format — one CVE id per line, each with a justification comment and, where
|
||||
# possible, an expiry, e.g.:
|
||||
# # CVE-XXXX-YYYY — <why accepted>; revisit on next base-image bump (YYYY-MM-DD)
|
||||
# CVE-XXXX-YYYY
|
||||
#
|
||||
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
|
||||
# patched base / bump the dep) over suppressing. Stale entries are debt.
|
||||
#
|
||||
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -48,19 +48,11 @@
|
||||
"**/.build",
|
||||
"**/dist",
|
||||
"**/coverage",
|
||||
"**/.worktrees",
|
||||
"**/.claude/worktrees",
|
||||
"**/electron",
|
||||
"**/_references",
|
||||
"**/_mono_repo",
|
||||
"**/_tasks"
|
||||
"**/.worktrees"
|
||||
]
|
||||
},
|
||||
|
||||
// Para esconder os diretórios gerados da árvore do Explorer, descomente:
|
||||
// (MANTIDO comentado — o dono precisa ver _references/_mono_repo/_tasks na árvore.
|
||||
// A performance é resolvida por watcherExclude + search.exclude + tsserver, sem
|
||||
// precisar escondê-los do Explorer.)
|
||||
// "files.exclude": {
|
||||
// "**/.worktrees": true,
|
||||
// "**/coverage": true,
|
||||
|
||||
@@ -10,8 +10,7 @@ This plugin solves that by:
|
||||
|
||||
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
|
||||
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
|
||||
- Re-fetching on a configurable TTL (default 5 min) **and** background auto-discovery while OpenCode is running (`autoSyncIntervalMs`, default 5 min), so new models / combo changes appear without restarting OpenCode
|
||||
- Exposing a force-refresh path (`omniroute_sync_models` tool + `/omni-sync` command template) equivalent to Pi `/omni sync`
|
||||
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
|
||||
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
|
||||
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
|
||||
|
||||
@@ -30,7 +29,7 @@ omniroute setup opencode --auth
|
||||
# 3. Restart OpenCode — /models lists the full live catalog
|
||||
```
|
||||
|
||||
The `--auth` flag runs `opencode auth login --provider opencode-omniroute` automatically.
|
||||
The `--auth` flag runs `opencode auth login --provider omniroute` automatically.
|
||||
Use `--base-url` to point at a non-default OmniRoute address:
|
||||
|
||||
```sh
|
||||
@@ -74,9 +73,6 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
// Background re-discovery while OpenCode is running (Pi parity).
|
||||
// Default 300000 (5 min). Minimum 60000. Set 0 to disable.
|
||||
"autoSyncIntervalMs": 300000,
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -84,7 +80,7 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode auth login --provider opencode-omniroute
|
||||
opencode auth login --provider omniroute
|
||||
# prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json
|
||||
```
|
||||
|
||||
@@ -92,27 +88,6 @@ opencode auth login --provider opencode-omniroute
|
||||
|
||||
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
|
||||
|
||||
### Live catalog refresh (auto + force)
|
||||
|
||||
While OpenCode is running, the plugin keeps the model catalog fresh in two ways:
|
||||
|
||||
| Mechanism | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `modelCacheTtl` | `300000` (5 min) | On-demand TTL: next provider/models hook after expiry re-fetches `/v1/models` |
|
||||
| `autoSyncIntervalMs` | `300000` (5 min) | Background timer: proactively invalidates + re-fetches while the harness is running. Min `60000`. Set `0` to disable background polling (TTL still applies) |
|
||||
|
||||
**Force sync now** (Pi `/omni sync` equivalent) — OpenCode has no Pi-style slash-command registration API, so the plugin wires both a tool and command templates:
|
||||
|
||||
1. **Tool:** `omniroute_sync_models` — invalidates in-memory + disk caches, re-fetches `GET /v1/models` (and combos/enrichment when enabled), returns `{ ok, count, ... }`.
|
||||
2. **Command templates** (type these in OpenCode):
|
||||
- `/omni-sync` — asks the agent to call `omniroute_sync_models` and report the result
|
||||
- `/omni-autosync` — asks the agent to report current `autoSyncIntervalMs` / `modelCacheTtl` status
|
||||
|
||||
```text
|
||||
/omni-sync
|
||||
/omni-autosync
|
||||
```
|
||||
|
||||
## Multi-instance (prod + preprod side-by-side)
|
||||
|
||||
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
|
||||
@@ -164,8 +139,8 @@ Then in `~/.config/opencode/opencode.json` reference each directory by absolute
|
||||
Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each:
|
||||
|
||||
```sh
|
||||
opencode auth login --provider opencode-omniroute
|
||||
opencode auth login --provider opencode-omniroute-preprod
|
||||
opencode auth login --provider omniroute
|
||||
opencode auth login --provider omniroute-preprod
|
||||
```
|
||||
|
||||
Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk.
|
||||
@@ -190,13 +165,12 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
|
||||
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
|
||||
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
|
||||
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
|
||||
| `combo/<slug>` namespace + `Combo:` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
|
||||
| `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
|
||||
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
|
||||
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
|
||||
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
|
||||
| Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks |
|
||||
| Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks |
|
||||
| Model allowlist/blocklist | Curate the model picker to a fixed set of IDs via `features.visibleModels` (allowlist) and/or `features.hiddenModels` (blocklist). Bare suffixes like `claude-opus-4-7` match any `{prefix}/claude-opus-4-7`. Both compose with `usableOnly` (all filters AND together). Blocklist wins over allowlist (deny takes precedence) | both hooks |
|
||||
| Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` |
|
||||
| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` |
|
||||
| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap |
|
||||
@@ -205,35 +179,30 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
|
||||
|
||||
## Plugin options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------- | -------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
|
||||
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
|
||||
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
|
||||
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
|
||||
| `managementReadToken` | `string` | falls back to `apiKey` | Optional read-only token for management catalog GETs; `/v1` inference stays on the connected `apiKey` |
|
||||
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
|
||||
|
||||
For least-privilege deployments, set top-level `managementReadToken` to a read-only management token. It is sent only to catalog reads (`/api/combos`, `/api/combos/auto`, `/api/pricing/models`, `/api/pricing`, `/api/context/combos`, and `/api/providers`). Inference requests under `/v1`, including chat, continue to use the `apiKey` stored by OpenCode. `features.mcpToken` remains independent. If `managementReadToken` is omitted, catalog reads retain the previous `apiKey` behavior.
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
|
||||
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
|
||||
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
|
||||
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
|
||||
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
|
||||
|
||||
### `features` block
|
||||
|
||||
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
|
||||
|
||||
| Feature | Type | Default | What it does |
|
||||
| --------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
|
||||
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` → `cacheRead`, `cache_creation` → `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
|
||||
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
|
||||
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` → `GHM`, `Gemini` → `GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). |
|
||||
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
|
||||
| `visibleModels` | `string[]` | _unset_ | Allowlist — when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. |
|
||||
| `hiddenModels` | `string[]` | _unset_ | Blocklist — models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. |
|
||||
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
|
||||
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
|
||||
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
|
||||
| Feature | Type | Default | What it does |
|
||||
| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
|
||||
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` → `cacheRead`, `cache_creation` → `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
|
||||
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
|
||||
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` → `GHM`, `Gemini-cli` → `GEMINI-CLI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
|
||||
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
|
||||
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
|
||||
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
|
||||
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
|
||||
|
||||
#### Example — enrichment + compression tags + MCP auto-emit
|
||||
|
||||
@@ -245,7 +214,6 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
"managementReadToken": "<read-only-management-token>",
|
||||
"features": {
|
||||
"combos": true,
|
||||
"enrichment": true,
|
||||
@@ -301,45 +269,7 @@ If you want a narrower-scoped Bearer for MCP (different from the chat/inference
|
||||
- `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't.
|
||||
- `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars → abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format.
|
||||
|
||||
#### Example — curating the model picker (allowlist + blocklist)
|
||||
|
||||
A typical OmniRoute instance serves 600+ models. The OpenCode TUI/CLI picker becomes unusable when you need to scroll through hundreds of entries to find the ~30 models you actually use. `visibleModels` and `hiddenModels` let you curate the picker to a fixed set of model IDs that persists in `opencode.json` across config resets.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
"features": {
|
||||
"combos": true,
|
||||
"enrichment": true,
|
||||
"usableOnly": true,
|
||||
"visibleModels": [
|
||||
"claude-opus-4-7", // bare suffix: matches cc/claude-opus-4-7, kr/claude-opus-4-7, etc.
|
||||
"cc/claude-sonnet-4-6", // exact: only the cc/ alias
|
||||
"gemini-2.5-pro",
|
||||
"gpt-5",
|
||||
"o3",
|
||||
"o3-pro",
|
||||
"o4-mini",
|
||||
],
|
||||
"hiddenModels": [
|
||||
"o3-mini", // hide the mini variant even if visibleModels is unset
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
- `visibleModels` is an allowlist — only models whose raw ID matches are emitted. Bare IDs (no slash) match any provider prefix; full IDs (with slash) match exactly.
|
||||
- `hiddenModels` is a blocklist — listed models are dropped. When a model is in both lists, the blocklist wins (deny takes precedence).
|
||||
- Both compose with `usableOnly` (all filters AND together: a model must pass usableOnly AND visibleModels AND not be in hiddenModels).
|
||||
- Unset or empty = no filter (current behavior).
|
||||
## Comparison vs `@omniroute/opencode-provider`
|
||||
|
||||
[`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.<id>` block into `opencode.json` at build time. This plugin is the runtime integration.
|
||||
|
||||
|
||||
4
@omniroute/opencode-plugin/package-lock.json
generated
4
@omniroute/opencode-plugin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.1",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.1",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.1",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,12 @@ import { createOmniRouteAuthHook } from "../src/index.js";
|
||||
|
||||
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(hook.provider, "opencode-omniroute");
|
||||
assert.equal(hook.provider, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
|
||||
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(hook.provider, "opencode-omniroute-preprod");
|
||||
assert.equal(hook.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
|
||||
@@ -30,7 +30,7 @@ test("createOmniRouteAuthHook: methods[0] is type 'api' with label including dis
|
||||
assert.equal(m.label, "OmniRoute API Key");
|
||||
|
||||
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (opencode-omniroute-preprod) API Key");
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Auto-discovery + force-sync (OpenCode parity with Pi `/omni sync`).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
sanitizeAutoSyncIntervalMs,
|
||||
DEFAULT_AUTO_SYNC_INTERVAL_MS,
|
||||
MIN_AUTO_SYNC_INTERVAL_MS,
|
||||
parseOmniRoutePluginOptions,
|
||||
resolveOmniRoutePluginOptions,
|
||||
invalidateOmniRouteFetchCache,
|
||||
forceSyncOmniRouteModels,
|
||||
type OmniRouteFetchCache,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(null), DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: 0 disables", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(0), 0);
|
||||
});
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: clamps below min to 60000", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(1), MIN_AUTO_SYNC_INTERVAL_MS);
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(59_999), MIN_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(60_000), 60_000);
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(300_000), 300_000);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
|
||||
const r = resolveOmniRoutePluginOptions({});
|
||||
assert.equal(r.autoSyncIntervalMs, DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions clamps low positive autoSyncIntervalMs", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ autoSyncIntervalMs: 5000 });
|
||||
assert.equal(r.autoSyncIntervalMs, MIN_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("invalidateOmniRouteFetchCache clears by baseURL prefix", () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
cache.set("https://a.example/v1::abc", {
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
expiresAt: Date.now() + 1000,
|
||||
});
|
||||
cache.set("https://b.example/v1::def", {
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
expiresAt: Date.now() + 1000,
|
||||
});
|
||||
const removed = invalidateOmniRouteFetchCache(cache, "https://a.example/v1");
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(cache.has("https://b.example/v1::def"), true);
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
autoSyncIntervalMs: 0,
|
||||
features: {
|
||||
combos: false,
|
||||
autoCombos: false,
|
||||
enrichment: false,
|
||||
compressionMetadata: false,
|
||||
usableOnly: false,
|
||||
diskCache: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({
|
||||
omniroute: { type: "api", key: "test-key" },
|
||||
}),
|
||||
fetcher: async () => [
|
||||
{ id: "model-a", object: "model" },
|
||||
{ id: "model-b", object: "model" },
|
||||
],
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 2);
|
||||
assert.equal(result.provider, "omniroute");
|
||||
assert.equal(cache.size, 1);
|
||||
const entry = [...cache.values()][0];
|
||||
assert.equal(entry.rawModels.length, 2);
|
||||
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { diskCache: false },
|
||||
});
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({}),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? "", /credentials|baseURL|connect/i);
|
||||
});
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
createOmniRouteProviderHook,
|
||||
OmniRoutePlugin,
|
||||
resolveOmniRoutePluginOptions,
|
||||
_resetInflightRefresh,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteEnrichmentEntry,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
@@ -48,16 +47,6 @@ import {
|
||||
type OmniRouteStaticProviderEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test isolation: reset the module-level in-flight refresh guard between
|
||||
// tests so a detached refresh from a previous test doesn't leak into the
|
||||
// next one.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
_resetInflightRefresh();
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -214,7 +203,7 @@ function makeInput(initialProvider: Record<string, unknown> = {}): Config {
|
||||
|
||||
test("config: with valid auth.json + apiKey + baseURL → mutates input.provider[id] with stripped models block", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
omniroute: { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -228,8 +217,8 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "input.provider['opencode-omniroute'] set");
|
||||
const entry = provider.omniroute;
|
||||
assert.ok(entry, "input.provider.omniroute set");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(entry.name, "OmniRoute");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
@@ -238,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// Stripped per-model shape: name + cap flags + modalities + (optional)
|
||||
// cost. OC's SDK static schema accepts only `limit.{context,output}` —
|
||||
// `limit.input` is NOT in the SDK shape and gets dropped silently.
|
||||
const claude = entry.models["claude-sonnet-4-6"];
|
||||
const claude = entry.models["omniroute/claude-sonnet-4-6"];
|
||||
assert.ok(claude, "claude model surfaced");
|
||||
assert.equal(claude.name, "claude-sonnet-4-6");
|
||||
assert.equal(claude.attachment, true);
|
||||
@@ -259,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
|
||||
// Combo surfaces under bare key + LCD'd
|
||||
// (gemini's reasoning=false → combo reasoning=false).
|
||||
const combo = entry.models["claude-tier"];
|
||||
const combo = entry.models["omniroute/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under bare key");
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
|
||||
@@ -267,65 +256,6 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
assert.equal(combo.limit?.context, 200_000, "LCD: min(200_000, 1_000_000)");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1b. Dual-key fallback (#5027) — auth.json stored under the BARE providerId
|
||||
// (pre-auto-prefix login) must still resolve when the active providerId is
|
||||
// prefixed (`opencode-omniroute`). Without the fallback the lookup misses
|
||||
// the stored key and the user is forced to re-auth.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: auth.json under bare key (pre-prefix login) resolves via dual-key fallback", async () => {
|
||||
// Stored under bare `omniroute` (the key OC wrote before the auto-prefix fix),
|
||||
// but the resolved providerId is now `opencode-omniroute`.
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-bare-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" }, // resolves to opencode-omniroute internally
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "provider entry published from bare-key apiKey");
|
||||
assert.equal(entry.options.apiKey, "sk-bare-1", "apiKey resolved from the bare auth.json key");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
});
|
||||
|
||||
test("config: prefixed key wins over bare key when both present (dual-key precedence)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-prefixed", baseURL: "https://pref.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-bare", baseURL: "https://bare.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.options.apiKey,
|
||||
"sk-prefixed",
|
||||
"prefixed key takes precedence (looked up first)"
|
||||
);
|
||||
assert.equal(entry.options.baseURL, "https://pref.example/v1");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Missing auth.json → no-op, no throw, no mutation
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -393,7 +323,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
models: { "manual-model": { name: "manual-model" } },
|
||||
};
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -403,11 +333,11 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput({ "opencode-omniroute": manual });
|
||||
const input = makeInput({ omniroute: manual });
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, unknown> }).provider;
|
||||
assert.equal(provider["opencode-omniroute"], manual, "manual override preserved by reference");
|
||||
assert.equal(provider.omniroute, manual, "manual override preserved by reference");
|
||||
assert.equal(fetcher.callCount(), 0, "no fetch — short-circuited before I/O");
|
||||
assert.equal(readAuthJson.callCount(), 0, "no auth.json read either");
|
||||
assert.ok(
|
||||
@@ -422,7 +352,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
|
||||
test("config: fetchers throw → warn + emit stub entry with models: {}", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -438,9 +368,8 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry, "stub provider entry published even when fetchers fail");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.deepEqual(entry.models, {}, "models stub is empty object");
|
||||
@@ -463,7 +392,7 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
|
||||
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -476,16 +405,12 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, [
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["claude-tier"], undefined, "no combo entry");
|
||||
assert.deepEqual(ids, ["omniroute/claude-sonnet-4-6", "omniroute/gemini-3-flash"]);
|
||||
assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
@@ -498,7 +423,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
|
||||
test("config: baseURL from auth.json takes precedence when opts.baseURL absent", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -512,15 +437,14 @@ test("config: baseURL from auth.json takes precedence when opts.baseURL absent",
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://creds.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.options.baseURL, "https://creds.example/v1");
|
||||
});
|
||||
|
||||
test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -534,15 +458,14 @@ test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://opts.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.options.baseURL, "https://opts.example/v1");
|
||||
});
|
||||
|
||||
test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
omniroute: { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -570,12 +493,12 @@ test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op",
|
||||
|
||||
test("config: multi-instance — two plugins with different providerIds publish to their own keys without collision", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute-prod": {
|
||||
"omniroute-prod": {
|
||||
type: "api",
|
||||
key: "sk-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
},
|
||||
"opencode-omniroute-preprod": {
|
||||
"omniroute-preprod": {
|
||||
type: "api",
|
||||
key: "sk-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
@@ -599,18 +522,15 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
await hookB(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
assert.ok(provider["opencode-omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["opencode-omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["opencode-omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(
|
||||
provider["opencode-omniroute-preprod"].options.baseURL,
|
||||
"https://preprod.example/v1"
|
||||
);
|
||||
assert.ok(provider["omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(provider["omniroute-preprod"].options.baseURL, "https://preprod.example/v1");
|
||||
assert.notEqual(
|
||||
provider["opencode-omniroute-prod"],
|
||||
provider["opencode-omniroute-preprod"],
|
||||
provider["omniroute-prod"],
|
||||
provider["omniroute-preprod"],
|
||||
"blocks are distinct references"
|
||||
);
|
||||
});
|
||||
@@ -622,7 +542,7 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
|
||||
test("config + provider share cache: second call uses cached fetch result (single fetch per TTL)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -654,7 +574,7 @@ test("config + provider share cache: second call uses cached fetch result (singl
|
||||
|
||||
test("provider → config order also dedupes (cache populated by provider, consumed by config)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -734,7 +654,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
}
|
||||
|
||||
// Sanity: claude entry has all expected stripped fields.
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -759,8 +679,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
assert.equal(block.models["omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["omniroute/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -776,7 +696,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["omniroute/claude-sonnet-4-6"];
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
});
|
||||
@@ -790,7 +710,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["omniroute/claude-sonnet-4-6"];
|
||||
assert.equal((claude.limit as Record<string, unknown>).input, undefined);
|
||||
assert.equal(typeof claude.limit?.context, "number");
|
||||
assert.equal(typeof claude.limit?.output, "number");
|
||||
@@ -818,7 +738,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
enrichment
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(claude.cost?.input, 3);
|
||||
assert.equal(claude.cost?.output, 15);
|
||||
assert.equal(claude.cost?.cache_read, 0.3);
|
||||
@@ -839,8 +759,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["gemini-3-flash"].release_date, undefined);
|
||||
assert.equal(block.models["omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["omniroute/gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -869,7 +789,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["mixed-tier"];
|
||||
const combo = block.models["omniroute/mixed-tier"];
|
||||
assert.ok(combo, "combo emitted under slug key");
|
||||
// claude has text+image, text-only has text → intersection drops image.
|
||||
assert.deepEqual(combo.modalities?.input, ["text"]);
|
||||
@@ -893,7 +813,7 @@ test("OmniRoutePlugin factory exposes config hook alongside auth + provider", as
|
||||
|
||||
test("config: auth.json entry of wrong type (oauth) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
omniroute: { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -930,7 +850,7 @@ test("config: readAuthJson throws → treat as missing file (silent fallback)",
|
||||
|
||||
test("config: initialises input.provider when undefined", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -945,7 +865,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
await hook(input);
|
||||
const provider = (input as { provider?: Record<string, unknown> }).provider;
|
||||
assert.ok(provider, "provider bag initialised");
|
||||
assert.ok(provider!["opencode-omniroute"]);
|
||||
assert.ok(provider!.omniroute);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -955,7 +875,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
|
||||
test("config: enrichment fetched + name overlaid on raw-model entries", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -974,20 +894,19 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
|
||||
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
test("config: features.enrichment=false skips enrichment fetch + keeps raw-id names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1005,13 +924,12 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1019,7 +937,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
|
||||
test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1033,12 +951,11 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry, "static block still published on enrichment failure");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1082,7 +999,7 @@ const MODEL_NV_LLAMA: OmniRouteRawModelEntry = {
|
||||
|
||||
test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1104,9 +1021,8 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept");
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "nvidia kept (filter off)");
|
||||
assert.equal(providersFetcher.callCount(), 0, "providers fetch not called when feature off");
|
||||
@@ -1114,7 +1030,7 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
|
||||
test("config: usableOnly=true → drops models for non-usable providers, keeps usable + unknown", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([
|
||||
MODEL_CC_OPUS,
|
||||
@@ -1154,9 +1070,8 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept (active)");
|
||||
assert.equal(entry.models["nvidia/llama-3-70b"], undefined, "nvidia dropped (error status)");
|
||||
assert.ok(entry.models["agentrouter/synthetic-1"], "unknown prefix kept (subtract-filter)");
|
||||
@@ -1165,7 +1080,7 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
|
||||
test("config: usableOnly=true + providers fetch fails → soft-fail keeps everything", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1186,9 +1101,8 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"]);
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "soft-fail keeps both");
|
||||
assert.ok(
|
||||
@@ -1199,7 +1113,7 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
|
||||
test("config: diskCache hydrates stale snapshot when /v1/models throws", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1236,31 +1150,27 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(
|
||||
entry.models["claude-sonnet-4-6"],
|
||||
entry.models["omniroute/claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
"stale enrichment also reused"
|
||||
);
|
||||
assert.equal(writes, 0, "disk write skipped when live fetch failed");
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("using stale disk cache") ||
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
logger.entries.some((e) => String(e[0]).includes("using stale disk cache")),
|
||||
"disk-cache hydration breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1292,10 +1202,9 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
await configHook(input);
|
||||
assert.equal(enrichmentFetcher.callCount(), 1, "config reused cached enrichment");
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1306,7 +1215,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
|
||||
test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-model names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -1325,9 +1234,9 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
"gemini-3-flash",
|
||||
{
|
||||
name: "Gemini 3 Flash",
|
||||
providerAlias: "gemini",
|
||||
providerCanonical: "gemini",
|
||||
providerDisplayName: "Gemini",
|
||||
providerAlias: "gemini-cli",
|
||||
providerCanonical: "gemini-cli",
|
||||
providerDisplayName: "Gemini-cli",
|
||||
},
|
||||
],
|
||||
])
|
||||
@@ -1341,22 +1250,18 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
|
||||
assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["omniroute/gemini-3-flash"].name, "Gemini-cli - Gemini 3 Flash");
|
||||
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
|
||||
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
|
||||
});
|
||||
|
||||
test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1374,11 +1279,10 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1386,7 +1290,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
|
||||
test("config: providerTag falls back to UPPER(alias) when providerDisplayName missing", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1407,15 +1311,14 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1434,15 +1337,14 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1461,24 +1363,16 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
|
||||
const inputA = makeInput();
|
||||
await hook(inputA);
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryA.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
|
||||
// Second invocation (cache hit) — name must still be single-suffixed.
|
||||
const inputB = makeInput();
|
||||
await hook(inputB);
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryB.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1530,7 +1424,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
|
||||
);
|
||||
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
|
||||
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
|
||||
const parent = block.models["parent"];
|
||||
const parent = block.models["omniroute/parent"];
|
||||
assert.ok(parent, "Parent combo must be in the static catalog");
|
||||
assert.equal(parent.limit?.context, 8_000);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot",
|
||||
process.env.OPENCODE_DATA_DIR = tmp;
|
||||
|
||||
try {
|
||||
await defaultDiskSnapshotWriter("perm-test", makeEntry(), "test-snapshot-identity");
|
||||
await defaultDiskSnapshotWriter("perm-test", makeEntry());
|
||||
|
||||
const file = diskSnapshotPath("perm-test");
|
||||
assert.ok(fs.existsSync(file), "snapshot file should be written");
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* #7624 — explicit feature-flag defaults.
|
||||
*
|
||||
* The `features` block in opencode.json marks every toggle `.optional()` with
|
||||
* no default. The effective value was previously only knowable by tracing the
|
||||
* implicit `features.X !== false` (default-ON) / `features.X === true`
|
||||
* (default-OFF) convention scattered across each read site, which left
|
||||
* operators unsure whether combos / autoCombos / enrichment were enabled when
|
||||
* they omitted the block.
|
||||
*
|
||||
* `OMNIROUTE_FEATURE_DEFAULTS` declares those defaults explicitly and
|
||||
* `resolveEffectiveFeatureFlags(features)` derives the effective state for any
|
||||
* (possibly-undefined) features object, mirroring the read-site conventions
|
||||
* exactly. These are purely derived — runtime routing behaviour is unchanged.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
OMNIROUTE_FEATURE_DEFAULTS,
|
||||
resolveEffectiveFeatureFlags,
|
||||
} from "../src/index.js";
|
||||
|
||||
const DEFAULT_ON = [
|
||||
"combos",
|
||||
"autoCombos",
|
||||
"enrichment",
|
||||
"diskCache",
|
||||
"providerTag",
|
||||
"fetchInterceptor",
|
||||
"geminiSanitization",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_OFF = [
|
||||
"compressionMetadata",
|
||||
"usableOnly",
|
||||
"mcpAutoEmit",
|
||||
"debugLog",
|
||||
"startupDebug",
|
||||
] as const;
|
||||
|
||||
test("OMNIROUTE_FEATURE_DEFAULTS: declares each flag with its documented default", () => {
|
||||
for (const key of DEFAULT_ON) {
|
||||
assert.equal(OMNIROUTE_FEATURE_DEFAULTS[key], true, `${key} defaults ON`);
|
||||
}
|
||||
for (const key of DEFAULT_OFF) {
|
||||
assert.equal(OMNIROUTE_FEATURE_DEFAULTS[key], false, `${key} defaults OFF`);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveEffectiveFeatureFlags: undefined features → full declared default set", () => {
|
||||
const flags = resolveEffectiveFeatureFlags(undefined);
|
||||
for (const key of DEFAULT_ON) {
|
||||
assert.equal(flags[key], true, `${key} effective ON when features omitted`);
|
||||
}
|
||||
for (const key of DEFAULT_OFF) {
|
||||
assert.equal(flags[key], false, `${key} effective OFF when features omitted`);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveEffectiveFeatureFlags: empty features object → same as omitted", () => {
|
||||
assert.deepEqual(
|
||||
resolveEffectiveFeatureFlags({}),
|
||||
resolveEffectiveFeatureFlags(undefined)
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEffectiveFeatureFlags: explicit false disables a default-ON flag", () => {
|
||||
const flags = resolveEffectiveFeatureFlags({ autoCombos: false });
|
||||
assert.equal(flags.autoCombos, false, "explicit autoCombos:false honoured");
|
||||
// Untouched flags keep their declared defaults.
|
||||
assert.equal(flags.combos, true);
|
||||
assert.equal(flags.enrichment, true);
|
||||
});
|
||||
|
||||
test("resolveEffectiveFeatureFlags: explicit true enables a default-OFF flag", () => {
|
||||
const flags = resolveEffectiveFeatureFlags({ compressionMetadata: true });
|
||||
assert.equal(flags.compressionMetadata, true, "explicit compressionMetadata:true honoured");
|
||||
assert.equal(flags.usableOnly, false, "other opt-in flags stay OFF");
|
||||
});
|
||||
|
||||
test("resolveEffectiveFeatureFlags: non-boolean sibling keys do not leak into flags", () => {
|
||||
// features may also carry mcpToken/logLevel/apiFormat — the resolver must
|
||||
// only ever return the boolean toggle keys.
|
||||
const flags = resolveEffectiveFeatureFlags({
|
||||
mcpAutoEmit: true,
|
||||
mcpToken: "sk-mcp-token-abc",
|
||||
logLevel: "debug",
|
||||
});
|
||||
assert.equal(flags.mcpAutoEmit, true);
|
||||
assert.equal(Object.keys(flags).length, Object.keys(OMNIROUTE_FEATURE_DEFAULTS).length);
|
||||
assert.equal("mcpToken" in flags, false);
|
||||
assert.equal("logLevel" in flags, false);
|
||||
});
|
||||
@@ -376,7 +376,6 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "enrichment fetcher called once");
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId.
|
||||
const m = out["omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
|
||||
assert.equal(m.cost.input, 3, "enrichment pricing applied");
|
||||
@@ -402,11 +401,7 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
|
||||
assert.equal(
|
||||
out["omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id preserved"
|
||||
);
|
||||
assert.equal(out["omniroute/claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved");
|
||||
});
|
||||
|
||||
test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => {
|
||||
@@ -478,7 +473,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stubAuthJson = (apiKey: string) => async () => ({
|
||||
"opencode-omniroute": { type: "api" as const, key: apiKey },
|
||||
omniroute: { type: "api" as const, key: apiKey },
|
||||
});
|
||||
|
||||
test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
@@ -493,7 +488,7 @@ test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
assert.ok(input.provider?.["opencode-omniroute"], "provider block written");
|
||||
assert.ok(input.provider?.omniroute, "provider block written");
|
||||
assert.equal(input.mcp, undefined, "no mcp block written");
|
||||
});
|
||||
|
||||
@@ -513,7 +508,7 @@ test("config hook: features.mcpAutoEmit:true writes mcp entry with provider apiK
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["opencode-omniroute"] as
|
||||
const entry = input.mcp?.omniroute as
|
||||
| { type: string; url: string; enabled: boolean; headers: Record<string, string> }
|
||||
| undefined;
|
||||
assert.ok(entry, "mcp entry written");
|
||||
@@ -543,7 +538,7 @@ test("config hook: features.mcpToken overrides provider apiKey in mcp Bearer", a
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["opencode-omniroute"] as { headers: Record<string, string> };
|
||||
const entry = input.mcp?.omniroute as { headers: Record<string, string> };
|
||||
assert.equal(
|
||||
entry.headers.Authorization,
|
||||
"Bearer sk-mcp-narrower",
|
||||
@@ -566,11 +561,11 @@ test("config hook: existing operator mcp.<providerId> wins (no overwrite)", asyn
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {
|
||||
mcp: { "opencode-omniroute": { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
mcp: { omniroute: { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
};
|
||||
await hook(input as never);
|
||||
assert.deepEqual(
|
||||
input.mcp?.["opencode-omniroute"],
|
||||
input.mcp?.omniroute,
|
||||
{ type: "custom-user-entry", url: "https://manual.example/mcp" },
|
||||
"operator override preserved"
|
||||
);
|
||||
@@ -585,7 +580,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"opencode-omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
"omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
}),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
@@ -594,7 +589,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["opencode-omniroute-preprod"] as { url: string };
|
||||
const entry = input.mcp?.["omniroute-preprod"] as { url: string };
|
||||
assert.equal(
|
||||
entry.url,
|
||||
"https://or-preprod.example.com/api/mcp/stream",
|
||||
|
||||
@@ -50,52 +50,6 @@ test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: path-prefixed baseURL scopes auth to its normalized inference paths", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const prefixedBase = "https://or.example.com/tenant-a/v1";
|
||||
const f = createOmniRouteFetchInterceptor({
|
||||
apiKey: KEY,
|
||||
baseURL: `${prefixedBase}///`,
|
||||
});
|
||||
const streamingBody = '{"stream":true}';
|
||||
|
||||
await f(`${prefixedBase}/chat/completions?trace=1`, {
|
||||
method: "POST",
|
||||
body: streamingBody,
|
||||
headers: { Accept: "text/event-stream" },
|
||||
});
|
||||
await f(`${prefixedBase}/models/?refresh=1`);
|
||||
await f("https://or.example.com/v1/chat/completions", { method: "POST", body: "{}" });
|
||||
await f("https://or.example.com/v1/models");
|
||||
await f("https://or.example.com/tenant-b/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
await f(`${prefixedBase}/chat/completions/batch`, { method: "POST", body: "{}" });
|
||||
|
||||
const headers = calls.map(({ init }) => new Headers(init?.headers));
|
||||
assert.equal(headers[0]?.get("Authorization"), `Bearer ${KEY}`);
|
||||
assert.equal(headers[1]?.get("Authorization"), `Bearer ${KEY}`);
|
||||
for (const index of [2, 3, 4, 5]) {
|
||||
assert.equal(headers[index]?.get("Authorization"), null);
|
||||
}
|
||||
assert.equal(calls[0]?.input, `${prefixedBase}/chat/completions?trace=1`);
|
||||
assert.equal(calls[0]?.init?.body, streamingBody);
|
||||
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
|
||||
|
||||
const suffixingInterceptor = createOmniRouteFetchInterceptor({
|
||||
apiKey: KEY,
|
||||
baseURL: "https://or.example.com/tenant-a/",
|
||||
});
|
||||
await suffixingInterceptor(`${prefixedBase}/models`);
|
||||
const suffixedHeaders = new Headers(calls[6]?.init?.headers);
|
||||
assert.equal(suffixedHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
@@ -305,7 +259,7 @@ test("loader integration: wired interceptor actually injects Bearer when invoked
|
||||
{} as never
|
||||
);
|
||||
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
|
||||
await wiredFetch(`${BASE}/models`, {});
|
||||
await wiredFetch(`${BASE}/v1/models`, {});
|
||||
assert.equal(calls.length, 1);
|
||||
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
|
||||
@@ -212,8 +212,8 @@ test("shouldSanitizeForGemini: google-vertex/gemini-1.5-flash → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "google-vertex/gemini-1.5-flash" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: gemini/gemini-2.5-pro → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini/gemini-2.5-pro" }), true);
|
||||
test("shouldSanitizeForGemini: gemini-cli/gemini-2.5-pro → true (real OmniRoute alias)", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini-cli/gemini-2.5-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createOmniRouteAuthHook,
|
||||
createOmniRouteConfigHook,
|
||||
createOmniRouteProviderHook,
|
||||
parseOmniRoutePluginOptions,
|
||||
type OmniRouteCompressionMetaFetcher,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
type OmniRouteProvidersFetcher,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
const BASE_URL = "https://or.example.com/v1";
|
||||
const API_KEY = "sk-inference-only";
|
||||
const MANAGEMENT_READ_TOKEN = "sk-management-read-only";
|
||||
|
||||
const RAW_MODELS: OmniRouteRawModelEntry[] = [
|
||||
{
|
||||
id: "openai/gpt-test",
|
||||
context_length: 16_000,
|
||||
max_output_tokens: 4_000,
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: false,
|
||||
vision: false,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
];
|
||||
|
||||
function apiAuth(key: string) {
|
||||
return { type: "api" as const, key };
|
||||
}
|
||||
|
||||
test("options: managementReadToken is accepted and preserved", () => {
|
||||
const parsed = parseOmniRoutePluginOptions({ managementReadToken: MANAGEMENT_READ_TOKEN });
|
||||
assert.equal(parsed.managementReadToken, MANAGEMENT_READ_TOKEN);
|
||||
});
|
||||
|
||||
test("provider hook: management GET fetchers use managementReadToken while /v1 uses apiKey", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const enrichmentFetcher: OmniRouteEnrichmentFetcher = async (_baseURL, token) => {
|
||||
calls.push(["pricing", token]);
|
||||
return new Map();
|
||||
};
|
||||
const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (_baseURL, token) => {
|
||||
calls.push(["context", token]);
|
||||
return [];
|
||||
};
|
||||
const providersFetcher: OmniRouteProvidersFetcher = async (_baseURL, token) => {
|
||||
calls.push(["providers", token]);
|
||||
return [];
|
||||
};
|
||||
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{
|
||||
baseURL: BASE_URL,
|
||||
managementReadToken: MANAGEMENT_READ_TOKEN,
|
||||
features: { compressionMetadata: true, usableOnly: true },
|
||||
},
|
||||
{
|
||||
fetcher: async (_baseURL, token) => {
|
||||
calls.push(["models", token]);
|
||||
return RAW_MODELS;
|
||||
},
|
||||
combosFetcher: async (_baseURL, token) => {
|
||||
calls.push(["combos", token]);
|
||||
return [];
|
||||
},
|
||||
autoCombosFetcher: async (_baseURL, token) => {
|
||||
calls.push(["auto-combos", token]);
|
||||
return [];
|
||||
},
|
||||
enrichmentFetcher,
|
||||
compressionMetaFetcher,
|
||||
providersFetcher,
|
||||
}
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["models", API_KEY],
|
||||
["combos", MANAGEMENT_READ_TOKEN],
|
||||
["auto-combos", MANAGEMENT_READ_TOKEN],
|
||||
["pricing", MANAGEMENT_READ_TOKEN],
|
||||
["context", MANAGEMENT_READ_TOKEN],
|
||||
["providers", MANAGEMENT_READ_TOKEN],
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider hook: absent managementReadToken preserves apiKey fallback", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: BASE_URL, features: { enrichment: false, autoCombos: false } },
|
||||
{
|
||||
fetcher: async (_baseURL, token) => {
|
||||
calls.push(["models", token]);
|
||||
return RAW_MODELS;
|
||||
},
|
||||
combosFetcher: async (_baseURL, token) => {
|
||||
calls.push(["combos", token]);
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["models", API_KEY],
|
||||
["combos", API_KEY],
|
||||
]);
|
||||
});
|
||||
|
||||
test("config hook: managementReadToken stays out of provider inference and MCP config", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
baseURL: BASE_URL,
|
||||
managementReadToken: MANAGEMENT_READ_TOKEN,
|
||||
features: { enrichment: false, autoCombos: false, diskCache: false, mcpAutoEmit: true },
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"opencode-omniroute": { type: "api" as const, key: API_KEY },
|
||||
}),
|
||||
fetcher: async (_baseURL, token) => {
|
||||
calls.push(["models", token]);
|
||||
return RAW_MODELS;
|
||||
},
|
||||
combosFetcher: async (_baseURL, token) => {
|
||||
calls.push(["combos", token]);
|
||||
return [];
|
||||
},
|
||||
logger: { warn: () => {} },
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, any>; mcp?: Record<string, any> } = {};
|
||||
|
||||
await hook(input as never);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["models", API_KEY],
|
||||
["combos", MANAGEMENT_READ_TOKEN],
|
||||
]);
|
||||
assert.equal(input.provider?.["opencode-omniroute"]?.options?.apiKey, API_KEY);
|
||||
assert.equal(
|
||||
input.mcp?.["opencode-omniroute"]?.headers?.Authorization,
|
||||
`Bearer ${API_KEY}`,
|
||||
"mcpAutoEmit remains independent of managementReadToken"
|
||||
);
|
||||
});
|
||||
|
||||
test("auth fetch: only intended same-origin inference paths receive apiKey", async () => {
|
||||
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ input, init });
|
||||
return new Response("ok");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const hook = createOmniRouteAuthHook({
|
||||
baseURL: `${BASE_URL}/`,
|
||||
managementReadToken: MANAGEMENT_READ_TOKEN,
|
||||
});
|
||||
const loaded = await hook.loader!(async () => apiAuth(API_KEY) as never, {} as never);
|
||||
const interceptedFetch = (loaded as { fetch: typeof fetch }).fetch;
|
||||
|
||||
const streamingBody = '{"stream":true}';
|
||||
await interceptedFetch(`${BASE_URL}/chat/completions?trace=1`, {
|
||||
method: "POST",
|
||||
body: streamingBody,
|
||||
headers: { Accept: "text/event-stream" },
|
||||
});
|
||||
await interceptedFetch(`${BASE_URL}/models/?refresh=1`);
|
||||
await interceptedFetch("https://or.example.com/api/combos");
|
||||
await interceptedFetch("https://or.example.com/api/mcp/stream");
|
||||
await interceptedFetch("https://or.example.com/v1/embeddings");
|
||||
await interceptedFetch("https://third-party.example/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
const headers = calls.map(({ init }) => new Headers(init?.headers));
|
||||
assert.equal(headers[0]?.get("Authorization"), `Bearer ${API_KEY}`);
|
||||
assert.equal(headers[1]?.get("Authorization"), `Bearer ${API_KEY}`);
|
||||
for (const index of [2, 3, 4, 5]) {
|
||||
assert.equal(headers[index]?.get("Authorization"), null);
|
||||
}
|
||||
assert.equal(calls[0]?.input, `${BASE_URL}/chat/completions?trace=1`);
|
||||
assert.equal(calls[0]?.init?.body, streamingBody);
|
||||
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
|
||||
assert.equal(
|
||||
calls.some(({ init }) =>
|
||||
[...new Headers(init?.headers).values()].some((value) =>
|
||||
value.includes(MANAGEMENT_READ_TOKEN)
|
||||
)
|
||||
),
|
||||
false,
|
||||
"managementReadToken must never enter inference fetch headers"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("disk cache: snapshot written under management token A is rejected under token B", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-token-snapshot-"));
|
||||
const previousDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = tmp;
|
||||
|
||||
try {
|
||||
const commonDeps = {
|
||||
readAuthJson: async () => ({
|
||||
"opencode-omniroute": {
|
||||
type: "api" as const,
|
||||
key: API_KEY,
|
||||
baseURL: BASE_URL,
|
||||
},
|
||||
}),
|
||||
combosFetcher: async () => [],
|
||||
logger: { warn: () => {} },
|
||||
};
|
||||
const features = {
|
||||
enrichment: false,
|
||||
autoCombos: false,
|
||||
diskCache: true,
|
||||
} as const;
|
||||
|
||||
const tokenAHook = createOmniRouteConfigHook(
|
||||
{ managementReadToken: "token-A", features },
|
||||
{
|
||||
...commonDeps,
|
||||
fetcher: async () => RAW_MODELS,
|
||||
}
|
||||
);
|
||||
await tokenAHook({} as never);
|
||||
|
||||
const tokenBHook = createOmniRouteConfigHook(
|
||||
{ managementReadToken: "token-B", features },
|
||||
{
|
||||
...commonDeps,
|
||||
fetcher: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, { models: Record<string, unknown> }> } = {};
|
||||
await tokenBHook(input as never);
|
||||
|
||||
assert.deepEqual(
|
||||
input.provider?.["opencode-omniroute"]?.models,
|
||||
{},
|
||||
"catalog from token A must not hydrate after switching to token B"
|
||||
);
|
||||
} finally {
|
||||
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = previousDataDir;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
/**
|
||||
* #9473 — Model allowlist/blocklist for the opencode-plugin.
|
||||
*
|
||||
* Tests for the pure filter helpers (`compileModelListFilter`,
|
||||
* `passesModelAllowlist`, `passesComboAllowlist`) and the schema + hook-level
|
||||
* integration. The allowlist/blocklist composes with `usableOnly` (all filters
|
||||
* AND together), blocklist wins over allowlist (deny takes precedence), and
|
||||
* bare-suffix entries (e.g. "claude-opus-4-7") match any "{prefix}/claude-opus-4-7".
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
compileModelListFilter,
|
||||
passesModelAllowlist,
|
||||
passesComboAllowlist,
|
||||
parseOmniRoutePluginOptions,
|
||||
buildStaticProviderEntry,
|
||||
resolveOmniRoutePluginOptions,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// compileModelListFilter
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("compileModelListFilter: undefined list → undefined", () => {
|
||||
assert.equal(compileModelListFilter(undefined), undefined);
|
||||
});
|
||||
|
||||
test("compileModelListFilter: empty array → undefined", () => {
|
||||
assert.equal(compileModelListFilter([]), undefined);
|
||||
});
|
||||
|
||||
test("compileModelListFilter: raw IDs with slash → exact set populated", () => {
|
||||
const f = compileModelListFilter(["cc/claude-opus-4-7", "glm/gpt-5"]);
|
||||
assert.ok(f);
|
||||
assert.equal(f.exact.has("cc/claude-opus-4-7"), true);
|
||||
assert.equal(f.exact.has("glm/gpt-5"), true);
|
||||
assert.equal(f.suffixes.size, 0);
|
||||
});
|
||||
|
||||
test("compileModelListFilter: bare IDs (no slash) → suffixes set populated", () => {
|
||||
const f = compileModelListFilter(["claude-opus-4-7", "gpt-5"]);
|
||||
assert.ok(f);
|
||||
assert.equal(f.suffixes.has("claude-opus-4-7"), true);
|
||||
assert.equal(f.suffixes.has("gpt-5"), true);
|
||||
assert.equal(f.exact.size, 0);
|
||||
});
|
||||
|
||||
test("compileModelListFilter: mixed raw + bare → both sets populated", () => {
|
||||
const f = compileModelListFilter(["cc/claude-opus-4-7", "gpt-5"]);
|
||||
assert.ok(f);
|
||||
assert.equal(f.exact.has("cc/claude-opus-4-7"), true);
|
||||
assert.equal(f.suffixes.has("gpt-5"), true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// passesModelAllowlist
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("passesModelAllowlist: no visible, no hidden → keep (passthrough)", () => {
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: visible undefined, hidden undefined → keep", () => {
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: visible set, id matches exact → keep", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: visible set, id matches suffix → keep", () => {
|
||||
const vis = compileModelListFilter(["claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: visible set, id does NOT match → drop", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("glm/gpt-5", vis, undefined), false);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: visible set, bare suffix matches different prefix → keep", () => {
|
||||
const vis = compileModelListFilter(["claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("kr/claude-opus-4-7", vis, undefined), true);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: hidden set, id matches exact → drop", () => {
|
||||
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: hidden set, id matches suffix → drop", () => {
|
||||
const hid = compileModelListFilter(["claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: hidden set, id does NOT match → keep", () => {
|
||||
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("glm/gpt-5", undefined, hid), true);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: id in BOTH visible and hidden → DROP (deny wins)", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), false);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: visible allows, hidden blocks different id → keep the visible one", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
const hid = compileModelListFilter(["glm/gpt-5"]);
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), true);
|
||||
assert.equal(passesModelAllowlist("glm/gpt-5", vis, hid), false);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: bare-suffix hidden blocks exact match too", () => {
|
||||
const hid = compileModelListFilter(["claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
|
||||
assert.equal(passesModelAllowlist("kr/claude-opus-4-7", undefined, hid), false);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: no-slash id, visible set has bare match → keep", () => {
|
||||
const vis = compileModelListFilter(["claude-primary"]);
|
||||
assert.equal(passesModelAllowlist("claude-primary", vis, undefined), true);
|
||||
});
|
||||
|
||||
test("passesModelAllowlist: no-slash id, visible set has no match → drop", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
assert.equal(passesModelAllowlist("claude-primary", vis, undefined), false);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// passesComboAllowlist
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function combo(models: OmniRouteRawCombo["models"]): OmniRouteRawCombo {
|
||||
return { id: "c1", name: "Test Combo", models };
|
||||
}
|
||||
|
||||
test("passesComboAllowlist: visible undefined → keep", () => {
|
||||
const c = combo([{ kind: "model", model: "cc/claude-opus-4-7" }]);
|
||||
assert.equal(passesComboAllowlist(c, undefined), true);
|
||||
});
|
||||
|
||||
test("passesComboAllowlist: ≥1 member matches visible → keep", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
const c = combo([
|
||||
{ kind: "model", model: "dead/legacy" },
|
||||
{ kind: "model", model: "cc/claude-opus-4-7" },
|
||||
]);
|
||||
assert.equal(passesComboAllowlist(c, vis), true);
|
||||
});
|
||||
|
||||
test("passesComboAllowlist: zero members match visible → drop", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
const c = combo([
|
||||
{ kind: "model", model: "glm/gpt-5" },
|
||||
{ kind: "model", model: "kr/claude-opus-4-7" },
|
||||
]);
|
||||
assert.equal(passesComboAllowlist(c, vis), false);
|
||||
});
|
||||
|
||||
test("passesComboAllowlist: bare suffix matches any prefix → keep", () => {
|
||||
const vis = compileModelListFilter(["claude-opus-4-7"]);
|
||||
const c = combo([{ kind: "model", model: "kr/claude-opus-4-7" }]);
|
||||
assert.equal(passesComboAllowlist(c, vis), true);
|
||||
});
|
||||
|
||||
test("passesComboAllowlist: zero members → keep", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
assert.equal(passesComboAllowlist(combo([]), vis), true);
|
||||
assert.equal(passesComboAllowlist(combo(undefined), vis), true);
|
||||
});
|
||||
|
||||
test("passesComboAllowlist: only combo-ref steps → keep", () => {
|
||||
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
|
||||
const c = combo([{ kind: "combo-ref", comboName: "nested" }]);
|
||||
assert.equal(passesComboAllowlist(c, vis), true);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Schema — visibleModels / hiddenModels
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("parseOmniRoutePluginOptions: visibleModels string[] → preserved", () => {
|
||||
const r = parseOmniRoutePluginOptions({
|
||||
features: { visibleModels: ["cc/claude-opus-4-7", "gpt-5"] },
|
||||
});
|
||||
assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7", "gpt-5"]);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: hiddenModels string[] → preserved", () => {
|
||||
const r = parseOmniRoutePluginOptions({
|
||||
features: { hiddenModels: ["glm/gpt-5"] },
|
||||
});
|
||||
assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: both lists together → preserved", () => {
|
||||
const r = parseOmniRoutePluginOptions({
|
||||
features: {
|
||||
visibleModels: ["cc/claude-opus-4-7"],
|
||||
hiddenModels: ["glm/gpt-5"],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7"]);
|
||||
assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty string in visibleModels → rejects", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseOmniRoutePluginOptions({
|
||||
features: { visibleModels: [""] },
|
||||
}),
|
||||
/Invalid @omniroute\/opencode-plugin options/
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty string in hiddenModels → rejects", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseOmniRoutePluginOptions({
|
||||
features: { hiddenModels: [""] },
|
||||
}),
|
||||
/Invalid @omniroute\/opencode-plugin options/
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: unknown features key still rejects (strict invariant)", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseOmniRoutePluginOptions({
|
||||
features: { visibleModels: ["x"], unknownKey: true },
|
||||
}),
|
||||
/Invalid @omniroute\/opencode-plugin options/
|
||||
);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// buildStaticProviderEntry — allowlist/blocklist integration
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const FAKE_RAW_MODELS: OmniRouteRawModelEntry[] = [
|
||||
{ id: "cc/claude-opus-4-7", owned_by: "anthropic" },
|
||||
{ id: "glm/gpt-5", owned_by: "openai" },
|
||||
{ id: "kr/claude-opus-4-7", owned_by: "anthropic" },
|
||||
{ id: "claude-primary", owned_by: "combo" },
|
||||
];
|
||||
|
||||
test("buildStaticProviderEntry: no allowlist → all models emitted", () => {
|
||||
const opts = resolveOmniRoutePluginOptions({ features: {} });
|
||||
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
|
||||
const ids = Object.keys(entry.models);
|
||||
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
|
||||
assert.ok(ids.includes("glm/gpt-5"), "glm/gpt-5 should be present");
|
||||
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present");
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: visibleModels filters to only listed IDs", () => {
|
||||
const opts = resolveOmniRoutePluginOptions({
|
||||
features: { visibleModels: ["cc/claude-opus-4-7"] },
|
||||
});
|
||||
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
|
||||
const ids = Object.keys(entry.models);
|
||||
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
|
||||
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out");
|
||||
assert.equal(ids.includes("kr/claude-opus-4-7"), false, "kr/claude-opus-4-7 should be filtered out");
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: hiddenModels drops listed IDs", () => {
|
||||
const opts = resolveOmniRoutePluginOptions({
|
||||
features: { hiddenModels: ["glm/gpt-5"] },
|
||||
});
|
||||
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
|
||||
const ids = Object.keys(entry.models);
|
||||
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
|
||||
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be hidden");
|
||||
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present");
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: bare-suffix visibleModels matches any prefix", () => {
|
||||
const opts = resolveOmniRoutePluginOptions({
|
||||
features: { visibleModels: ["claude-opus-4-7"] },
|
||||
});
|
||||
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
|
||||
const ids = Object.keys(entry.models);
|
||||
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should match via suffix");
|
||||
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should match via suffix");
|
||||
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out");
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: id in both visible and hidden → hidden wins", () => {
|
||||
const opts = resolveOmniRoutePluginOptions({
|
||||
features: {
|
||||
visibleModels: ["cc/claude-opus-4-7"],
|
||||
hiddenModels: ["cc/claude-opus-4-7"],
|
||||
},
|
||||
});
|
||||
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
|
||||
const ids = Object.keys(entry.models);
|
||||
assert.equal(ids.includes("cc/claude-opus-4-7"), false, "deny takes precedence");
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: empty visibleModels → no filter (passthrough)", () => {
|
||||
const opts = resolveOmniRoutePluginOptions({
|
||||
features: { visibleModels: [] },
|
||||
});
|
||||
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
|
||||
const ids = Object.keys(entry.models);
|
||||
assert.ok(ids.includes("cc/claude-opus-4-7"), "empty visibleModels should not filter");
|
||||
assert.ok(ids.includes("glm/gpt-5"), "empty visibleModels should not filter");
|
||||
});
|
||||
@@ -38,8 +38,8 @@ test("multi-instance: two plugin invocations bind to their own providerId", asyn
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.equal(a.auth?.provider, "opencode-omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "opencode-omniroute-preprod");
|
||||
assert.equal(a.auth?.provider, "omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("multi-instance: hook objects + nested arrays are independent references", async () => {
|
||||
@@ -70,8 +70,8 @@ test("multi-instance: identical opts twice still yield independent objects", asy
|
||||
assert.notEqual(first.auth, second.auth);
|
||||
assert.notEqual(first.auth?.methods, second.auth?.methods);
|
||||
// Same provider id is fine — what matters is no shared mutable state.
|
||||
assert.equal(first.auth?.provider, "opencode-twin");
|
||||
assert.equal(second.auth?.provider, "opencode-twin");
|
||||
assert.equal(first.auth?.provider, "twin");
|
||||
assert.equal(second.auth?.provider, "twin");
|
||||
});
|
||||
|
||||
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
|
||||
@@ -132,5 +132,5 @@ test("multi-instance: invalid opts on one instance does not poison the other", a
|
||||
providerId: "recovered",
|
||||
baseURL: "https://ok.example/v1",
|
||||
});
|
||||
assert.equal(ok.auth?.provider, "opencode-recovered");
|
||||
assert.equal(ok.auth?.provider, "recovered");
|
||||
});
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Regression test for #6859.
|
||||
*
|
||||
* `resolveOmniRoutePluginOptions()` auto-prefixes `providerId` with
|
||||
* `"opencode-"` (commit 75b52e286) so OpenCode 1.17.8+'s native-adapter gate
|
||||
* accepts it as an OC-registered provider id. That prefixed value must stay
|
||||
* OC-internal (AuthHook.provider / provider registration keys) — it must
|
||||
* NEVER leak into the identifiers OmniRoute's own server parses to resolve
|
||||
* credentials (`mapRawModelToModelV2`'s `id`/`providerID`,
|
||||
* `mapComboToModelV2`'s `providerID`, and the dynamic-hook catalog keys).
|
||||
*
|
||||
* OmniRoute's server-side `parseModel()` (open-sse/services/model.ts) splits
|
||||
* a dispatched model string on `/` to recover the provider name and look up
|
||||
* credentials. If the plugin embeds the OC-gate-prefixed id in that string,
|
||||
* the server looks up credentials for a provider named "opencode-omniroute"
|
||||
* (which never exists in `src/shared/constants/providers.ts`) instead of
|
||||
* "omniroute" — producing the exact "No credentials for opencode-omniroute" /
|
||||
* "No active credentials for provider: opencode-omniroute" errors reported
|
||||
* in #6859.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildStaticProviderEntry,
|
||||
createOmniRouteProviderHook,
|
||||
mapRawModelToModelV2,
|
||||
resolveOmniRoutePluginOptions,
|
||||
type OmniRouteRawCombo,
|
||||
} from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Minimal stand-in for OmniRoute's own `parseModel()` (open-sse/services/
|
||||
* model.ts), which splits a dispatched `<providerID>/<modelID>` string on the
|
||||
* FIRST "/" to recover the provider name used for credential lookup. Kept
|
||||
* local (rather than cross-importing the real module) so this package's
|
||||
* self-contained test suite (`cd @omniroute/opencode-plugin && npm test`)
|
||||
* doesn't depend on the root repo's `@/*` path-alias resolution.
|
||||
*/
|
||||
function splitProviderFromDispatchedModel(modelStr: string): string {
|
||||
const idx = modelStr.indexOf("/");
|
||||
return idx === -1 ? modelStr : modelStr.slice(0, idx);
|
||||
}
|
||||
|
||||
const apiAuth = (key: string) => ({ type: "api" as const, key });
|
||||
|
||||
test("#6859: server-facing model id/providerID must resolve to the unprefixed provider name", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions();
|
||||
|
||||
// The OC-gate-compatible id stays prefixed — it is legitimate for
|
||||
// AuthHook.provider / provider registration.
|
||||
assert.equal(resolved.providerId, "opencode-omniroute");
|
||||
|
||||
// A second, unprefixed id must be exposed for anything that reaches
|
||||
// OmniRoute's own server (model id prefix, ModelV2.providerID, combo keys).
|
||||
assert.equal(
|
||||
resolved.omnirouteProviderId,
|
||||
"omniroute",
|
||||
"resolveOmniRoutePluginOptions() must expose an unprefixed omnirouteProviderId"
|
||||
);
|
||||
|
||||
// A bare raw /v1/models entry (no existing "/" in its id — the common
|
||||
// case for OmniRoute's catalog) mapped with the server-facing id.
|
||||
const model = mapRawModelToModelV2(
|
||||
{ id: "claude-opus-4-7" },
|
||||
{ providerId: resolved.omnirouteProviderId, baseURL: "http://localhost:20128" }
|
||||
);
|
||||
|
||||
assert.equal(model.providerID, "omniroute");
|
||||
assert.equal(model.id, "omniroute/claude-opus-4-7");
|
||||
|
||||
// OpenCode dispatches back to OmniRoute using `providerID/modelKey`
|
||||
// (matches the issue's own repro: `-m opencode-omniroute/oc/big-pickle`).
|
||||
const dispatchedModelString = `${model.providerID}/claude-opus-4-7`;
|
||||
const parsedProvider = splitProviderFromDispatchedModel(dispatchedModelString);
|
||||
|
||||
assert.equal(
|
||||
parsedProvider,
|
||||
"omniroute",
|
||||
`server-side provider split resolved '${parsedProvider}', expected 'omniroute' — ` +
|
||||
`credentials lookup would fail for an OC-gate-prefixed provider id`
|
||||
);
|
||||
});
|
||||
|
||||
test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID never carry the OC-gate prefix", async () => {
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
fetcher: async () => [{ id: "claude-opus-4-7" }],
|
||||
combosFetcher: async () => [],
|
||||
}
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-test") as never });
|
||||
const model = out["omniroute/claude-opus-4-7"];
|
||||
assert.ok(model, "catalog keyed under the unprefixed provider name");
|
||||
assert.equal(model.providerID, "omniroute");
|
||||
assert.ok(
|
||||
!model.providerID.startsWith("opencode-"),
|
||||
"the OC-gate prefix must never leak into ModelV2.providerID"
|
||||
);
|
||||
});
|
||||
|
||||
// #7976: buildStaticProviderEntry (the STATIC provider() config-hook path,
|
||||
// exercised when the plugin writes `opencode.json` up front rather than
|
||||
// registering the dynamic `provider.models()` hook) never received the
|
||||
// #6859 fix. OC dispatches a static-catalog `models` map key verbatim as
|
||||
// the `model` field of the outbound request — only the top-level
|
||||
// `provider["<id>"]` segment is stripped for routing — so a bare-slug combo
|
||||
// key built with the OC-gated `providerId` reaches OmniRoute's server
|
||||
// doubled and fails credential lookup for the nonexistent provider
|
||||
// `opencode-omniroute`. Confirmed against the issue's own curl repro
|
||||
// (`model: "opencode-omniroute/hermes-smart-stack"` → "No active
|
||||
// credentials for provider: opencode-omniroute").
|
||||
// #9175 tightened this further: OC's `getModel` looks models up by BARE id,
|
||||
// so combo dict keys now carry NO prefix at all (not even `omniroute/`).
|
||||
test("#7976/#9175: buildStaticProviderEntry keys combos by bare slug (no prefix at all — never the OC-gate providerId)", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
assert.equal(resolved.providerId, "opencode-omniroute");
|
||||
assert.equal(resolved.omnirouteProviderId, "omniroute");
|
||||
|
||||
const combo = {
|
||||
id: "combo-abc123",
|
||||
name: "Hermes Smart Stack",
|
||||
isHidden: false,
|
||||
models: [],
|
||||
} as unknown as OmniRouteRawCombo;
|
||||
|
||||
const block = buildStaticProviderEntry(
|
||||
[],
|
||||
[combo],
|
||||
resolved,
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
|
||||
assert.deepEqual(Object.keys(block.models), ["hermes-smart-stack"]);
|
||||
assert.equal(
|
||||
block.models["opencode-omniroute/hermes-smart-stack"],
|
||||
undefined,
|
||||
"combo key must not carry the OC-gate-prefixed providerId — it doubles up once " +
|
||||
"OC dispatches it verbatim as the `model` field"
|
||||
);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ const apiAuth = (key: string, baseURL?: string): unknown =>
|
||||
|
||||
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
||||
assert.equal(hook.id, "opencode-omniroute");
|
||||
assert.equal(hook.id, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
||||
@@ -87,8 +87,8 @@ test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-ins
|
||||
{ providerId: "omniroute-local" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(a.id, "opencode-omniroute-preprod");
|
||||
assert.equal(b.id, "opencode-omniroute-local");
|
||||
assert.equal(a.id, "omniroute-preprod");
|
||||
assert.equal(b.id, "omniroute-local");
|
||||
});
|
||||
|
||||
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
||||
@@ -101,9 +101,6 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
|
||||
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
|
||||
// that prefix must never leak into anything OmniRoute's server parses.
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
@@ -155,10 +152,6 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
// #6859: dynamic-hook catalog keys/ids/providerID use the unprefixed
|
||||
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
|
||||
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
|
||||
// anything OmniRoute's own server parses for credential lookup.
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
|
||||
@@ -26,7 +26,7 @@ test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin
|
||||
|
||||
test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
const r = resolveOmniRoutePluginOptions();
|
||||
assert.equal(r.providerId, "opencode-omniroute");
|
||||
assert.equal(r.providerId, "omniroute");
|
||||
assert.equal(r.displayName, "OmniRoute");
|
||||
assert.equal(r.modelCacheTtl, 300_000);
|
||||
assert.equal(r.baseURL, undefined);
|
||||
@@ -34,8 +34,8 @@ test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
|
||||
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "opencode-omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (opencode-omniroute-preprod)");
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (omniroute-preprod)");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
|
||||
|
||||
@@ -1,827 +0,0 @@
|
||||
/**
|
||||
* Warm-startup + parallel-refresh tests for the opencode-plugin config shim.
|
||||
*
|
||||
* Covers `createOmniRouteConfigHook(opts, deps)`:
|
||||
* - (a) Warm startup: cache miss + matching snapshot → provider block
|
||||
* populated from snapshot data (not live fetch data).
|
||||
* - (b) Fingerprint mismatch: reader returns undefined → no warm publish,
|
||||
* falls through to awaited fetch (cold-start behavior).
|
||||
* - (c) Successful parallel refresh: all fetchers resolve → cache updated,
|
||||
* disk snapshot written.
|
||||
* - (d) Failed refresh keeps the snapshot: warm-served + models fetcher
|
||||
* rejects → no disk overwrite, block stays at warm-snapshot shape.
|
||||
* - (e) Parallelism: all six fetchers start concurrently (not sequential).
|
||||
* - (f) Soft-fail parity under Promise.allSettled: per-endpoint
|
||||
* fallbacks + logger.warn breadcrumbs preserved.
|
||||
* - (g) No double-refresh: concurrent hook invocations on the same cacheKey
|
||||
* trigger only one refresh (in-flight guard).
|
||||
* - (h) features.diskCache: false disables the warm read entirely.
|
||||
*
|
||||
* Mocking strategy: every dependency is DI-injected at hook construction
|
||||
* (same pattern as config-shim.test.ts). No global monkey-patching.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Config } from "@opencode-ai/plugin";
|
||||
|
||||
import {
|
||||
createOmniRouteConfigHook,
|
||||
resolveOmniRoutePluginOptions,
|
||||
_resetInflightRefresh,
|
||||
type OmniRouteAutoCombosFetcher,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteCompressionMetaFetcher,
|
||||
type OmniRouteEnrichmentEntry,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
type OmniRouteEnrichmentMap,
|
||||
type OmniRouteFetchCache,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteProviderConnection,
|
||||
type OmniRouteProvidersFetcher,
|
||||
type OmniRouteRawAutoCombo,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
type OmniRouteReadAuthJson,
|
||||
type OmniRouteStaticProviderEntry,
|
||||
type OmniRouteDiskSnapshotReader,
|
||||
type OmniRouteDiskSnapshotWriter,
|
||||
type OmniRouteCompressionCombo,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test isolation: reset the module-level in-flight refresh guard between
|
||||
// tests so a detached refresh from a previous test doesn't leak into the
|
||||
// next one (same cacheKey, different cache instance).
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
_resetInflightRefresh();
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_CLAUDE: OmniRouteRawModelEntry = {
|
||||
id: "claude-sonnet-4-6",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
max_input_tokens: 180_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_GEMINI: OmniRouteRawModelEntry = {
|
||||
id: "gemini-3-flash",
|
||||
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
|
||||
context_length: 1_000_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
|
||||
id: "combo-claude-tier",
|
||||
name: "Claude Tier",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 },
|
||||
{ id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const AUTO_COMBO: OmniRouteRawAutoCombo = {
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
};
|
||||
|
||||
const COMPRESSION_COMBO: OmniRouteCompressionCombo = {
|
||||
id: "ctx-combo-1",
|
||||
name: "Context Combo",
|
||||
pipeline: "gzip",
|
||||
};
|
||||
|
||||
const CONNECTION_CLAUDE: OmniRouteProviderConnection = {
|
||||
id: "c1",
|
||||
provider: "claude",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// DI stub helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stubReadAuthJson(
|
||||
value: Record<string, unknown> | undefined | null
|
||||
): OmniRouteReadAuthJson {
|
||||
return async () => value as never;
|
||||
}
|
||||
|
||||
function immediateFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
payload: ReturnType<T> extends Promise<infer U> ? U : never
|
||||
): T & { callCount: () => number; startedAt: () => number | undefined } {
|
||||
let n = 0;
|
||||
let start: number | undefined;
|
||||
const f = async (..._args: unknown[]) => {
|
||||
start = Date.now();
|
||||
n++;
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f as T, { callCount: () => n, startedAt: () => start });
|
||||
}
|
||||
|
||||
function throwingFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
msg = "ECONNREFUSED"
|
||||
): T & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f = async (..._args: unknown[]) => {
|
||||
n++;
|
||||
throw new Error(msg);
|
||||
};
|
||||
return Object.assign(f as T, { callCount: () => n });
|
||||
}
|
||||
|
||||
interface WarnCapture {
|
||||
warn: (...args: unknown[]) => void;
|
||||
entries: unknown[][];
|
||||
}
|
||||
|
||||
function captureWarn(): WarnCapture {
|
||||
const entries: unknown[][] = [];
|
||||
return {
|
||||
warn: (...args: unknown[]) => {
|
||||
entries.push(args);
|
||||
},
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInput(initialProvider: Record<string, unknown> = {}): Config {
|
||||
return { provider: initialProvider } as unknown as Config;
|
||||
}
|
||||
|
||||
/** Build a valid auth.json stub for the default providerId. */
|
||||
function authStub() {
|
||||
return stubReadAuthJson({
|
||||
"opencode-omniroute": {
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (a) Warm startup: cache miss + matching snapshot → provider block populated
|
||||
// from snapshot data (not live fetch data)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: snapshot data used when snapshot is present", async () => {
|
||||
// Live fetch returns MODEL_CLAUDE, but snapshot has MODEL_GEMINI.
|
||||
// With warm startup, the block should contain the snapshot data.
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([]);
|
||||
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(new Map());
|
||||
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([]);
|
||||
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
autoCombosFetcher,
|
||||
enrichmentFetcher,
|
||||
compressionMetaFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "provider entry published");
|
||||
|
||||
// With warm startup, the block should contain the snapshot data (GEMINI),
|
||||
// not the live fetch data (CLAUDE). This is the key assertion: the warm
|
||||
// snapshot is served first, and the live refresh updates the cache in the
|
||||
// background. On the next hook invocation, the cache will have the fresh data.
|
||||
const hasGemini = entry.models["opencode-omniroute/gemini-3-flash"] !== undefined;
|
||||
const hasClaude = entry.models["opencode-omniroute/claude-sonnet-4-6"] !== undefined;
|
||||
assert.ok(
|
||||
hasGemini || hasClaude,
|
||||
"provider block has at least one model"
|
||||
);
|
||||
|
||||
// The warm-startup breadcrumb should be emitted.
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"warm-startup breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (b) Fingerprint mismatch: reader returns undefined → no warm publish,
|
||||
// falls through to awaited fetch
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: fingerprint mismatch → no warm publish, awaited fetch", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
// Reader returns undefined → fingerprint mismatch or missing snapshot.
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published from live fetch");
|
||||
// Live fetch data, not snapshot data.
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"live fetch model present"
|
||||
);
|
||||
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
|
||||
// No warm-startup breadcrumb when no snapshot.
|
||||
assert.ok(
|
||||
!logger.entries.some((e) =>
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"no warm-startup breadcrumb when no snapshot"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (c) Successful parallel refresh: all fetchers resolve → cache updated,
|
||||
// disk snapshot written, block re-published with fresh data
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: parallel refresh updates cache + writes snapshot", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([COMBO_CLAUDE_TIER]);
|
||||
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([AUTO_COMBO]);
|
||||
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(
|
||||
new Map<string, OmniRouteEnrichmentEntry>([
|
||||
["claude-sonnet-4-6", { name: "Claude Sonnet 4.6" }],
|
||||
])
|
||||
);
|
||||
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([
|
||||
COMPRESSION_COMBO,
|
||||
]);
|
||||
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([CONNECTION_CLAUDE]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
let snapshotWrites = 0;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
|
||||
snapshotWrites++;
|
||||
};
|
||||
|
||||
const sharedCache: OmniRouteFetchCache = new Map();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", modelCacheTtl: 60_000 },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
autoCombosFetcher,
|
||||
enrichmentFetcher,
|
||||
compressionMetaFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
cache: sharedCache,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
// Warm block should have been published.
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "warm provider entry published");
|
||||
|
||||
// Give detached refresh time to complete.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// After parallel refresh, the cache should have the fresh data.
|
||||
const cacheKey = Array.from(sharedCache.keys())[0];
|
||||
assert.ok(cacheKey, "cache entry created");
|
||||
const cached = sharedCache.get(cacheKey)!;
|
||||
assert.ok(cached.expiresAt > 0, "cache entry has expiresAt");
|
||||
// Fresh data from the live fetchers (not the stale snapshot).
|
||||
assert.equal(cached.rawModels.length, 1, "cache has fresh models");
|
||||
assert.equal(cached.rawModels[0].id, "claude-sonnet-4-6", "cache has correct model");
|
||||
|
||||
// Disk snapshot should have been written.
|
||||
assert.equal(snapshotWrites, 1, "disk snapshot written after successful refresh");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (d) Failed refresh keeps the snapshot: warm-served + models fetcher
|
||||
// rejects → no disk overwrite, block stays at warm-snapshot shape
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: failed refresh keeps the snapshot, no disk overwrite", async () => {
|
||||
const fetcher = throwingFetcher<OmniRouteModelsFetcher>();
|
||||
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>();
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [COMBO_CLAUDE_TIER],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
let snapshotWrites = 0;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
|
||||
snapshotWrites++;
|
||||
};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "warm provider entry published");
|
||||
|
||||
// The block should contain the warm snapshot data (gemini), not be
|
||||
// downgraded to a stub.
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/gemini-3-flash"],
|
||||
"warm snapshot model preserved (not downgraded to stub)"
|
||||
);
|
||||
|
||||
// Give detached refresh time to complete.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// No disk write on failed refresh.
|
||||
assert.equal(snapshotWrites, 0, "no disk snapshot written when models fetch failed");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (e) Parallelism: all six fetchers start concurrently (not sequential)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: all fetchers start concurrently (parallel fan-out)", async () => {
|
||||
const startTimes: number[] = [];
|
||||
const barrier = new Promise<void>((r) => {
|
||||
setTimeout(r, 30);
|
||||
});
|
||||
|
||||
function instrumentedFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
payload: ReturnType<T> extends Promise<infer U> ? U : never
|
||||
): T & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f = async (..._args: unknown[]) => {
|
||||
startTimes.push(Date.now());
|
||||
n++;
|
||||
await barrier;
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f as T, { callCount: () => n });
|
||||
}
|
||||
|
||||
const fetcher = instrumentedFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = instrumentedFetcher<OmniRouteCombosFetcher>([]);
|
||||
const autoCombosFetcher = instrumentedFetcher<OmniRouteAutoCombosFetcher>([]);
|
||||
const enrichmentFetcher = instrumentedFetcher<OmniRouteEnrichmentFetcher>(new Map());
|
||||
const compressionMetaFetcher = instrumentedFetcher<OmniRouteCompressionMetaFetcher>([]);
|
||||
const providersFetcher = instrumentedFetcher<OmniRouteProvidersFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
// No snapshot → cold path (awaited). All fetchers must still start
|
||||
// concurrently.
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { enrichment: true, compressionMetadata: true, usableOnly: true } },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
autoCombosFetcher,
|
||||
enrichmentFetcher,
|
||||
compressionMetaFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
// All fetchers should have been called.
|
||||
assert.equal(fetcher.callCount(), 1, "models fetcher called");
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetcher called");
|
||||
assert.equal(autoCombosFetcher.callCount(), 1, "autoCombos fetcher called");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1, "enrichment fetcher called");
|
||||
assert.equal(compressionMetaFetcher.callCount(), 1, "compressionMeta fetcher called");
|
||||
assert.equal(providersFetcher.callCount(), 1, "providers fetcher called");
|
||||
|
||||
// All start times should be within 20ms of each other (parallel fan-out),
|
||||
// NOT sequential (which would show ~30ms gaps between each).
|
||||
assert.ok(startTimes.length >= 6, "all 6 fetchers started");
|
||||
const minStart = Math.min(...startTimes);
|
||||
const maxStart = Math.max(...startTimes);
|
||||
assert.ok(
|
||||
maxStart - minStart < 20,
|
||||
`all fetchers started within 20ms (spread: ${maxStart - minStart}ms) — parallel fan-out confirmed`
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (f) Soft-fail parity under Promise.allSettled: per-endpoint fallbacks +
|
||||
// logger.warn breadcrumbs preserved
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: combos reject → models-only catalog with warn", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>("403 Forbidden");
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published");
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"models-only catalog (no combos)"
|
||||
);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("warm-startup: enrichment rejects → raw-id catalog with warn", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const enrichmentFetcher = throwingFetcher<OmniRouteEnrichmentFetcher>("ETIMEDOUT");
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
enrichmentFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained (no enrichment)"
|
||||
);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
|
||||
"enrichment-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("warm-startup: providers reject → usableOnly filter disabled with warn", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const providersFetcher = throwingFetcher<OmniRouteProvidersFetcher>("ETIMEDOUT");
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { usableOnly: true } },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published");
|
||||
// Soft-fail: model kept (filter disabled).
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"model kept (usableOnly filter disabled)"
|
||||
);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/providers fetch failed")),
|
||||
"providers-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (g) No double-refresh: concurrent hook invocations on the same cacheKey
|
||||
// trigger only one refresh (in-flight guard)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: concurrent hook invocations dedupe refresh", async () => {
|
||||
let fetchCount = 0;
|
||||
const slowResolve = new Promise<void>((r) => {
|
||||
setTimeout(r, 100);
|
||||
});
|
||||
|
||||
const fetcher: OmniRouteModelsFetcher = async () => {
|
||||
fetchCount++;
|
||||
await slowResolve;
|
||||
return [MODEL_CLAUDE];
|
||||
};
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const sharedCache: OmniRouteFetchCache = new Map();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", modelCacheTtl: 60_000 },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
cache: sharedCache,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
// Fire two concurrent hook invocations on the same cache.
|
||||
const inputA = makeInput();
|
||||
const inputB = makeInput();
|
||||
await Promise.all([hook(inputA), hook(inputB)]);
|
||||
|
||||
// Both should have published, but the refresh should only run once.
|
||||
assert.equal(
|
||||
fetchCount,
|
||||
1,
|
||||
"models fetcher called only once across concurrent invocations (in-flight guard)"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (h) features.diskCache: false disables the warm read entirely
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: diskCache=false disables warm read, falls through to awaited fetch", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
let readerCalled = false;
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => {
|
||||
readerCalled = true;
|
||||
return {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
};
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { diskCache: false } },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.equal(readerCalled, false, "disk snapshot reader NOT called when diskCache=false");
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published from live fetch");
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"live fetch model present (not snapshot)"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Warm startup: snapshot age logged
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: snapshot age is logged when warm-starting from disk", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> & {
|
||||
writtenAt?: number;
|
||||
} = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
writtenAt: Date.now() - 3_600_000, // 1 hour ago
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
// The log should mention "warm startup from disk snapshot".
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"warm-startup breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Warm startup: empty snapshot (rawModels.length === 0) is skipped
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: empty snapshot (rawModels.length=0) is skipped, falls through to fetch", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => ({
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
});
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published from live fetch");
|
||||
// Live data, not empty snapshot.
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"live fetch model present (empty snapshot skipped)"
|
||||
);
|
||||
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
|
||||
});
|
||||
4655
CHANGELOG.md
4655
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
548
CLAUDE.md
548
CLAUDE.md
@@ -1,55 +1,523 @@
|
||||
# CLAUDE.md
|
||||
|
||||
@AGENTS.md
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**All project rules live in [`AGENTS.md`](AGENTS.md)** — the single source of truth for every AI
|
||||
assistant (architecture, conventions, testing, quality gates, git workflow, the 23 Hard Rules,
|
||||
PII learnings). Read it in full; do not re-add project rules here. Everything below applies ONLY
|
||||
to Claude Code — operational refinements of rules already defined in `AGENTS.md`.
|
||||
## Quick Start
|
||||
|
||||
## Worktree isolation — Claude Code specifics
|
||||
```bash
|
||||
npm install # Install deps (auto-generates .env from .env.example)
|
||||
npm run dev # Dev server at http://localhost:20128
|
||||
npm run build # Production build (Next.js 16 standalone)
|
||||
npm run lint # ESLint (0 errors expected; warnings are pre-existing)
|
||||
npm run typecheck:core # TypeScript check (should be clean)
|
||||
npm run typecheck:noimplicit:core # Strict check (no implicit any)
|
||||
npm run test:coverage # Unit tests + coverage gate (60/60/60/60 — statements/lines/functions/branches)
|
||||
npm run check # lint + test combined
|
||||
npm run check:cycles # Detect circular dependencies
|
||||
```
|
||||
|
||||
The full mandatory worktree protocol (base-branch confirmation, `.claude/worktrees/` canonical
|
||||
path, `cp -al` node_modules, teardown rules) is in `AGENTS.md` → Git Workflow → "Worktree
|
||||
isolation". Claude-Code-specific points:
|
||||
### Running Tests
|
||||
|
||||
- Confirm the base branch with the operator via `AskUserQuestion` (Hard Rule #19) unless they
|
||||
already told you.
|
||||
- Prefer the native `EnterWorktree` tool — it already creates worktrees under
|
||||
`.claude/worktrees/` (the canonical path). Create the worktree with the documented `git
|
||||
worktree add` command, then call `EnterWorktree` with its `path`.
|
||||
```bash
|
||||
# Single test file (Node.js native test runner — most tests)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
|
||||
## Cross-session safety — Claude Code specifics
|
||||
# Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
Hard Rules #19/#21/#22 (in `AGENTS.md`) govern parallel sessions. Operational reminders for this
|
||||
harness:
|
||||
# All suites
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
- **Replicate the `git stash` ban verbatim in the prompt of every subagent that touches git**
|
||||
(Agent tool / Workflow scripts) — subagents do not inherit this file, and the recorded
|
||||
recurrence of the stash incident came through a subagent.
|
||||
- Before merging or pushing to any PR you did not create _this session_, run `git worktree list`
|
||||
and re-check `gh pr view <N> --json state,headRefOid` (Hard Rule #22b).
|
||||
- End every session with the main checkout on the branch it started on.
|
||||
For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep architecture, see `AGENTS.md`.
|
||||
|
||||
## Superpowers / planning artifacts — path overrides
|
||||
---
|
||||
|
||||
The `_tasks/` convention is defined in `AGENTS.md` → "Planning & Research Artifacts". The
|
||||
superpowers skills ship with defaults that point at `docs/…` — those defaults are **overridden
|
||||
here**. When a superpowers skill announces a path like "saved to `docs/superpowers/plans/…`",
|
||||
rewrite it to the `_tasks/…` equivalent before writing:
|
||||
## Project at a Glance
|
||||
|
||||
| Artifact (skill) | Default (do NOT use) | Save here instead |
|
||||
| ---------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| Plans (`writing-plans`) | `docs/superpowers/plans/` | `_tasks/superpowers/plans/YYYY-MM-DD-<feature>.md` |
|
||||
| Specs / design (`brainstorming`) | `docs/superpowers/specs/` | `_tasks/superpowers/specs/YYYY-MM-DD-<topic>-design.md` |
|
||||
| Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` |
|
||||
| Hand-offs (`/handoff`) | — | `_tasks/hands-off/<YYYY-MM-DD>_<branch>_v<versão>_sess-<id>/` |
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 231 LLM providers, auto-fallback.
|
||||
|
||||
Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
|
||||
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
|
||||
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (83 files, 97 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 87 tools (33 base + memory/skill/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||
|
||||
## Base-green before opening PRs
|
||||
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
|
||||
|
||||
Before cutting a branch or opening a PR, run the base-green check (`AGENTS.md` → Git Workflow →
|
||||
"Base-green check"; project skills reference it as `.agents/skills/_shared/base-green.md`). A PR
|
||||
opened while the base tip is red must carry `⚠️ base-red inherited: #<issue>` in its body. To
|
||||
drain an accumulated red state (base tip + red PRs), use the `/sweep-reds` skill.
|
||||
---
|
||||
|
||||
## Request Pipeline
|
||||
|
||||
```
|
||||
Client → /v1/chat/completions (Next.js route)
|
||||
→ CORS → Zod validation → auth? → policy check → prompt injection guard
|
||||
→ handleChatCore() [open-sse/handlers/chatCore.ts]
|
||||
→ cache check → rate limit → combo routing?
|
||||
→ resolveComboTargets() → handleSingleModel() per target
|
||||
→ translateRequest() → getExecutor() → executor.execute()
|
||||
→ fetch() upstream → retry w/ backoff
|
||||
→ response translation → SSE stream or JSON
|
||||
→ If Responses API: responsesTransformer.ts TransformStream
|
||||
```
|
||||
|
||||
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
|
||||
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 15 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, reset-window, strict-random, auto, lkgp, context-optimized, context-relay). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. See `docs/routing/AUTO-COMBO.md` for the 9-factor Auto-Combo scoring and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
|
||||
---
|
||||
|
||||
## Resilience Runtime State
|
||||
|
||||
OmniRoute has three related but distinct temporary-failure mechanisms. Keep their
|
||||
scope separate when debugging routing behavior. See the
|
||||
[3-layer resilience diagram](./docs/diagrams/exported/resilience-3layers.svg)
|
||||
(source: [docs/diagrams/resilience-3layers.mmd](./docs/diagrams/resilience-3layers.mmd))
|
||||
for an at-a-glance map.
|
||||
|
||||
### Provider Circuit Breaker
|
||||
|
||||
**Scope**: whole provider, e.g. `glm`, `openai`, `anthropic`.
|
||||
|
||||
**Purpose**: stop sending traffic to a provider that is repeatedly failing at the
|
||||
upstream/service level, so one unhealthy provider does not slow down every request.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Core class: `src/shared/utils/circuitBreaker.ts`
|
||||
- Chat gate/execution wiring: `src/sse/handlers/chatHelpers.ts`, `src/sse/handlers/chat.ts`
|
||||
- Runtime status API: `src/app/api/monitoring/health/route.ts`
|
||||
- Shared wrappers: `open-sse/services/accountFallback.ts`
|
||||
- Persisted state table: `domain_circuit_breakers`
|
||||
|
||||
**States**:
|
||||
|
||||
- `CLOSED`: normal traffic is allowed.
|
||||
- `OPEN`: provider is temporarily blocked; callers get a provider-circuit-open response
|
||||
or combo routing skips to another target.
|
||||
- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the
|
||||
breaker, failure opens it again.
|
||||
|
||||
**Defaults** (`open-sse/config/constants.ts`):
|
||||
|
||||
- OAuth providers: threshold `3`, reset timeout `60s`.
|
||||
- API-key providers: threshold `5`, reset timeout `30s`.
|
||||
- Local providers: threshold `2`, reset timeout `15s`.
|
||||
|
||||
Only provider-level failure statuses should trip the provider breaker:
|
||||
|
||||
```ts
|
||||
(408, 500, 502, 503, 504);
|
||||
```
|
||||
|
||||
Do not trip the whole-provider breaker for normal account/key/model errors like most
|
||||
`401`, `403`, or `429` cases. Those usually belong to connection cooldown or model
|
||||
lockout. A generic API-key provider `403` should be recoverable unless it is classified
|
||||
as a terminal provider/account error.
|
||||
|
||||
The breaker uses lazy recovery, not a background timer. When `OPEN` expires, reads such
|
||||
as `getStatus()`, `canExecute()`, and `getRetryAfterMs()` refresh the state to
|
||||
`HALF_OPEN`, so dashboards and combo candidate builders do not keep excluding an
|
||||
expired provider forever.
|
||||
|
||||
### Connection Cooldown
|
||||
|
||||
**Scope**: one provider connection/account/key.
|
||||
|
||||
**Purpose**: temporarily skip one bad key/account while allowing other connections for
|
||||
the same provider to continue serving requests.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Write/update path: `src/sse/services/auth.ts::markAccountUnavailable()`
|
||||
- Account selection/filtering: `src/sse/services/auth.ts::getProviderCredentials...`
|
||||
- Cooldown calculation: `open-sse/services/accountFallback.ts::checkFallbackError()`
|
||||
- Settings: `src/lib/resilience/settings.ts`
|
||||
|
||||
Important fields on provider connections:
|
||||
|
||||
```ts
|
||||
rateLimitedUntil;
|
||||
testStatus: "unavailable";
|
||||
lastError;
|
||||
lastErrorType;
|
||||
errorCode;
|
||||
backoffLevel;
|
||||
```
|
||||
|
||||
During account selection, a connection is skipped while:
|
||||
|
||||
```ts
|
||||
new Date(rateLimitedUntil).getTime() > Date.now();
|
||||
```
|
||||
|
||||
Cooldowns are also lazy: when `rateLimitedUntil` is in the past, the connection becomes
|
||||
eligible again. On successful use, `clearAccountError()` clears `testStatus`,
|
||||
`rateLimitedUntil`, error fields, and `backoffLevel`.
|
||||
|
||||
Default connection cooldown behavior:
|
||||
|
||||
- OAuth base cooldown: `5s`.
|
||||
- API-key base cooldown: `3s`.
|
||||
- API-key `429` should prefer upstream retry hints (`Retry-After`, reset headers, or
|
||||
parseable reset text) when available.
|
||||
- Repeated recoverable failures use exponential backoff:
|
||||
|
||||
```ts
|
||||
baseCooldownMs * 2 ** failureIndex;
|
||||
```
|
||||
|
||||
The anti-thundering-herd guard prevents concurrent failures on the same connection from
|
||||
repeatedly extending the cooldown or double-incrementing `backoffLevel`.
|
||||
|
||||
Terminal states are not cooldowns. `banned`, `expired`, and `credits_exhausted` are
|
||||
intended to stay unavailable until credentials/settings change or an operator resets
|
||||
them. Do not overwrite terminal states with transient cooldown state.
|
||||
|
||||
### Model Lockout
|
||||
|
||||
**Scope**: provider + connection + model.
|
||||
|
||||
**Purpose**: avoid disabling a whole connection when only one model is unavailable or
|
||||
quota-limited for that connection.
|
||||
|
||||
Examples:
|
||||
|
||||
- Per-model quota providers returning `429`.
|
||||
- Local providers returning `404` for one missing model.
|
||||
- Provider-specific mode/model permission failures such as selected Grok modes.
|
||||
|
||||
Model lockout lives in `open-sse/services/accountFallback.ts` and lets the same
|
||||
connection continue serving other models.
|
||||
|
||||
### Debugging Guidance
|
||||
|
||||
- If all keys for a provider are skipped, inspect both provider breaker state and each
|
||||
connection's `rateLimitedUntil`/`testStatus`.
|
||||
- If a provider appears permanently excluded after the reset window, check whether code
|
||||
is reading raw `state` instead of using `getStatus()`/`canExecute()`.
|
||||
- If one provider key fails but others should work, prefer connection cooldown over
|
||||
provider breaker.
|
||||
- If only one model fails, prefer model lockout over connection cooldown.
|
||||
- If a state should self-recover, it should have a future timestamp/reset timeout and a
|
||||
read path that refreshes expired state. Permanent statuses require manual credential
|
||||
or config changes.
|
||||
|
||||
---
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas (enforced by lint-staged via Prettier)
|
||||
- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative
|
||||
- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE
|
||||
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = warn in `open-sse/` and `tests/`
|
||||
- **TypeScript**: `strict: false`, target ES2022, module esnext, resolution bundler. Prefer explicit types.
|
||||
|
||||
### Database
|
||||
|
||||
- **Always** go through `src/lib/db/` domain modules — **never** write raw SQL in routes or handlers
|
||||
- **Never** add logic to `src/lib/localDb.ts` (re-export layer only)
|
||||
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules instead
|
||||
- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling)
|
||||
- Migrations: `src/lib/db/migrations/` — versioned SQL files, idempotent, run in transactions
|
||||
|
||||
### Error Handling
|
||||
|
||||
- try/catch with specific error types, log with pino context
|
||||
- Never swallow errors in SSE streams — use abort signals for cleanup
|
||||
- Return proper HTTP status codes (4xx/5xx)
|
||||
|
||||
### Security
|
||||
|
||||
- **Never** use `eval()`, `new Function()`, or implied eval
|
||||
- Validate all inputs with Zod schemas
|
||||
- Encrypt credentials at rest (AES-256-GCM)
|
||||
- Upstream header denylist: `src/shared/constants/upstreamHeaders.ts` — keep sanitize, Zod schemas, and unit tests aligned when editing
|
||||
- **Public upstream credentials** (Gemini/Antigravity/Windsurf-style OAuth client_id/secret + Firebase Web keys extracted from public CLIs): **MUST** be embedded via `resolvePublicCred()` from `open-sse/utils/publicCreds.ts` — **never** as string literals. See `docs/security/PUBLIC_CREDS.md` for the mandatory pattern.
|
||||
- **Error responses** (HTTP / SSE / executor / MCP handler): **MUST** route through `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts` — **never** put raw `err.stack` or `err.message` in a response body. See `docs/security/ERROR_SANITIZATION.md`.
|
||||
- **Shell commands built from variables**: when calling `exec()`/`spawn()` with a script that needs runtime values, pass them via the `env` option (shell-escaped automatically) — **never** string-interpolate untrusted/external paths into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
- **Secure-by-default libraries** ([tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)): prefer Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink over custom implementations whenever adding new security-sensitive surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Common Modification Scenarios
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
1. Register in `src/shared/constants/providers.ts` (Zod-validated at load)
|
||||
2. Add executor in `open-sse/executors/` if custom logic needed (extend `BaseExecutor`)
|
||||
3. Add translator in `open-sse/translator/` if non-OpenAI format
|
||||
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` if OAuth-based — if the upstream CLI ships a public client_id/secret, embed via `resolvePublicCred()` (see `docs/security/PUBLIC_CREDS.md`), **never** as a literal
|
||||
5. Register models in `open-sse/config/providerRegistry.ts`
|
||||
6. Write tests in `tests/unit/` (include the publicCreds shape assertion if you added a new embedded default)
|
||||
|
||||
### Adding a New API Route
|
||||
|
||||
1. Create directory under `src/app/api/v1/your-route/`
|
||||
2. Create `route.ts` with `GET`/`POST` handlers
|
||||
3. Follow pattern: CORS → Zod body validation → optional auth → handler delegation
|
||||
4. Handler goes in `open-sse/handlers/` (import from there, not inline)
|
||||
5. Error responses use `buildErrorBody()` / `errorResponse()` from `open-sse/utils/error.ts` (auto-sanitized — never put `err.stack` or `err.message` raw in the body). See `docs/security/ERROR_SANITIZATION.md`.
|
||||
6. Add tests — including at least one assertion that error responses do not leak stack traces (`!body.error.message.includes("at /")`)
|
||||
|
||||
### Adding a New DB Module
|
||||
|
||||
1. Create `src/lib/db/yourModule.ts` — import `getDbInstance` from `./core.ts`
|
||||
2. Export CRUD functions for your domain table(s)
|
||||
3. Add migration in `src/lib/db/migrations/` if new tables needed
|
||||
4. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
|
||||
5. Write tests
|
||||
|
||||
### Adding a New MCP Tool
|
||||
|
||||
1. Add tool definition in `open-sse/mcp-server/tools/` with Zod input schema + async handler
|
||||
2. Register in tool set (wired by `createMcpServer()`)
|
||||
3. Assign to appropriate scope(s)
|
||||
4. Write tests (tool invocation logged to `mcp_audit` table)
|
||||
|
||||
### Adding a New A2A Skill
|
||||
|
||||
1. Create skill in `src/lib/a2a/skills/` (5 already exist: smart-routing, quota-management, provider-discovery, cost-analysis, health-report)
|
||||
2. Skill receives task context (messages, metadata) → returns structured result
|
||||
3. Register in `A2A_SKILL_HANDLERS` in `src/lib/a2a/taskExecution.ts`
|
||||
4. Expose in `src/app/.well-known/agent.json/route.ts` (Agent Card)
|
||||
5. Write tests in `tests/unit/`
|
||||
6. Document in `docs/frameworks/A2A-SERVER.md` skill table
|
||||
|
||||
### Adding a New Cloud Agent
|
||||
|
||||
1. Create agent class in `src/lib/cloudAgent/agents/` extending `CloudAgentBase` (3 already exist: codex-cloud, devin, jules)
|
||||
2. Implement `createTask`, `getStatus`, `approvePlan`, `sendMessage`, `listSources`
|
||||
3. Register in `src/lib/cloudAgent/registry.ts`
|
||||
4. Add OAuth/credentials handling if needed (`src/lib/oauth/providers/`)
|
||||
5. Tests + document in `docs/frameworks/CLOUD_AGENT.md`
|
||||
|
||||
### Adding a New Embedded Service
|
||||
|
||||
1. Create installer in `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts` (use `runNpm` from `installers/utils.ts` — no shell interpolation, hard rule #13).
|
||||
2. Register the service in `src/lib/services/bootstrap.ts` (add to `SERVICES[]` array and extend `buildSpawnArgsFactory()`).
|
||||
3. Add a DB seed row for the new service in `src/lib/db/migrations/` (`version_manager` table, `status='not_installed'`, `auto_start=0`).
|
||||
4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
|
||||
5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17).
|
||||
6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`.
|
||||
8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section.
|
||||
|
||||
### Adding a New Guardrail / Eval / Skill / Webhook event
|
||||
|
||||
- Guardrail: `src/lib/guardrails/` → docs: `docs/security/GUARDRAILS.md`
|
||||
- Eval suite: `src/lib/evals/` → docs: `docs/frameworks/EVALS.md`
|
||||
- Skill (sandbox): `src/lib/skills/` → docs: `docs/frameworks/SKILLS.md`
|
||||
- Webhook event: `src/lib/webhookDispatcher.ts` → docs: `docs/frameworks/WEBHOOKS.md`
|
||||
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (9-factor scoring, 15 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
|
||||
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
|
||||
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
|
||||
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
|
||||
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
|
||||
| Evals | `docs/frameworks/EVALS.md` |
|
||||
| Compliance / audit | `docs/security/COMPLIANCE.md` |
|
||||
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
|
||||
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
|
||||
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
|
||||
| MCP server | `docs/frameworks/MCP-SERVER.md` |
|
||||
| A2A server | `docs/frameworks/A2A-SERVER.md` |
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` |
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
| What | Command |
|
||||
| ----------------------- | --------------------------------------------------------------------------- |
|
||||
| Unit tests | `npm run test:unit` |
|
||||
| Single file | `node --import tsx/esm --test tests/unit/file.test.ts` |
|
||||
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
|
||||
| E2E (Playwright) | `npm run test:e2e` |
|
||||
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
|
||||
| Ecosystem | `npm run test:ecosystem` |
|
||||
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
|
||||
| Coverage report | `npm run coverage:report` |
|
||||
|
||||
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.
|
||||
|
||||
**Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix.
|
||||
|
||||
**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both are wired in CI (jobs `test-unit` and `test-vitest`) and must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions.
|
||||
|
||||
**Bug fix / issue triage protocol (Hard Rule #18)**: Every fix for a reported issue must be validated by one of the following — no exceptions:
|
||||
|
||||
1. **TDD (preferred)** — write a failing test reproducing the bug → fix it → confirm the test passes. The test becomes the permanent regression guard. Touch only the files the test proves need changing; nothing more.
|
||||
2. **Real-environment test (when TDD is not possible)** — deploy to the production VPS (`root@192.168.0.15`) and run a documented live test. Record the exact command + result in the PR description. Applies to: OAuth upstream flows, Cloudflare/WS upstream behavior, UI-only regressions, hardware-dependent behavior.
|
||||
3. "It worked locally without a test" does not count. A fix without a test or a VPS validation record is not a fix — it is a guess.
|
||||
|
||||
Why this matters: fixing bug A while opening bug B is worse than not fixing at all. The TDD/VPS gate enforces surgical scope — you touch only what the failing test proves is broken. Examples where this paid off: #3090 (claude-web 403), #3113 (WS HTTP fallback), #3052 (heap-guard auto-calibration).
|
||||
|
||||
**Copilot coverage policy**: When a PR changes production code and coverage is below 60% (statements/lines/functions/branches), do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report.
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
|
||||
```bash
|
||||
# Never commit directly to main
|
||||
git checkout -b feat/your-feature
|
||||
git commit -m "feat: describe your change"
|
||||
git push -u origin feat/your-feature
|
||||
```
|
||||
|
||||
**Branch prefixes**: `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, `chore/`
|
||||
|
||||
**Commit format** (Conventional Commits): `feat(db): add circuit breaker` — scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`, `memory`, `skills`
|
||||
|
||||
**Husky hooks**:
|
||||
|
||||
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11`
|
||||
- **pre-push**: fast deterministic gates (`check:any-budget:t11` + `check:tracked-artifacts`); intentionally excludes `test:unit` (slow — covered by the CI `test-unit` job). Activated 2026-06-13 (Quality Gates Fase 6A.12).
|
||||
|
||||
### Worktree isolation (MANDATORY for every development task)
|
||||
|
||||
Multiple sessions/agents work this repo in parallel. The main checkout is **shared**, so a
|
||||
`git checkout`/branch switch in it silently discards another session's uncommitted work and
|
||||
yanks the branch out from under whatever else is running (incidents: 2026-06-05, 2026-06-13).
|
||||
|
||||
**Rule: never develop on the shared main checkout. Every task gets its own git worktree on its
|
||||
own dedicated branch, and you MUST confirm the base branch with the operator before creating it.**
|
||||
|
||||
1. **Ask first — which base branch?** Before creating anything, ask the operator (via
|
||||
`AskUserQuestion`, unless they already told you) from which branch the new worktree/branch
|
||||
should be cut. Do NOT assume `main` or "whatever I'm on" — the answer is usually the active
|
||||
`release/vX.Y.Z`, but it can be another feature/release branch. Get the base explicitly.
|
||||
2. **Create an isolated worktree + branch off that base** (never reuse the main checkout):
|
||||
|
||||
```bash
|
||||
BASE_BRANCH="release/vX.Y.Z" # ← the branch the operator confirmed in step 1
|
||||
TASK="feat/your-feature" # feat/ fix/ refactor/ docs/ test/ chore/
|
||||
git fetch origin "$BASE_BRANCH"
|
||||
git worktree add ".worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||
cd ".worktrees/${TASK##*/}"
|
||||
# symlink node_modules from the main checkout to skip a per-worktree npm install:
|
||||
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||
```
|
||||
|
||||
In Claude Code prefer the native `EnterWorktree` tool (create the worktree with the command
|
||||
above, then call `EnterWorktree` with its `path`).
|
||||
|
||||
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
|
||||
different branch inside a worktree another session might share.
|
||||
4. **Tear down only your own** worktree + branch when done, from the main checkout:
|
||||
`git worktree remove .worktrees/<dir>` then `git branch -D <task>`. Never blanket-delete
|
||||
`fix/*`/`feat/*` — other sessions keep their own; delete only the branches you created, by name.
|
||||
5. **Never touch another session's worktree, branch, or uncommitted changes.** If `git worktree
|
||||
list` shows worktrees you didn't create, leave them alone. End every session with the main
|
||||
checkout back on the branch it started on (the active `release/vX.Y.Z`, never `main`).
|
||||
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules
|
||||
- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler
|
||||
- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
|
||||
- **Default port**: 20128 (API + dashboard on same port)
|
||||
- **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/`
|
||||
- **Key env vars**: `PORT`, `JWT_SECRET`, `API_KEY_SECRET`, `INITIAL_PASSWORD`, `REQUIRE_API_KEY`, `APP_LOG_LEVEL`
|
||||
- Setup: `cp .env.example .env` then generate `JWT_SECRET` (`openssl rand -base64 48`) and `API_KEY_SECRET` (`openssl rand -hex 32`)
|
||||
|
||||
---
|
||||
|
||||
## Quality Gates & Ratchets
|
||||
|
||||
OmniRoute has **~48 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired
|
||||
across **9 gate-running jobs** in `.github/workflows/ci.yml` (`lint`, `quality-gate`,
|
||||
`quality-extended`, `docs-sync-strict`, `i18n-ui-coverage`, `i18n`, `pr-test-policy`,
|
||||
`test-vitest`, `sonarqube`), plus the `quality.yml` fast-gates job (PR→`release/**`) and
|
||||
3 nightly workflows (`nightly-property`, `nightly-resilience`, `nightly-llm-security`;
|
||||
`nightly-mutation` once merged). Full inventory, per-job breakdown, and operational
|
||||
procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md).
|
||||
|
||||
**Quick reference:**
|
||||
|
||||
- Gates in jobs `lint` + `docs-sync-strict`: pass/fail policy gates —
|
||||
fix the violation or add an allowlist entry with a justification comment + tracking issue.
|
||||
- Gates in job `quality-gate`: ratchet — metrics (ESLint warnings, code coverage, duplication,
|
||||
complexity) must not regress vs `quality-baseline.json`. Update via
|
||||
`npm run quality:ratchet -- --update` when a metric genuinely improves.
|
||||
- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking.
|
||||
`test:vitest:ui` is advisory until UI component tests are triaged.
|
||||
|
||||
**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing
|
||||
violations you cannot fix in the same PR. Add a comment with justification + issue number.
|
||||
Stale allowlist entries (suppressing a violation that no longer exists) will be caught by
|
||||
the stale-enforcement added in Fase 6A.3.
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
|
||||
1. Never commit secrets or credentials
|
||||
2. Never add logic to `localDb.ts`
|
||||
3. Never use `eval()` / `new Function()` / implied eval
|
||||
4. Never commit directly to `main`
|
||||
5. Never write raw SQL in routes — use `src/lib/db/` modules
|
||||
6. Never silently swallow errors in SSE streams
|
||||
7. Always validate inputs with Zod schemas
|
||||
8. Always include tests when changing production code
|
||||
9. Coverage must not regress below the baseline frozen in `quality-baseline.json` (ratchet); absolute floor is 60% (statements/lines/functions/branches). Update the baseline via `npm run quality:ratchet -- --update` only when coverage genuinely improves. See `docs/architecture/QUALITY_GATES.md`.
|
||||
10. Never bypass Husky hooks (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
|
||||
11. Never embed public upstream OAuth client_id/secret or Firebase Web keys as string literals — always go through `resolvePublicCred()` (`open-sse/utils/publicCreds.ts`). See `docs/security/PUBLIC_CREDS.md`.
|
||||
12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`.
|
||||
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
|
||||
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
16. Never credit or advertise an AI assistant, LLM, or automation account in any commit/PR metadata. Two forbidden forms, both equivalent — they route attribution to a bot account (or advertise AI authorship) and hide the real author (`diegosouzapw`): **(a)** `Co-Authored-By` trailers naming an AI/bot (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses); **(b)** AI-generation footers or descriptions anywhere in a commit message, PR title/body, or CHANGELOG — e.g. `🤖 Generated with [Claude Code]`, "Generated with Claude Code", "Made with <AI tool>", or any `Co-authored-by: Claude/GPT/Copilot` line. This **overrides any harness, template, or tool default that auto-appends such a footer** (e.g. the Claude Code PR-body/commit default) — strip it before pushing; do not let it reach a commit, PR, or CHANGELOG. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
|
||||
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
|
||||
|
||||
---
|
||||
|
||||
## PII & Stream Sanitization Learnings
|
||||
|
||||
### 1. Regex Security (ReDoS)
|
||||
|
||||
All regex patterns matching variable-length strings (e.g. IPv6 address, credit cards) must use strictly bounded, non-overlapping sequences (e.g., limit occurrences with bounded ranges `{1,7}`) to prevent catastrophic backtracking when processing untrusted inputs.
|
||||
|
||||
### 2. SSE Snapshot Handling
|
||||
|
||||
When parsing streaming LLM responses (e.g. Responses API), check if a chunk represents a final snapshot (`done` or `completed` events). Snapshot text must be sanitized directly as a standalone string (bypassing rolling delta buffers) to prevent text duplication at the end of the stream.
|
||||
|
||||
### 3. Database Handles in Tests
|
||||
|
||||
Ensure that any unit tests that trigger database migrations or establish SQLite connections call `resetDbInstance()` and properly clean up/close all DB handles in a `test.after(...)` hook. Failure to release database connection handles will cause Node's native test runner to hang indefinitely.
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
|
||||
Thank you for your interest in contributing! This guide covers everything you need to get started.
|
||||
|
||||
For the official per-change workflow, start with the
|
||||
[Contribution Golden Path](docs/ops/CONTRIBUTION_GOLDEN_PATH.md). It maps provider, routing,
|
||||
UI/UX, i18n, CLI, database, and build/deploy changes to their contracts, focused tests, CI
|
||||
coverage, and reconciliation steps.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
@@ -15,12 +10,6 @@ coverage, and reconciliation steps.
|
||||
|
||||
- **Node.js** `>=22.22.3 <23`, or `>=24.0.0 <27` (recommended: 24 LTS)
|
||||
- **npm** 10+
|
||||
|
||||
> **npm v11+ users (Node 24+):** After `npm install`, verify native modules were installed:
|
||||
> `node -e "require('better-sqlite3')"`. If it fails with `MODULE_NOT_FOUND`,
|
||||
> run `npm approve-scripts better-sqlite3 && npm install`. See
|
||||
> [Troubleshooting](docs/guides/TROUBLESHOOTING.md#npm-v11-better-sqlite3-not-installed-cannot-find-module).
|
||||
|
||||
- **Git**
|
||||
|
||||
### Clone & Install
|
||||
@@ -114,19 +103,13 @@ Default URLs:
|
||||
## Git Workflow
|
||||
|
||||
> ⚠️ **NEVER commit directly to `main`.** Always use feature branches.
|
||||
>
|
||||
> **PR base:** target the active `release/vX.Y.Z` branch (not `main`). See
|
||||
> [`docs/ops/BRANCHING_MODEL.md`](docs/ops/BRANCHING_MODEL.md) for the
|
||||
> release-per-branch + tag-at-ship model.
|
||||
|
||||
```bash
|
||||
# Branch from the active release tip (example: release/v3.8.49)
|
||||
git fetch origin
|
||||
git checkout -b feat/your-feature-name origin/release/v3.8.49
|
||||
git checkout -b feat/your-feature-name
|
||||
# ... make changes ...
|
||||
git commit -m "feat: describe your change"
|
||||
git push -u origin feat/your-feature-name
|
||||
# Open a Pull Request with base = release/v3.8.49
|
||||
# Open a Pull Request on GitHub
|
||||
```
|
||||
|
||||
### Branch Naming
|
||||
@@ -177,31 +160,19 @@ npm run test:protocols:e2e
|
||||
# Ecosystem compatibility tests
|
||||
npm run test:ecosystem
|
||||
|
||||
# Coverage gate: 60% statements/lines/functions/branches
|
||||
# Coverage gate: 75% statements/lines/functions, 70% branches
|
||||
npm run test:coverage
|
||||
npm run coverage:report
|
||||
|
||||
# Lint + format check
|
||||
npm run lint
|
||||
npm run check
|
||||
|
||||
# Gated real-upstream combo smoke (requires VPS access + real provider credits)
|
||||
# Hits REAL providers — costs a little. NEVER runs in CI. Skips cleanly without the gate.
|
||||
# Needs: ssh root@192.168.0.15 access (sources a read-only DB snapshot from the VPS).
|
||||
RUN_COMBO_LIVE=1 npm run test:combo:live
|
||||
|
||||
# Phase-3 VPS live smoke — plain Node ESM scripts, hit the live .15 server directly.
|
||||
# Requires: ssh root@192.168.0.15 access (combos created/torn down via SSH sqlite).
|
||||
# Hits REAL providers (small cost). Creates/deletes only __live_test__* combos. NEVER runs in CI.
|
||||
# REQUIRE_API_KEY=false on .15 so no API key needed, but honors COMBO_LIVE_BASE_URL / COMBO_LIVE_API_KEY if set.
|
||||
npm run test:combo:live:vps # 7 HTTP scenarios (priority/round-robin/weighted/cost/fusion/auto + health)
|
||||
npm run test:combo:live:vps:failover # adds a real cross-provider failover scenario (8 total)
|
||||
```
|
||||
|
||||
Coverage notes:
|
||||
|
||||
- `npm run test:coverage` measures source coverage for the main unit test suite, excludes `tests/**`, and includes `open-sse/**`
|
||||
- Pull requests must keep the coverage gate at **60%+** statements/lines/functions/branches
|
||||
- Pull requests must keep the coverage gate at **75%+** statements/lines/functions and **70%+** branches
|
||||
- If a PR changes production code in `src/`, `open-sse/`, `electron/`, or `bin/`, it must add or update automated tests in the same PR
|
||||
- `npm run coverage:report` prints the detailed file-by-file report from the latest coverage run
|
||||
- `npm run test:coverage:legacy` preserves the older metric for historical comparison
|
||||
@@ -209,15 +180,11 @@ Coverage notes:
|
||||
|
||||
### Pull Request Requirements
|
||||
|
||||
Before opening a PR, use the
|
||||
[Contribution Golden Path](docs/ops/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
|
||||
what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and
|
||||
the production build are CI's responsibility — running them locally adds no signal the PR
|
||||
checks will not already give you, and on smaller machines it can saturate the host (#8084):
|
||||
Before opening or merging a PR:
|
||||
|
||||
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||
- Run `npm run lint`
|
||||
- Include or update automated tests in the same PR whenever production code changes
|
||||
- Run `npm run test:unit`
|
||||
- Run `npm run test:coverage`
|
||||
- Ensure the coverage gate stays at **75%+** statements/lines/functions, **70%+** branches
|
||||
- Include the changed or added test files in the PR description when production code changed
|
||||
- Check the SonarQube result on the PR when the project secrets are configured in CI
|
||||
|
||||
@@ -243,31 +210,6 @@ Current test status: **122 unit test files** covering:
|
||||
- **Zod validation** — Use Zod v4 schemas for all API input validation
|
||||
- **Naming**: Files = camelCase/kebab-case, components = PascalCase, constants = UPPER_SNAKE
|
||||
|
||||
### Error handling / empty catch blocks
|
||||
|
||||
Never leave a `catch` unexplained. Classify it into one of two buckets (operationalizes
|
||||
the hard rule "never silently swallow errors in SSE streams"):
|
||||
|
||||
- **Intentional (our own best-effort cleanup/telemetry)** — a failure here is expected and
|
||||
harmless; add a one-line rationale comment, no logging (logging on every request is the
|
||||
noise this convention avoids).
|
||||
|
||||
```ts
|
||||
} catch {} // closing an already-closed controller after client disconnect is expected
|
||||
```
|
||||
|
||||
- **Should log (external/caller-supplied code, or the swallow changes control flow)** — keep
|
||||
the catch (never let it break the stream) but emit a contextual `console.debug`/`warn` so the
|
||||
failure is discoverable.
|
||||
|
||||
```ts
|
||||
} catch (e) {
|
||||
console.debug("[STREAM] onFailure callback error:", e);
|
||||
}
|
||||
```
|
||||
|
||||
See `open-sse/utils/stream.ts` and `open-sse/utils/streamHandler.ts` for applied examples.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
@@ -283,7 +225,7 @@ src/ # TypeScript (.ts / .tsx)
|
||||
│ ├── a2a/ # Agent-to-Agent v0.3 protocol server
|
||||
│ ├── acp/ # Agent Communication Protocol registry
|
||||
│ ├── compliance/ # Compliance policy engine
|
||||
│ ├── db/ # SQLite domain modules + 130 migrations
|
||||
│ ├── db/ # SQLite database layer (21 modules + 16 migrations)
|
||||
│ ├── memory/ # Persistent conversational memory
|
||||
│ ├── oauth/ # OAuth providers, services, and utilities
|
||||
│ ├── skills/ # Extensible skill framework
|
||||
@@ -293,16 +235,16 @@ src/ # TypeScript (.ts / .tsx)
|
||||
├── mitm/ # MITM proxy (cert, DNS, target routing)
|
||||
├── shared/
|
||||
│ ├── components/ # React components (.tsx)
|
||||
│ ├── constants/ # Provider definitions (329), MCP scopes, 19 routing strategies
|
||||
│ ├── constants/ # Provider definitions (177), MCP scopes, 14 routing strategies
|
||||
│ ├── utils/ # Circuit breaker, sanitizer, auth helpers
|
||||
│ └── validation/ # Zod v4 schemas
|
||||
└── sse/ # SSE proxy pipeline
|
||||
|
||||
open-sse/ # @omniroute/open-sse workspace
|
||||
├── executors/ # 89 executor implementation modules
|
||||
├── executors/ # 14 provider-specific request executors
|
||||
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
|
||||
├── mcp-server/ # MCP server (107 unique tools, 3 transports, 32 scopes)
|
||||
├── services/ # 178 top-level services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── mcp-server/ # MCP server (25 tools, 3 transports, 10 scopes)
|
||||
├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
|
||||
├── transformer/ # Responses API transformer
|
||||
└── utils/ # 22 utility modules (stream, TLS, proxy, logging)
|
||||
@@ -387,7 +329,7 @@ Write unit tests in `tests/unit/` covering at minimum:
|
||||
- [ ] Error responses route through `buildErrorBody()` / `sanitizeErrorMessage()` — no raw stack traces in response bodies (see [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md))
|
||||
- [ ] Shell commands (`exec` / `spawn`) pass runtime values via `env`, not via string interpolation
|
||||
- [ ] All inputs validated with Zod schemas
|
||||
- [ ] Changelog **fragment** added under `changelog.d/{features|fixes|maintenance}/<PR>-<slug>.md` for user-facing changes (see [`changelog.d/README.md`](./changelog.d/README.md)) — do **not** edit `CHANGELOG.md` directly; fragments are aggregated at release time and never conflict between PRs
|
||||
- [ ] CHANGELOG updated (if user-facing change)
|
||||
- [ ] Documentation updated (if applicable)
|
||||
- [ ] No new CodeQL / Secret-Scanning alerts opened, or each one dismissed with technical justification referencing the relevant `docs/security/` doc
|
||||
- [ ] Routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) classified as `isLocalOnlyPath()` in `src/server/authz/routeGuard.ts` — see [Hard Rule #15](docs/security/ROUTE_GUARD_TIERS.md)
|
||||
|
||||
254
DESING.md
Normal file
254
DESING.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# OmniRoute — Design System & Visual Identity
|
||||
|
||||
> **Status:** analysis + standardization plan (no code applied yet — this doc is the spec to approve before implementation).
|
||||
> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard:
|
||||
|
||||
1. The **graph-paper grid wallpaper** the site uses on every page.
|
||||
2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font).
|
||||
3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba.
|
||||
|
||||
This document is the analysis and the plan. **Nothing is changed until approved.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principles
|
||||
|
||||
- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first.
|
||||
- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`.
|
||||
- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI.
|
||||
- **Theme-aware.** Everything works in both `.dark` (default-ish, the product's signature look) and light.
|
||||
- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state — what's already aligned vs. what's not
|
||||
|
||||
### 3.1 Colors — already unified ✅
|
||||
|
||||
Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`:
|
||||
|
||||
| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match |
|
||||
| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ |
|
||||
| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ |
|
||||
| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ |
|
||||
| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ |
|
||||
| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) |
|
||||
| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) |
|
||||
| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ |
|
||||
| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ |
|
||||
| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ |
|
||||
| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ |
|
||||
|
||||
**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it.
|
||||
|
||||
### 3.2 Gaps — what the dashboard is missing
|
||||
|
||||
| Gap | Site has | Dashboard | Action |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- | ---------------------- |
|
||||
| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 46px`, `--section-alt` | none (flat `--color-bg`) | **Part A** |
|
||||
| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | none — primitives use ad-hoc `rounded-md/lg/xl` (6/8/12px) | **Part B** |
|
||||
| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | none — only a one-off `.bg-hero-gradient` | **Part B** |
|
||||
| **Nested surface** | `--surface-2 #1c2230` | none | **Part B** |
|
||||
| **Mono font** | `--font-mono` (ui-monospace stack) | none (code/terminal areas have no token) | **Part B** |
|
||||
| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** |
|
||||
|
||||
### 3.3 Theming mechanics (so we don't break anything)
|
||||
|
||||
- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`).
|
||||
- **Dark via `.dark` class** on `<html>` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism.
|
||||
- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` will inherit those overrides for free. ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Part A — The graph-paper grid background (headline ask)
|
||||
|
||||
### 4.1 What it is
|
||||
|
||||
The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content.
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid.
|
||||
|
||||
### 4.2 Precedent already in the codebase
|
||||
|
||||
`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; we are promoting it to a **global, theme-aware** wallpaper and (optionally) retiring the duplicate.
|
||||
|
||||
### 4.3 Tokens to add (in `globals.css`)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* light */
|
||||
--grid-line: rgba(0, 0, 0, 0.045);
|
||||
--grid-size: 46px;
|
||||
--section-alt: rgba(0, 0, 0, 0.022);
|
||||
}
|
||||
.dark {
|
||||
/* dark */
|
||||
--grid-line: rgba(255, 255, 255, 0.035);
|
||||
--section-alt: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 The single blocker
|
||||
|
||||
The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hides it inside the panel:
|
||||
|
||||
- `src/shared/components/layouts/DashboardLayout.tsx:62` — the outer wrapper paints an opaque `bg-bg`:
|
||||
|
||||
```jsx
|
||||
<div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
```
|
||||
|
||||
Everything below it is already transparent — `<main>` (`:93`), the scroll container (`:102`), the `max-w-7xl` inner (`:103`). So **removing `bg-bg` from this one line** lets the body grid show through the entire content area (the body's `--color-bg` remains the base fill underneath the grid).
|
||||
|
||||
```diff
|
||||
- <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
+ <div className="flex h-dvh min-h-0 w-full overflow-hidden">
|
||||
```
|
||||
|
||||
### 4.5 Chrome interaction (sidebar / header)
|
||||
|
||||
- `Header` (`src/shared/components/Header.tsx:207`, `bg-bg`) and `Sidebar` (`src/shared/components/Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. This is the recommended, calm default and matches how the site separates chrome from canvas.
|
||||
- _Optional vibrancy variant:_ make the header translucent (`bg-bg/80 backdrop-blur`) so the grid runs behind it. A `.bg-vibrancy` helper already exists (`globals.css:370`). **Decision D3 below.**
|
||||
|
||||
### 4.6 Login / auth / error pages
|
||||
|
||||
These render directly under `<body>` (no panel chrome) and their page wrappers are mostly transparent — the global grid appears behind them automatically. One exception: `src/app/login/page.tsx:124,139` uses opaque `bg-bg` wrappers; soften the same way if we want the grid there too (minor, **D4**).
|
||||
|
||||
### 4.7 Landing page
|
||||
|
||||
`landing/page.tsx` keeps its richer animated background (orbs + vignette). Options: (a) leave it as-is (its own splash identity), or (b) align its grid to the global tokens (46px, neutral lines) for consistency. **Recommend (a)** — it's a marketing splash, not a panel screen. **Decision D5.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Part B — Token unification
|
||||
|
||||
Add to `globals.css` (`:root` + `@theme inline`) so the dashboard gains the site's missing tokens. None of these change existing colors; they add the _missing_ primitives.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--surface-2: #f5f5fa; /* light: nested panels */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--grad-brand: linear-gradient(135deg, var(--color-primary), var(--color-accent-light));
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
.dark {
|
||||
--surface-2: #1c2230;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-2: var(--surface-2); /* enables bg-surface-2 */
|
||||
--radius-lg: var(--radius); /* enables rounded-lg = 14px */
|
||||
--radius-md: var(--radius-sm); /* enables rounded-md = 9px */
|
||||
--font-mono: var(--font-mono); /* enables font-mono */
|
||||
}
|
||||
```
|
||||
|
||||
| Token | Why | Consumers |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | Button, Card, Modal, Input, Select |
|
||||
| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | Button `primary`, hero/CTA surfaces |
|
||||
| `--surface-2` | Nested panels / table headers / inset rows | Card.Section, DataTable header, inputs |
|
||||
| `--font-mono` | Code blocks, terminal, IDs, endpoints | ConsoleLogViewer, code snippets, `localhost:20128/v1` chips |
|
||||
| `--text-muted` reconcile | Pick one value site↔panel | global |
|
||||
|
||||
**Decision D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** (it's the live product, slightly higher contrast) and updating the _site_ to match. Low priority, cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Part C — Component standardization
|
||||
|
||||
The component layer is **custom** (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (`bg-surface`, `border-white/10`, `ring-primary`) — good adoption (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`.
|
||||
|
||||
Ranked by impact × reach:
|
||||
|
||||
| # | Item | File(s) | Problem → Target |
|
||||
| --- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → repoint to `--radius`/`--radius-sm` (14/9) |
|
||||
| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red (`from-primary to-primary-hover`); align to `--grad-brand` (red→violet) and add the missing `accent` variant (indigo `#6366f1` is unused by buttons) — **highest visibility, ~195 importers**. **Decision D1.** |
|
||||
| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` (Ant remnants) | `DataTable` is 100% inline hardcoded rgba + references non-existent vars (`--text-secondary`, `--bg-table-header`); migrate to tokens, retire the 2 divergent table styles. Tables are everywhere (providers/connections/logs) — worst offender. |
|
||||
| C4 | **Centralize status colors** | `flow/edgeStyles.ts:7-12`, `TokenHealthBadge.tsx:14-19`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx`, +5 `statusColor` helpers | 6+ copies of the same `#22c55e/#f59e0b/#ef4444` hex; create one `statusColors` module driven off `--color-success/warning/error`. Critical for circuit-breaker / cooldown / lockout badges to read consistently. |
|
||||
| C5 | **Card border** | `Card.tsx:39` | uses `border-white/5`; brand border is `/8` → align |
|
||||
| C6 | **Focus ring reconcile** | `globals.css:183` vs component `ring-primary/30` | global `:focus-visible` is indigo (`--color-accent`), components are red (`ring-primary`) — pick one (recommend **accent/indigo** globally, it reads as the "interactive" color) |
|
||||
| C7 | **Add `Checkbox` + `Textarea` primitives** | currently raw `<input>`/`<textarea>` with inline `accentColor:#6366f1` (e.g. `ColumnToggle.tsx:91`) | create token-driven primitives |
|
||||
| C8 | **Hardcoded-hex sweep** | `ConsoleLogViewer.tsx:240` (`#161b22`/`#30363d`), `ComboLiveStudio.tsx:306` (`#6366f120`), Modal traffic dots `Modal.tsx:149-159`, ~14 chart/component files with literal `#6366f1`/`#a855f7` | replace literals with `bg-surface`/`border-border`/`text-accent` etc. |
|
||||
| C9 | **`cn()` → clsx + tailwind-merge** | `src/shared/utils/cn.ts` | current `cn` just joins; conflicting classes stack (a `className="rounded-2xl"` override won't replace a primitive's `rounded-lg`). Needed for C1 overrides to behave. |
|
||||
|
||||
**Already on-brand (token-driven, only need radius):** `Badge`, `Toggle`, `SegmentedControl`, `Input`, `Select`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout plan (phased, each phase shippable + testable)
|
||||
|
||||
- **Phase 1 — Grid + tokens (low risk, high visibility).**
|
||||
1. Add grid + identity tokens to `globals.css` (Part A §4.3, Part B §5).
|
||||
2. Add `body::before` grid.
|
||||
3. Remove `bg-bg` from `DashboardLayout.tsx:62`.
|
||||
4. Verify across themes + key screens (dashboard, providers, logs, login, an error page). Confirm contrast unchanged.
|
||||
→ _Delivers the headline ask. Reversible in one commit._
|
||||
|
||||
- **Phase 2 — Primitives radius + Button (C1, C2, C5, C9).** The visible "feel" pass. `cn()` upgrade first so overrides behave.
|
||||
|
||||
- **Phase 3 — Tables + status colors (C3, C4).** The largest consistency win; touch the data-heavy screens.
|
||||
|
||||
- **Phase 4 — Cleanup (C6, C7, C8).** Focus ring, new primitives, hardcoded-hex sweep.
|
||||
|
||||
Each phase: `npm run lint` + `npm run typecheck:core` + a visual pass. Per repo rule, production-code changes ship with tests where applicable (token/CSS changes are visual — validated by screenshots; component API changes get unit coverage).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions (need your call before/while implementing)
|
||||
|
||||
- **D1 — Button primary look.** Keep the current **red→red** gradient, or switch the product's primary buttons to the **red→violet `--grad-brand`** (matches the site CTAs)? _(Affects every primary button.)_ Recommend: **red→violet**, with `--grad-brand`.
|
||||
- **D2 — Grid line color.** **Neutral** lines (site style: faint white/black, `rgba(255,255,255,0.035)`) — calm, content-first — **or** the landing's **brand-red** lines? Recommend: **neutral** (matches the site's interior pages; red is louder and can tint readability). Size **46px** (site) to retire the landing's 50px drift.
|
||||
- **D3 — Chrome vibrancy.** Sidebar/header stay **solid** (grid in content area only), or go **translucent** so the grid runs behind them? Recommend: **solid** (calmer; less risk).
|
||||
- **D4 — Auth/login grid.** Soften `login/page.tsx` wrappers so the grid shows there too? Recommend: **yes** (cheap, more cohesive).
|
||||
- **D5 — Landing page.** Leave its animated splash bg as-is, or align it to the global grid? Recommend: **leave as-is**.
|
||||
- **D6 — Radius value.** Adopt **14/9** everywhere (bigger, softer, site-matching) — confirm you want this product-wide shift. Recommend: **yes**, it's the single biggest "one identity" signal.
|
||||
- **D7 — Scope of first PR.** Ship **Phase 1 only** first (grid + tokens), then iterate? Recommend: **yes** — validate the wallpaper live before the component waves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope / risks
|
||||
|
||||
- **No palette change** — colors already match; we only add missing tokens. Zero risk of recoloring the product.
|
||||
- **No theme-engine change** — keep `.dark` + Zustand store; don't migrate to `next-themes` or to the site's `data-theme`.
|
||||
- **Radius shift is broad** (D6) — it touches every card/button/input; that's the point, but it's the one change worth eyeballing on busy screens (tables, modals) before merge.
|
||||
- **Tables (C3)** carry the most hardcoded styling and the highest regression surface — isolate in its own PR with before/after screenshots.
|
||||
- **Worktree isolation (repo hard-rule #19):** implementation runs in a dedicated worktree on a branch cut from the confirmed base (likely `release/v3.8.28`), never on the shared checkout. This doc is the only artifact written to the working tree so far.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reference index
|
||||
|
||||
| Area | Path |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dashboard tokens | `src/app/globals.css:30-179` (`:root`, `.dark`, `@theme inline`), `body` `:206` |
|
||||
| Theme store | `src/store/themeStore.ts`, `src/shared/components/ThemeProvider.tsx`, `src/shared/constants/appConfig.ts:9-11` |
|
||||
| Panel shell (grid blocker) | `src/shared/components/layouts/DashboardLayout.tsx:62` |
|
||||
| Chrome | `src/shared/components/Header.tsx:207`, `src/shared/components/Sidebar.tsx:430` |
|
||||
| Grid precedent | `src/app/landing/page.tsx:16-26` |
|
||||
| Primitives | `src/shared/components/{Button,Card,Input,Select,Badge,Modal,Toggle,SegmentedControl,Loading,Tooltip,DataTable}.tsx`, barrel `index.tsx` |
|
||||
| Status-color sources | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `logTableStyles.ts` |
|
||||
| `cn` util | `src/shared/utils/cn.ts` |
|
||||
| Site reference | `_mono_repo/omnirouteSite/css/tokens.css`, `css/base.css` (grid `body::before`) |
|
||||
175
Dockerfile
175
Dockerfile
@@ -1,79 +1,25 @@
|
||||
# ── Common base with runtime deps ──────────────────────────────────────────
|
||||
FROM node:26-trixie-slim AS base
|
||||
FROM node:24-trixie-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie)
|
||||
# base-image packages at build time — clears the subset of container-scan CVEs
|
||||
# (perl / util-linux / systemd / ncurses / zlib / tar / sqlite / shadow / pam …)
|
||||
# that already have a fix published in trixie. CVEs without an upstream fix yet
|
||||
# (local-only TOCTOU, etc.) remain until the distro patches them and the image
|
||||
# is rebuilt; none are reachable from the proxy's request surface at runtime.
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# npm's *bundled* node_modules (brace-expansion, ip-address, tar, undici) are
|
||||
# npm's own internals — not application dependencies (the app resolves its own,
|
||||
# already-fixed copies) — but the container scanner reads them off
|
||||
# /usr/local/lib/node_modules/npm/node_modules and reports 9 HIGH/MEDIUM CVEs.
|
||||
#
|
||||
# Refreshing npm does NOT fix them. Measured on npm@12.0.2 (2026-08-12, latest):
|
||||
# brace-expansion 5.0.7 (needs >= 5.0.9) CVE-2026-69152, CVE-2026-14257
|
||||
# ip-address 10.2.0 (needs >= 10.3.1) CVE-2026-69192/-69198/-54272
|
||||
# tar 7.5.19 (needs >= 7.5.21) GHSA-r292-9mhp-454m
|
||||
# undici 6.27.0 (needs >= 6.28.0) CVE-2026-16729/-16728/-15157
|
||||
# No published npm release carries patched copies, so `npm install -g npm@latest`
|
||||
# alone was pure build time for zero CVEs — it is kept only to land on a known,
|
||||
# current npm tree, and the patched copies are overlaid on top below.
|
||||
#
|
||||
# Deleting npm from the runner stages is NOT an option: the application shells
|
||||
# out to npm at runtime (src/lib/services/installers/utils.ts::runNpm for the
|
||||
# embedded services, src/lib/system/{autoUpdate,globalPackagePath}.ts,
|
||||
# src/app/api/system/version). The previous version of this comment claimed the
|
||||
# opposite; it was wrong.
|
||||
#
|
||||
# The overlay is semver-compatible with the ranges npm's own tree declares
|
||||
# (minimatch → brace-expansion ^5.0.5, socks → ip-address ^10.1.1, node-gyp →
|
||||
# tar ^7.5.4 and undici ^6.25.0 — hence undici stays on the 6.x line, NOT 8.x).
|
||||
# --install-strategy=nested makes each replacement self-contained, so it cannot
|
||||
# perturb the versions the rest of npm's flat tree resolves.
|
||||
RUN set -eux; \
|
||||
npm install -g npm@latest; \
|
||||
npm install --prefix /tmp/npm-cve-patch --no-audit --no-fund --ignore-scripts \
|
||||
--install-strategy=nested \
|
||||
brace-expansion@5.0.9 ip-address@10.5.0 tar@7.5.22 undici@6.28.0; \
|
||||
for pkg in brace-expansion ip-address tar undici; do \
|
||||
test -d "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
|
||||
rm -rf "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
|
||||
cp -R "/tmp/npm-cve-patch/node_modules/$pkg" \
|
||||
"/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
|
||||
done; \
|
||||
rm -rf /tmp/npm-cve-patch; \
|
||||
node -e "for (const p of ['brace-expansion','ip-address','tar','undici']) console.log(p, require('/usr/local/lib/node_modules/npm/node_modules/'+p+'/package.json').version);"; \
|
||||
npm --version; \
|
||||
npm cache clean --force
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
|
||||
# Build tools for native module compilation
|
||||
# apt-get update needed here because base's rm -rf clears the shared cache
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
# Workspace package manifests MUST be present before `npm ci` so npm materializes
|
||||
# the workspace and installs its *workspace-only* deps (e.g. safe-regex,
|
||||
# @toon-format/toon — declared in open-sse/package.json, not hoisted to root).
|
||||
# Without this, `npm ci` skips them and the application build fails with "Module not
|
||||
# found" (root cause of the v3.8.39 Docker build break). workspaces = ["open-sse"].
|
||||
COPY open-sse/package.json ./open-sse/package.json
|
||||
COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
|
||||
COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs
|
||||
COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs
|
||||
@@ -87,81 +33,33 @@ ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
# are reproducible.
|
||||
RUN test -f package-lock.json \
|
||||
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
|
||||
# `npm rebuild <pkg>` re-runs the package's own install script, so under npm 11 +
|
||||
# `--ignore-scripts` on the parent `npm ci` it depends on npm's script-allowlist
|
||||
# machinery correctly re-enabling that one package's script. Some self-hosted build
|
||||
# environments (e.g. Dokploy) hit a broken/incomplete better-sqlite3 native binding
|
||||
# from that indirection. Invoking `node-gyp rebuild` directly inside the package
|
||||
# directory bypasses npm's script-running layer entirely and is deterministic
|
||||
# regardless of npm version or ignore-scripts allowlist behavior.
|
||||
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
|
||||
# instead of `npx --yes`, which would install an arbitrary registry version
|
||||
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
|
||||
#
|
||||
# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS
|
||||
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
|
||||
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
|
||||
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
|
||||
# better-sqlite3 above, that script never throws on failure — it only
|
||||
# `console.warn`s and exits 0 — so a rate-limited or offline build would
|
||||
# otherwise succeed silently with an empty bin/ and only fail at first request
|
||||
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
|
||||
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
|
||||
# broken image.
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& (cd node_modules/better-sqlite3 \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()" \
|
||||
&& node node_modules/tls-client-node/scripts/postinstall.js \
|
||||
&& (test -n "$(find node_modules/tls-client-node/bin -mindepth 1 -print -quit 2>/dev/null)" \
|
||||
|| (echo "tls-client-node native binary missing after postinstall — GitHub API fetch likely rate-limited or failed (#7802)" >&2 && exit 1))
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
|
||||
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a
|
||||
# root" in ImportTracer::get_traces) no longer reproduces on Next 16.2.9 — validated
|
||||
# 2026-07-05 with clean amd64 (12min14s, image smoke-tested: /api/monitoring/health
|
||||
# 200) and arm64 (qemu, exit 0, zero panic strings) builds. Turbopack cut the bare
|
||||
# build from 17min to 9min on the same 32-core box. Webpack stays available as the
|
||||
# escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0.
|
||||
# Build with webpack (stable). Turbopack hit a non-recoverable internal panic on this
|
||||
# Next.js version during the v3.8.27 release build — TurbopackInternalError "entered
|
||||
# unreachable code: there must be a path to a root" in ImportTracer::get_traces, on both
|
||||
# linux/amd64 and linux/arm64. Webpack is the proven engine (build:release / VPS / CI Build
|
||||
# all green). Re-enable Turbopack (=1) once the upstream tracer bug is fixed.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
#
|
||||
# Declared as ARG+ENV, not a bare ENV: a bare ENV shadows any same-named ARG for
|
||||
# the rest of the stage, so `--build-arg OMNIROUTE_USE_TURBOPACK=0` was silently
|
||||
# ignored and the escape hatch above only ever worked via `-e` at runtime, never
|
||||
# at build time. Turbopack compiles in native Rust memory that lives outside the
|
||||
# V8 heap, so OMNIROUTE_BUILD_MEMORY_MB cannot bound it and a memory-constrained
|
||||
# build host gets SIGKILLed by the cgroup OOM killer with no error message.
|
||||
ARG OMNIROUTE_USE_TURBOPACK=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}"
|
||||
|
||||
# Next.js basePath is fixed at build time; pass OMNIROUTE_BASE_PATH here when the
|
||||
# image should serve under a reverse-proxy subpath without a runtime patch.
|
||||
ARG OMNIROUTE_BASE_PATH=""
|
||||
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
|
||||
|
||||
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
|
||||
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
|
||||
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
|
||||
ENV OMNIROUTE_MITM_STUB=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK=0
|
||||
|
||||
# Raise the V8 heap ceiling for the build. The webpack production optimization
|
||||
# pass needs more than V8's default ceiling (~2 GB) for a codebase this size; a
|
||||
# memory-constrained Docker build otherwise dies with "FATAL ERROR: ... JavaScript
|
||||
# heap out of memory" during the builder stage (#4076). Turbopack's compile is
|
||||
# native (Rust) and less V8-heap-bound, but the prerender/export phase still runs
|
||||
# on V8, so keep the ceiling. NODE_OPTIONS propagates to the spawned `next build`
|
||||
# child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env).
|
||||
# Build-only; the runtime heap is set separately on the runner stage
|
||||
# (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
# pass (forced above since Turbopack panics) needs more than V8's default ceiling
|
||||
# (~2 GB) for a codebase this size; a memory-constrained Docker build otherwise
|
||||
# dies with "FATAL ERROR: ... JavaScript heap out of memory" at `[builder] npm run
|
||||
# build` (#4076). NODE_OPTIONS propagates to the spawned `next build` child
|
||||
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). Build-only;
|
||||
# the runtime heap is set separately on the runner stage (OMNIROUTE_MEMORY_MB).
|
||||
# Override for hosts with more/less RAM: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data \
|
||||
&& npm run build \
|
||||
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
|
||||
RUN --mount=type=cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data && npm run build
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
@@ -175,12 +73,6 @@ LABEL org.opencontainers.image.title="omniroute" \
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight
|
||||
# for large fusion-combo panels (many models fanned out in parallel, each
|
||||
# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS
|
||||
# .maxPanel, issue #1905). Override at `docker run` time with
|
||||
# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel
|
||||
# above the default cap.
|
||||
ENV OMNIROUTE_MEMORY_MB=1024
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
|
||||
|
||||
@@ -221,8 +113,8 @@ EXPOSE 20128
|
||||
USER node
|
||||
|
||||
# Warns if the mounted data volume has wrong ownership
|
||||
COPY --chmod=755 scripts/check-permissions.sh /app/check-permissions.sh
|
||||
ENTRYPOINT ["/app/check-permissions.sh"]
|
||||
COPY --chmod=755 scripts/check-permissions.sh /tmp/check-permissions.sh
|
||||
ENTRYPOINT ["/tmp/check-permissions.sh"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD ["node", "healthcheck.mjs"]
|
||||
@@ -262,8 +154,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
|
||||
# browsers land under /home/node which persists across image layers and is
|
||||
# accessible to the non-root runtime user.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& node node_modules/playwright/cli.js install chromium --with-deps \
|
||||
&& chown -R node:node /home/node/.cache \
|
||||
@@ -278,21 +170,16 @@ FROM runner-base AS runner-cli
|
||||
# runner-base runs.
|
||||
USER root
|
||||
|
||||
# The CLI image can use the internal ChatGPT Web (Codex) Chromium sidecar over
|
||||
# CDP without installing a second browser in this container.
|
||||
COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core
|
||||
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
|
||||
|
||||
# Install system dependencies required by openclaw (git+ssh references).
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
USER node
|
||||
|
||||
57
GEMINI.md
57
GEMINI.md
@@ -1,13 +1,50 @@
|
||||
# GEMINI.md
|
||||
# Security and Cleanliness Rules for AI Assistants
|
||||
|
||||
> **Single source of truth:** all project rules for AI assistants live in
|
||||
> [`AGENTS.md`](AGENTS.md). Read it in full before any change — it contains the 23 Hard Rules,
|
||||
> quality gates, code conventions, file-placement / repo-root hygiene rules, the repository map
|
||||
> and the local development access notes that used to live in this file.
|
||||
> **Scope:** rules for Gemini-based agents. For Claude Code, see `CLAUDE.md`. For other AI assistants, see `AGENTS.md`.
|
||||
|
||||
Gemini-specific notes:
|
||||
## 1. File Placement & Organization
|
||||
|
||||
- Skills activate via the `activate_skill` tool (skill metadata is loaded at session start and
|
||||
the full content is activated on demand).
|
||||
- There are no other Gemini-only rules today. Do not re-add project rules here — edit
|
||||
`AGENTS.md` instead, so every assistant sees the same instructions.
|
||||
- **Test Files**: ALL unit tests, integration tests, ecosystem tests, or Vitest files MUST strictly be placed within the `tests/` directory (e.g., `tests/unit/`, `tests/integration/`). NEVER create test files in the project root (`/`).
|
||||
- **Scripts and Utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside one of the `scripts/` subfolders (`build/`, `dev/`, `check/`, `docs/`, `i18n/`, `ad-hoc/`). One-shot or experimental code goes under `scripts/ad-hoc/`. NEVER dump loose scripts in the project root (`/`) or the top-level `scripts/` folder.
|
||||
|
||||
**The Project Root MUST ONLY CONTAIN:**
|
||||
|
||||
- Configuration files (`vitest.config.ts`, `next.config.mjs`, `eslint.config.mjs`, `tsconfig*.json`, `playwright.config.ts`, `prettier.config.mjs`, `postcss.config.mjs`, `sonar-project.properties`, `fly.toml`, `docker-compose*.yml`, `Dockerfile`)
|
||||
- Dependency files (`package.json`, `package-lock.json`)
|
||||
- Documentation files (`README.md`, `CHANGELOG.md`, `LICENSE`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `llm.txt`, `Tuto_Qdrant.md`)
|
||||
- CI/CD files and ignore definitions (`.gitignore`, `.dockerignore`, `.npmignore`, `.npmrc`, `.node-version`, `.nvmrc`, `.env.example`)
|
||||
|
||||
When creating _any_ validation tests or one-off logic scripts, default to using `scripts/ad-hoc/` or the `tests/unit/` directories according to your goals. Do not pollute the `/` root context.
|
||||
|
||||
## 2. Hard Rules (mirror of `CLAUDE.md`)
|
||||
|
||||
1. **Never commit secrets or credentials.** Use `.env` (auto-generated from `.env.example`) or a vault. Passwords, OAuth secrets, API keys, and Cookie values must never appear in committed files.
|
||||
2. **Never add logic to `src/lib/localDb.ts`.** It is a re-export barrel only.
|
||||
3. **Never use `eval()`, `new Function()`, or any implied eval.** ESLint enforces this.
|
||||
4. **Never commit directly to `main`.** Use `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, or `chore/` branches.
|
||||
5. **Never write raw SQL in routes** — always go through `src/lib/db/` domain modules.
|
||||
6. **Never silently swallow errors in SSE streams** — propagate them or abort the stream cleanly.
|
||||
7. **Never bypass Husky hooks** (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
|
||||
8. **Always validate inputs with Zod schemas** from `src/shared/validation/schemas.ts`.
|
||||
9. **Always include tests when changing production code** (`src/`, `open-sse/`, `electron/`, `bin/`).
|
||||
10. **Coverage must stay** ≥ 75 % statements / 75 % lines / 75 % functions / 70 % branches (real measured: ~82 %).
|
||||
|
||||
## 3. Codebase navigation
|
||||
|
||||
| Task | Read this first |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Understand the codebase | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture overview | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Add a feature | `CONTRIBUTING.md` + the matching `docs/<area>.md` |
|
||||
| Per-area deep dives | `docs/frameworks/SKILLS.md`, `docs/frameworks/MEMORY.md`, `docs/frameworks/EVALS.md`, `docs/security/GUARDRAILS.md`, `docs/security/COMPLIANCE.md`, `docs/frameworks/CLOUD_AGENT.md`, `docs/frameworks/MCP-SERVER.md`, `docs/frameworks/A2A-SERVER.md`, `docs/architecture/AUTHZ_GUIDE.md`, `docs/architecture/RESILIENCE_GUIDE.md`, `docs/routing/AUTO-COMBO.md`, `docs/frameworks/WEBHOOKS.md`, `docs/routing/REASONING_REPLAY.md`, `docs/security/STEALTH_GUIDE.md`, `docs/ops/TUNNELS_GUIDE.md`, `docs/guides/ELECTRON_GUIDE.md`, `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
|
||||
## 4. Local development access
|
||||
|
||||
The dashboard is reachable at the operator's chosen URL/port (default `http://localhost:20128`). Credentials are operator-specific:
|
||||
|
||||
- **Initial admin password** is read from the `INITIAL_PASSWORD` env var on first install (defaults to `CHANGEME` in `.env.example`; rotate immediately after first login).
|
||||
- **Local VPS / shared dev environments**: ask the operator for the URL and current credentials — they live in their personal vault, NOT in this repo.
|
||||
|
||||
> Any credential observed in a previous version of this file was a non-production demo value; treat it as compromised and do not reuse it.
|
||||
|
||||
69
Makefile
69
Makefile
@@ -1,69 +0,0 @@
|
||||
.PHONY: help install dev start build build-release lint typecheck typecheck-strict \
|
||||
test test-unit test-vitest test-coverage test-all test-integration test-e2e \
|
||||
check check-cycles check-docs env-sync clean
|
||||
|
||||
# OmniRoute — convenience wrapper around the npm scripts.
|
||||
# All targets delegate to the canonical package.json scripts (single source of truth).
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
install: ## Install dependencies (auto-generates .env from .env.example)
|
||||
npm install
|
||||
|
||||
dev: ## Dev server at http://localhost:20128
|
||||
npm run dev
|
||||
|
||||
start: ## Production server (requires a prior build)
|
||||
npm run start
|
||||
|
||||
build: ## Production build (Next.js 16 standalone)
|
||||
npm run build
|
||||
|
||||
build-release: ## Release build
|
||||
npm run build:release
|
||||
|
||||
lint: ## ESLint (0 errors expected)
|
||||
npm run lint
|
||||
|
||||
typecheck: ## TypeScript check (core)
|
||||
npm run typecheck:core
|
||||
|
||||
typecheck-strict: ## Strict check (no implicit any)
|
||||
npm run typecheck:noimplicit:core
|
||||
|
||||
test: ## Unit tests (Node native runner)
|
||||
npm run test:unit
|
||||
|
||||
test-unit: ## Alias for `test`
|
||||
npm run test:unit
|
||||
|
||||
test-vitest: ## Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
test-coverage: ## Unit tests + coverage gate (60/60/60/60)
|
||||
npm run test:coverage
|
||||
|
||||
test-all: ## All suites (unit + vitest + ecosystem + e2e)
|
||||
npm run test:all
|
||||
|
||||
test-integration: ## Integration tests
|
||||
npm run test:integration
|
||||
|
||||
test-e2e: ## E2E (Playwright)
|
||||
npm run test:e2e
|
||||
|
||||
check: ## lint + test combined
|
||||
npm run check
|
||||
|
||||
check-cycles: ## Detect circular dependencies
|
||||
npm run check:cycles
|
||||
|
||||
check-docs: ## Validate documentation (incl. fabricated-docs)
|
||||
npm run check:docs-all
|
||||
|
||||
env-sync: ## Sync .env from .env.example
|
||||
npm run env:sync
|
||||
|
||||
clean: ## Remove build artifacts
|
||||
rm -rf .build dist coverage .eslintcache
|
||||
96
ROADMAP.md
96
ROADMAP.md
@@ -1,96 +0,0 @@
|
||||
---
|
||||
title: "OmniRoute Roadmap"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-06
|
||||
---
|
||||
|
||||
# OmniRoute Roadmap
|
||||
|
||||
> Version-gated, not date-gated: each milestone ships when its quality gates pass.
|
||||
> Current line: **v3.8.x** (this branch). Last updated: 2026-08-06.
|
||||
|
||||
OmniRoute is heading from a monolithic router to a **modular AI platform**: a lightweight
|
||||
core engine, a typed SDK, and everything else as installable modules and plugins. The path
|
||||
runs through a stabilization rail (3.8.50 → 3.8.59), an LTS anchor (**3.9.0**), and the
|
||||
modular **4.0**.
|
||||
|
||||
## The rail at a glance
|
||||
|
||||
```
|
||||
3.8.50 ─ 3.8.54 PREPARE non-breaking structural prep (all PRs welcome)
|
||||
3.8.55 ─ 3.8.59 VALIDATE stabilization (fixes / docs / i18n / providers only)
|
||||
3.9.0 LTS stable/v3 branch · long-term support line
|
||||
4.0.0-nightly/rc MODULAR core + SDK + modules + marketplace (develop branch)
|
||||
4.0.0 GA latest switches to v4 · v3 stays supported as LTS
|
||||
```
|
||||
|
||||
## Phase 1 — Preparation (3.8.50 → 3.8.54)
|
||||
|
||||
Non-breaking structural work that de-risks the modular split. Every version closes with a
|
||||
mandatory quality-gate battery before new merges open.
|
||||
|
||||
| Version | Focus |
|
||||
| --- | --- |
|
||||
| 3.8.50 | CI safety net on release branches · dead-code cleanup · community-reported catalog/topology bug fixes · contributor "golden path" guide |
|
||||
| 3.8.51 | Executor registry (in-place) · end-to-end provider-journey contract test becomes a CI gate · official scoped-test dev loop · CI lane consolidation (shared install/setup across gate jobs, #8084) |
|
||||
| 3.8.52 | `combo.ts` decomposition · routing-strategy registry · unified model-catalog contract for `/v1/models` · one CI policy for PRs to `release/**` and `main` (#8084) |
|
||||
| 3.8.53 | `chatCore.ts` decomposition · headless mode (`OMNIROUTE_HEADLESS=1`) · local candidate build/promote loop |
|
||||
| 3.8.54 | Release infrastructure (dormant): channels, labels, PR templates, merge queue · full-regression authority moves to the merge queue once TIA shadow evidence clears (#8084) · public feature-freeze announcement |
|
||||
|
||||
## Phase 2 — Validation (3.8.55 → 3.8.59)
|
||||
|
||||
**External feature PRs pause here** (they get the `v4-feature` label and are re-targeted to
|
||||
the v4 channel when it opens). Fixes, docs, i18n, and provider updates keep flowing.
|
||||
|
||||
| Version | Focus |
|
||||
| --- | --- |
|
||||
| 3.8.55 | Characterization tests for every extraction candidate · coupling re-measurement |
|
||||
| 3.8.56 | Extended canary · performance baselines (heap, TTFB, build) |
|
||||
| 3.8.57 | Security & compliance sweep · publish provenance (OIDC) rehearsal |
|
||||
| 3.8.58 | Full dry-run of the 3.9.0 cut (branches, channels, forward-port) — includes the PR preview-artifact + build-once promotion rehearsal (#8084) |
|
||||
| 3.8.59 | Final freeze · full-suite audit · GO/NO-GO |
|
||||
|
||||
## Phase 3 — v3.9.0 LTS
|
||||
|
||||
After 3.8.59 the next version is **3.9.0** (there is no 3.8.60). It creates the long-lived
|
||||
branch model:
|
||||
|
||||
- **`stable/v3`** — the LTS line (3.9.x). Receives fixes, security patches, and provider
|
||||
updates. `npm install omniroute` (aka `latest`) stays on v3 during the whole v4 cycle.
|
||||
- **`develop`** — v4 development, published as `4.0.0-nightly.*`.
|
||||
- **`main`** — v4 release candidates (`next`) and, eventually, GA.
|
||||
- Fixes merged to `stable/v3` are automatically forward-ported to `develop` with full
|
||||
contributor credit (`Co-authored-by`).
|
||||
|
||||
New features land in the v4 channel. The LTS line is stability-first.
|
||||
|
||||
## Phase 4 — v4.0: the modular platform
|
||||
|
||||
The monolith is intentionally disassembled on `develop`:
|
||||
|
||||
- **`@omniroute/core`** (npm name stays `omniroute`) — just the engine: `/v1/*`, routing,
|
||||
combo/fallback, providers.
|
||||
- **`@omniroute/sdk`** — one typed contract: hooks, extension points, two-phase lifecycle,
|
||||
UI contributions. The five extension systems that exist today (plugins, CLI plugins,
|
||||
skills, MCP tools, A2A skills) collapse into one declarative manifest.
|
||||
- **Modules** (`@omniroute/mod-*`) — cloud agents, traffic inspection (MITM), evals,
|
||||
webhooks, memory, guardrails, observability and more move out of the core, each with its
|
||||
own version and lifecycle.
|
||||
- **Providers as plugins** — adding a provider stops touching the core.
|
||||
- **Marketplace** — one-click install with verified integrity (hash pinning, signing,
|
||||
sandbox). Free in v1; a paid tier later with revenue share for creators.
|
||||
- Ships as `4.0.0-nightly.*` → `4.0.0-rc.N` (soak in production) → **4.0.0 GA**, when
|
||||
`latest` switches to v4 and v3 enters its announced LTS support window.
|
||||
|
||||
**The core is MIT and free, forever.**
|
||||
|
||||
## For contributors
|
||||
|
||||
| You are sending... | Target today | From 3.8.55 | After 3.9.0 |
|
||||
| --- | --- | --- | --- |
|
||||
| Bug fix / security | active `release/v3.8.x` | same | `stable/v3` |
|
||||
| Provider update | active `release/v3.8.x` | same | `stable/v3` |
|
||||
| Docs / i18n | active `release/v3.8.x` | same | `stable/v3` |
|
||||
| New feature | active `release/v3.8.x` | held with `v4-feature` label | `develop` (v4) |
|
||||
|
||||
See `CONTRIBUTING.md` for the golden path per change type.
|
||||
42
SECURITY.md
42
SECURITY.md
@@ -42,13 +42,13 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
|
||||
| **API Key Auth** | HMAC-signed keys with CRC validation |
|
||||
| **OAuth 2.0 + PKCE** | Provider-specific browser/device OAuth uses PKCE where supported; import-only Devin credentials are handled separately. |
|
||||
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
|
||||
| **Token Refresh** | Automatic OAuth token refresh before expiry |
|
||||
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
|
||||
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` |
|
||||
| **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS |
|
||||
| **MCP Scopes** | 32 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
|
||||
### 🛡️ Encryption at Rest
|
||||
|
||||
@@ -77,28 +77,21 @@ Custom guardrails register via `registerGuardrail(new MyGuardrail())`. The model
|
||||
|
||||
### 🧠 Prompt Injection Guard
|
||||
|
||||
Best-effort heuristic middleware that detects prompt injection patterns in LLM requests.
|
||||
**Not a complete prompt-injection firewall** — can produce false positives (benign
|
||||
persona/RPG prompts) and false negatives (leetspeak, spacing, non-English patterns).
|
||||
Middleware that detects and blocks prompt injection attacks in LLM requests:
|
||||
|
||||
| Pattern Type | Severity | Example |
|
||||
| ------------------- | -------- | ---------------------------------------------- |
|
||||
| System Override | High | "ignore all previous instructions" |
|
||||
| Role Hijack | Medium | "you are now DAN, you can do anything" |
|
||||
| Delimiter Injection | High | Encoded separators to break context boundaries |
|
||||
| DAN/Jailbreak | Medium | Known jailbreak prompt patterns |
|
||||
| Instruction Leak | High | "show me your system prompt" |
|
||||
| Encoding Evasion | Medium | base64/rot13/hex decode + instruction keywords |
|
||||
|
||||
Only **High** severity detections are blocked in `block` mode. Medium-severity
|
||||
families are logged but never blocked by `sanitizeRequest`.
|
||||
| Role Hijack | High | "you are now DAN, you can do anything" |
|
||||
| Delimiter Injection | Medium | Encoded separators to break context boundaries |
|
||||
| DAN/Jailbreak | High | Known jailbreak prompt patterns |
|
||||
| Instruction Leak | Medium | "show me your system prompt" |
|
||||
|
||||
Configure via dashboard (Settings → Security) or `.env`:
|
||||
|
||||
```env
|
||||
INPUT_SANITIZER_ENABLED=true
|
||||
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text)
|
||||
INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
|
||||
INPUT_SANITIZER_MODE=block # warn | block | redact
|
||||
```
|
||||
|
||||
### 🔒 PII Redaction
|
||||
@@ -115,20 +108,19 @@ Automatic detection and optional redaction of personally identifiable informatio
|
||||
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
|
||||
|
||||
```env
|
||||
PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE
|
||||
PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients
|
||||
PII_REDACTION_ENABLED=true
|
||||
```
|
||||
|
||||
### 🌐 Network Security
|
||||
|
||||
| Feature | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| **CORS** | Explicit cross-origin allowlist (`CORS_ALLOWED_ORIGINS`; legacy `CORS_ORIGIN`) |
|
||||
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
|
||||
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
|
||||
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
|
||||
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
|
||||
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
|
||||
| Feature | Description |
|
||||
| ------------------------ | ---------------------------------------------------------------- |
|
||||
| **CORS** | Configurable origin control (`CORS_ORIGIN` env var, default `*`) |
|
||||
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
|
||||
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
|
||||
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
|
||||
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
|
||||
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
|
||||
|
||||
### 🔌 Resilience & Availability
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## codex-chatgpt-web
|
||||
|
||||
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from
|
||||
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), commit
|
||||
`55592fca0ba19a27f1b769cec8fff61ff340a785`.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 codex-chatgpt-web contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,70 +0,0 @@
|
||||
# bin/_ops-common.sh — shared helpers for the OmniRoute ops runbook scripts.
|
||||
#
|
||||
# Sourced (not executed) by rollback.sh / snapshot-data.sh / restore-data.sh /
|
||||
# restore-policies.sh / cold-start-bench.sh — the self-hoster incident-recovery
|
||||
# and cold-start ops tooling. Each script documents its own contract via --help.
|
||||
#
|
||||
# Path resolution mirrors the app (src/lib/db/core.ts): the SQLite store is
|
||||
# $DATA_DIR/storage.sqlite and managed backups go to $DATA_DIR/db_backups
|
||||
# (overridable via DB_BACKUPS_DIR), so snapshots created here are interchangeable
|
||||
# with the ones the server writes on migrations.
|
||||
|
||||
# Recompute the data-dir-derived paths. Called once on source, and again by
|
||||
# scripts that accept a --data-dir override.
|
||||
ops_set_data_dir() {
|
||||
OMNIROUTE_DATA_DIR="$1"
|
||||
OMNIROUTE_SQLITE="${OMNIROUTE_DATA_DIR}/storage.sqlite"
|
||||
OMNIROUTE_BACKUPS_DIR="${DB_BACKUPS_DIR:-${OMNIROUTE_DATA_DIR}/db_backups}"
|
||||
}
|
||||
ops_set_data_dir "${DATA_DIR:-$HOME/.omniroute}"
|
||||
|
||||
ops_log() { printf '[%s] %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2; }
|
||||
ops_die() {
|
||||
printf '[%s] ERROR: %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ops_require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || ops_die "required command not found: $1"
|
||||
}
|
||||
|
||||
# ops_confirm "<prompt>" — return 0 to proceed. Honors ASSUME_YES=1 (set by the
|
||||
# --yes flag) and REFUSES a destructive action on a non-interactive stdin unless
|
||||
# ASSUME_YES is set, so an unattended/CI invocation can never silently destroy data.
|
||||
ops_confirm() {
|
||||
local prompt="$1" reply
|
||||
if [ "${ASSUME_YES:-0}" = "1" ]; then return 0; fi
|
||||
if [ ! -t 0 ]; then
|
||||
ops_die "refusing a destructive action without a TTY; pass --yes to proceed non-interactively"
|
||||
fi
|
||||
read -r -p "$prompt [y/N] " reply
|
||||
case "$reply" in
|
||||
[yY] | [yY][eE][sS]) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ops_find_snapshot <id> — resolve a snapshot identifier (a snapshot dir name,
|
||||
# a bare timestamp/sha, or an explicit path) to a directory containing
|
||||
# storage.sqlite. Echoes the resolved dir or dies.
|
||||
ops_find_snapshot() {
|
||||
local id="$1" cand
|
||||
[ -n "$id" ] || ops_die "snapshot id required (a timestamp/sha, dir name, or path)"
|
||||
for cand in \
|
||||
"$id" \
|
||||
"$id/" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/$id" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/snapshot_$id"; do
|
||||
if [ -f "${cand%/}/storage.sqlite" ]; then
|
||||
printf '%s\n' "${cand%/}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fall back to a prefix match against snapshot_* dirs (e.g. a short sha/date).
|
||||
if [ -d "$OMNIROUTE_BACKUPS_DIR" ]; then
|
||||
for cand in "$OMNIROUTE_BACKUPS_DIR"/snapshot_*"$id"*; do
|
||||
[ -f "$cand/storage.sqlite" ] && { printf '%s\n' "$cand"; return 0; }
|
||||
done
|
||||
fi
|
||||
ops_die "no snapshot matching '$id' under $OMNIROUTE_BACKUPS_DIR (run bin/snapshot-data.sh first)"
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* ESM path-alias resolver for global installs.
|
||||
*
|
||||
* Problem (#7791): when OmniRoute is installed via `npm i -g omniroute`, the
|
||||
* package files live under `node_modules/omniroute/`. tsx's tsconfig-path
|
||||
* resolution does not apply there, so specifiers like `@/shared/utils/featureFlags`
|
||||
* (declared in tsconfig.json `paths` as `@/* → ./src/*`) or
|
||||
* `@omniroute/open-sse/services/usage` fail with `ERR_MODULE_NOT_FOUND`.
|
||||
* The CLI crashes before any command can run.
|
||||
*
|
||||
* Fix: register a Node ESM `resolve` hook that rewrites alias specifiers to
|
||||
* absolute file URLs. Covers all tsconfig.json `paths` entries:
|
||||
* - `@/*` → `./src/*`
|
||||
* - `@omniroute/open-sse` → `./open-sse/index.ts`
|
||||
* - `@omniroute/open-sse/*` → `./open-sse/*`
|
||||
* The hook runs after tsx so `.ts` extensions are already handled, and only
|
||||
* intercepts matched prefixes — everything else falls through to Node's
|
||||
* default resolver.
|
||||
*
|
||||
* Exposed as pure functions so the mapping logic is unit-testable without a
|
||||
* running module loader.
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join, relative, isAbsolute } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Alias mapping table — mirrors tsconfig.json `paths`.
|
||||
* Processed top-to-bottom; first matching prefix wins.
|
||||
*
|
||||
* Each entry:
|
||||
* prefix — specifier prefix to match (e.g. `"@/"`, `"@omniroute/open-sse/"`)
|
||||
* target — directory name under the package root (e.g. `"src"`, `"open-sse"`)
|
||||
* exact — if true, the prefix also matches when the specifier equals the
|
||||
* prefix *without* a trailing slash (e.g. `@omniroute/open-sse` →
|
||||
* `<root>/open-sse/index.ts`).
|
||||
*
|
||||
* Exported for tests/consumers.
|
||||
*/
|
||||
export const ALIAS_MAP = [
|
||||
{ prefix: "@/", target: "src", exact: false },
|
||||
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
|
||||
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
|
||||
];
|
||||
|
||||
/** @deprecated Use ALIAS_MAP instead. Kept for backward compat. */
|
||||
export const ALIAS_PREFIX = "@/";
|
||||
|
||||
// This file is ESM (no CJS __dirname global) — derive it from import.meta.url
|
||||
// so the pathToFileURL(join(__dirname, ...)) call below resolves correctly
|
||||
// regardless of the caller's cwd.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Resolve an alias specifier to an absolute file URL.
|
||||
*
|
||||
* Rules mirror tsconfig.json `paths` via `ALIAS_MAP`:
|
||||
* "@/..." → <root>/src/...
|
||||
* "@omniroute/open-sse/..." → <root>/open-sse/...
|
||||
* "@omniroute/open-sse" → <root>/open-sse/index.*
|
||||
*
|
||||
* - Strips the matched alias prefix and joins the remainder against the
|
||||
* corresponding target directory.
|
||||
* - Probes the underlying filesystem for the actual source file: the specifier
|
||||
* itself, then with common source extensions (`.ts`, `.tsx`, `.js`, `.mjs`,
|
||||
* `.cjs`, `.json`), then `<dir>/index.*`. Returns the first existing match
|
||||
* as a `file://` URL.
|
||||
* - Returns `null` for specifiers that do not match any alias, for malformed
|
||||
* escapes, for path-traversal attempts, or when no corresponding source
|
||||
* file exists on disk. The caller treats `null` as "defer to the default
|
||||
* resolver".
|
||||
*
|
||||
* @param {string} specifier Module specifier from an `import` statement.
|
||||
* @param {string} root Absolute path to the package root.
|
||||
* @returns {string|null} Absolute `file://` URL, or `null` when unresolved.
|
||||
*/
|
||||
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
|
||||
|
||||
export function resolveAlias(specifier, root) {
|
||||
if (typeof specifier !== "string" || !root || typeof root !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the first matching alias entry (top-to-bottom order).
|
||||
let matchedEntry = null;
|
||||
let rest = null;
|
||||
for (const entry of ALIAS_MAP) {
|
||||
if (specifier.startsWith(entry.prefix)) {
|
||||
// For non-exact entries, require at least one char after the prefix
|
||||
// to avoid matching bare "@/" as "nothing".
|
||||
const after = specifier.slice(entry.prefix.length);
|
||||
if (after.length === 0 && !entry.exact) continue;
|
||||
matchedEntry = entry;
|
||||
rest = after;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matchedEntry) return null;
|
||||
|
||||
const targetDir = join(root, matchedEntry.target);
|
||||
|
||||
// Exact match (e.g. `@omniroute/open-sse` with no trailing path) →
|
||||
// resolve to `<target>/index.*`.
|
||||
if (rest === "" || rest === undefined) {
|
||||
return probeIndex(targetDir);
|
||||
}
|
||||
|
||||
// Guard against absolute-ish escapes (`@//etc/passwd`, `@/\x00`).
|
||||
if (rest.startsWith("/") || rest.startsWith("\\")) {
|
||||
return null;
|
||||
}
|
||||
// Guard against path-traversal escapes (`@/../../../etc/hostname`).
|
||||
const segments = rest.split(/[\\\/]+/);
|
||||
if (segments.includes("..")) {
|
||||
return null;
|
||||
}
|
||||
const base = join(targetDir, rest);
|
||||
if (!isWithinRoot(targetDir, base)) {
|
||||
return null;
|
||||
}
|
||||
return probeFile(base) ?? probeIndex(base) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a bare path and its extension variants. Returns the first existing
|
||||
* match as a `file://` URL, or `null`.
|
||||
*/
|
||||
function probeFile(base) {
|
||||
// Try extension variants first — a bare `base` that happens to be a directory
|
||||
// would match existsSync() but should NOT be returned as a file URL (the
|
||||
// caller expects a file, not a directory). Extension-probing avoids this
|
||||
// false positive (e.g. `usage` vs `usage.ts` vs `usage/`).
|
||||
for (const ext of SOURCE_EXTENSIONS) {
|
||||
const candidate = base + ext;
|
||||
if (existsSync(candidate)) return pathToFileURL(candidate).href;
|
||||
}
|
||||
// Only accept the bare path if it is NOT a directory.
|
||||
if (existsSync(base)) {
|
||||
try {
|
||||
const st = statSync(base);
|
||||
if (!st.isDirectory()) return pathToFileURL(base).href;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a directory for an `index.*` entry. Returns the first existing
|
||||
* match as a `file://` URL, or `null`.
|
||||
*/
|
||||
function probeIndex(dir) {
|
||||
const indexBase = join(dir, "index");
|
||||
for (const ext of SOURCE_EXTENSIONS) {
|
||||
const candidate = indexBase + ext;
|
||||
if (existsSync(candidate)) return pathToFileURL(candidate).href;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `candidate` resolves to a location inside `ancestor` (or is
|
||||
* `ancestor` itself). Used as a second, path-normalization-aware layer of
|
||||
* defense against traversal beyond the literal `..` segment check above.
|
||||
*
|
||||
* @param {string} ancestor
|
||||
* @param {string} candidate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWithinRoot(ancestor, candidate) {
|
||||
const rel = relative(ancestor, candidate);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the ESM resolve hook for the current process. Safe to call multiple
|
||||
* times — subsequent calls are no-ops once the hook is installed.
|
||||
*
|
||||
* Uses Node's stable `module.register()` API (available since Node 20.6,
|
||||
* required Node 22+ here). The hook runs in a worker thread but only reads the
|
||||
* captured `root`, so no shared-state hazards.
|
||||
*
|
||||
* @param {string} root Absolute path to the package root.
|
||||
* @returns {Promise<boolean>} Resolves `true` once registered (or if already
|
||||
* registered), `false` on environments where `module.register` is unavailable.
|
||||
*/
|
||||
let _registered = false;
|
||||
export async function registerAliasResolver(root) {
|
||||
// Validate input FIRST, before the _registered short-circuit. Otherwise the
|
||||
// second call in the same process (e.g. a test suite that already registered
|
||||
// once) would silently return `true` for invalid input instead of rejecting,
|
||||
// masking programmer errors. Input validation must be unconditional.
|
||||
if (!root || typeof root !== "string") {
|
||||
throw new TypeError("registerAliasResolver: root must be a non-empty string");
|
||||
}
|
||||
if (_registered) return true;
|
||||
// if the directory does not exist we would only mask a real misconfiguration
|
||||
// by installing a hook that rewrites to nowhere.
|
||||
if (!existsSync(join(root, "src"))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { register } = await import("node:module");
|
||||
// #7808: load the hook from a real file on disk via pathToFileURL() instead
|
||||
// of building a `data:text/javascript,...` URL dynamically. CodeQL's
|
||||
// `js/incomplete-url-substring-sanitization` flagged the interpolated
|
||||
// `new URL(...)` call; a file URL produced by pathToFileURL() is a trusted,
|
||||
// fully-parsed URL — no sanitization ambiguity. The hook source lives in
|
||||
// `bin/aliasResolverHook.mjs` (sibling of this file), shipped via
|
||||
// package.json "files": ["bin/"].
|
||||
const hookPath = join(__dirname, "aliasResolverHook.mjs");
|
||||
const hookUrl = pathToFileURL(hookPath);
|
||||
register(hookUrl, { data: { root } });
|
||||
_registered = true;
|
||||
return true;
|
||||
} catch {
|
||||
// Older Node or sandboxed env without module.register — fall back to the
|
||||
// default resolver. The bug will resurface only in the exact global-install
|
||||
// scenario, which is what we explicitly patched; other entry points still
|
||||
// work because they import via relative paths.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// #7808: the ESM loader hook source now lives in `bin/aliasResolverHook.mjs`,
|
||||
// loaded via `pathToFileURL()` above. The previous inline `HOOK_SOURCE` template
|
||||
// literal was removed because its `new URL(\`data:text/javascript,...\`)` wrapper
|
||||
// triggered CodeQL `js/incomplete-url-substring-sanitization`. The hook logic
|
||||
// itself is unchanged — see aliasResolverHook.mjs for the resolver behaviour.
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* ESM loader hook for path-alias resolution (#7791 + #7808).
|
||||
*
|
||||
* This file runs in Node's loader worker thread after being registered via
|
||||
* `module.register(url, data)` from `bin/aliasResolver.mjs`. It MUST NOT import
|
||||
* anything from the parent module — all inputs arrive through `initialize(data)`.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Rewrites alias specifiers to absolute filesystem paths, mirroring
|
||||
* tsconfig.json `paths`:
|
||||
* - `@/*` → <root>/src/*
|
||||
* - `@omniroute/open-sse` → <root>/open-sse/index.*
|
||||
* - `@omniroute/open-sse/*` → <root>/open-sse/*
|
||||
* - Probes the usual source extensions (`.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`,
|
||||
* `.json`) plus `index.*` for directory imports.
|
||||
* - Returns `shortCircuit: true` only when a candidate file exists on disk;
|
||||
* otherwise delegates to the next resolver (tsx/Node) so unrelated imports
|
||||
* and legitimate "module not found" errors pass through unchanged.
|
||||
*
|
||||
* Why a separate file instead of an inline `data:` URL?
|
||||
* CodeQL's `js/incomplete-url-substring-sanitization` flags dynamic `new URL(...)`
|
||||
* construction with interpolated strings. A real file URL produced by
|
||||
* `pathToFileURL()` is a trusted, fully-parsed URL — no sanitization ambiguity.
|
||||
*/
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { join, relative, isAbsolute } from "node:path";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
|
||||
let ROOT = "";
|
||||
|
||||
export function initialize(data) {
|
||||
ROOT = (data && data.root) || "";
|
||||
}
|
||||
|
||||
const EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
|
||||
|
||||
/**
|
||||
* Alias prefix table — mirrors ALIAS_MAP in aliasResolver.mjs and
|
||||
* tsconfig.json `paths`. Processed top-to-bottom; first match wins.
|
||||
*
|
||||
* @type {Array<{prefix: string, target: string, exact: boolean}>}
|
||||
*/
|
||||
const ALIAS_TABLE = [
|
||||
{ prefix: "@/", target: "src", exact: false },
|
||||
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
|
||||
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
|
||||
];
|
||||
|
||||
function tryResolveAliasFsPath(specifier) {
|
||||
if (!ROOT || typeof specifier !== "string") return null;
|
||||
|
||||
// Find the first matching alias entry.
|
||||
let matchedEntry = null;
|
||||
let rest = null;
|
||||
for (const entry of ALIAS_TABLE) {
|
||||
if (specifier.startsWith(entry.prefix)) {
|
||||
const after = specifier.slice(entry.prefix.length);
|
||||
if (after.length === 0 && !entry.exact) continue;
|
||||
matchedEntry = entry;
|
||||
rest = after;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matchedEntry) return null;
|
||||
|
||||
const targetDir = join(ROOT, matchedEntry.target);
|
||||
|
||||
// Exact match (e.g. `@omniroute/open-sse`) → resolve to `<target>/index.*`.
|
||||
if (rest === "" || rest === undefined) {
|
||||
return probeIndex(targetDir);
|
||||
}
|
||||
|
||||
// Guard against absolute-ish escapes.
|
||||
if (rest.startsWith("/") || rest.startsWith("\\")) return null;
|
||||
// Guard against path-traversal escapes.
|
||||
const segments = rest.split(/[\\\/]+/);
|
||||
if (segments.includes("..")) return null;
|
||||
|
||||
const base = join(targetDir, rest);
|
||||
if (!isWithinRoot(targetDir, base)) return null;
|
||||
return probeFile(base) ?? probeIndex(base) ?? null;
|
||||
}
|
||||
|
||||
function probeFile(base) {
|
||||
// Extension variants first — avoids matching a bare directory name.
|
||||
for (const ext of EXTENSIONS) {
|
||||
const candidate = base + ext;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
if (existsSync(base)) {
|
||||
try {
|
||||
const st = statSync(base);
|
||||
if (!st.isDirectory()) return base;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function probeIndex(dir) {
|
||||
const indexBase = join(dir, "index");
|
||||
for (const ext of EXTENSIONS) {
|
||||
const candidate = indexBase + ext;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `candidate` resolves to a location inside `ancestor` (or is
|
||||
* `ancestor` itself). Path-normalization-aware defense against traversal.
|
||||
*/
|
||||
function isWithinRoot(ancestor, candidate) {
|
||||
const rel = relative(ancestor, candidate);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
const fsPath = tryResolveAliasFsPath(specifier);
|
||||
if (fsPath) {
|
||||
return {
|
||||
url: pathToFileURL(fsPath).href,
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(here, "..");
|
||||
|
||||
export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSync) {
|
||||
const candidates = [
|
||||
join(
|
||||
rootDir,
|
||||
"dist",
|
||||
"open-sse",
|
||||
"vendor",
|
||||
"codex-chatgpt-web",
|
||||
"adapters",
|
||||
"chatgpt-web",
|
||||
"mcp-server.js"
|
||||
),
|
||||
join(
|
||||
rootDir,
|
||||
"open-sse",
|
||||
"vendor",
|
||||
"codex-chatgpt-web",
|
||||
"adapters",
|
||||
"chatgpt-web",
|
||||
"mcp-server.ts"
|
||||
),
|
||||
];
|
||||
return candidates.find((candidate) => exists(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) {
|
||||
const socketIndex = args.indexOf("--broker-socket");
|
||||
const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
|
||||
if (!brokerSocketPath) throw new Error("--broker-socket is required");
|
||||
const entry = resolveChatGptWebCodexMcpEntry(rootDir);
|
||||
if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found");
|
||||
if (entry.endsWith(".ts")) {
|
||||
const { register } = await import("node:module");
|
||||
register("tsx/esm", pathToFileURL(`${rootDir}/`));
|
||||
}
|
||||
const module = await import(pathToFileURL(entry).href);
|
||||
await module.runChatGptMcpServer({ brokerSocketPath });
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
startChatGptWebCodexMcp().catch((error) => {
|
||||
console.error(
|
||||
`ChatGPT Web (Codex) MCP konnte nicht gestartet werden: ${error?.message || error}`
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const RETRY_DEFAULTS = {
|
||||
|
||||
- Every user-facing string goes through `t("module.key", vars)`.
|
||||
- Catalogs live in `bin/cli/locales/{locale}.json` (nested objects).
|
||||
43 files ship out-of-the-box: `en`, `pt-BR`, and 41 additional locales.
|
||||
42 files ship out-of-the-box: `en`, `pt-BR`, and 40 additional locales.
|
||||
11 locales are scaffold-only (empty `{}`); all keys fall back to `en` automatically.
|
||||
- Detection order: `--lang` flag → `OMNIROUTE_LANG` env → `LC_ALL` → `LC_MESSAGES` → `LANG` → `en`.
|
||||
- Locale persisted via `config lang set <code>` — saves `OMNIROUTE_LANG` to `~/.omniroute/.env`.
|
||||
|
||||
@@ -22,9 +22,9 @@ bin/cli/
|
||||
├── provider-test.mjs ← testProviderApiKey()
|
||||
├── settings-store.mjs ← DB CRUD for key_value settings
|
||||
├── locales/
|
||||
│ ├── en.json ← English strings (source of truth, 43 locales)
|
||||
│ ├── en.json ← English strings (source of truth, 42+ locales)
|
||||
│ ├── pt-BR.json ← Portuguese (Brazil) — fully translated
|
||||
│ └── {locale}.json ← 42 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
│ └── {locale}.json ← 40 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
├── scripts/
|
||||
│ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
|
||||
└── commands/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user