mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
Compare commits
5 Commits
docs/audit
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca23eed77c | ||
|
|
5f0a394091 | ||
|
|
918fba5e39 | ||
|
|
026e1cadaa | ||
|
|
b090b601a5 |
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
|
||||
|
||||
@@ -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
|
||||
433
.env.example
433
.env.example
@@ -67,14 +67,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Used by: src/shared/utils/rateLimiter.ts
|
||||
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# Host interface docker-compose publishes the Redis sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
|
||||
# `requirepass`, and app containers reach it over the compose network
|
||||
# (redis:6379) — the published port is only for host-side tooling. Setting this
|
||||
# to 0.0.0.0 exposes an unauthenticated Redis to your whole LAN.
|
||||
# REDIS_BIND_HOST=127.0.0.1
|
||||
# Host port for the compose Redis sidecar. Default: 6379.
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
@@ -109,17 +101,6 @@ PORT=20128
|
||||
# stay consistent without relying on window.location.origin alone:
|
||||
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
|
||||
|
||||
# Opt-in iframe embedding of the OmniRoute HTML pages (issue #10273). Off by default:
|
||||
# every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`, which is why the
|
||||
# VS Code Simple Browser (used by the OmniCopilot extension's "Open Dashboard → editor"
|
||||
# mode) renders a blank tab. Set this to `vscode` to switch the HTML pages — dashboard,
|
||||
# login, docs, landing — to `frame-ancestors 'self' vscode-webview:` and drop
|
||||
# X-Frame-Options for them (XFO cannot express a custom scheme). The API surface
|
||||
# (/api, /v1, /v1beta, /a2a, /healthz and the root-level aliases) keeps the strict
|
||||
# headers regardless. Only `vscode` is recognised; `1`/`true` do NOT enable it.
|
||||
# Used by: next.config.mjs via scripts/build/dashboardEmbed.mjs — build-time, rebuild after changing.
|
||||
# DASHBOARD_ALLOW_EMBED=vscode
|
||||
|
||||
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
|
||||
# Used by: src/lib/runtime/ports.ts — overrides PORT for each service.
|
||||
# API_PORT=20129
|
||||
@@ -356,30 +337,14 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64
|
||||
# Conservative string-size token estimate that classifies a request as heavyweight. Default 32000.
|
||||
# OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000
|
||||
# Optional opt-in hard message-count cap; excess receives compact-required 413 before
|
||||
# compression can run. Unset/0 (the default) means no history cap: heap growth is bounded
|
||||
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
|
||||
# value only on memory-constrained deployments that need a hard ceiling.
|
||||
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
|
||||
# How long a heavy request waits for heavyweight capacity before a retryable 503.
|
||||
# A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant.
|
||||
# Default 2000 (2s).
|
||||
# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=2000
|
||||
# Queued-bytes budget for the admission wait: bounds total buffered body bytes parked
|
||||
# per lane so the wait cannot amplify the heap (#4380). Over-budget waits 503 immediately.
|
||||
# Default 4194304 (4 MB).
|
||||
# OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES=4194304
|
||||
# Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. Default 60000 (60s).
|
||||
# OMNIROUTE_CHAT_VIRTUAL_TTL_MS=60000
|
||||
# Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). Default 64.
|
||||
# OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS=64
|
||||
# Hard message-count cap; excess receives compact-required 413. Default 800.
|
||||
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800
|
||||
|
||||
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
|
||||
# (#5152). Past this the upstream reader is cancelled and the request fails fast
|
||||
# instead of growing an unbounded string until the V8 heap is exhausted.
|
||||
# Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts
|
||||
# Default: 67108864 (64 MB)
|
||||
# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768
|
||||
# OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864
|
||||
|
||||
# CORS configuration — controls which cross-origin browser clients can call the API.
|
||||
@@ -480,13 +445,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Default: false
|
||||
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false
|
||||
|
||||
# Per-model concurrency cap for round-robin combos (#9100).
|
||||
# Used by: open-sse/services/comboConfig.ts — the round-robin combo semaphore
|
||||
# was hard-capped at 3 concurrent requests per model with no override, which
|
||||
# serialized higher-concurrency traffic behind that cap.
|
||||
# Validated to >= 1, clamped to <= 32. | Default: 3
|
||||
# COMBO_CONCURRENCY_PER_MODEL=3
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 7. URLS & CLOUD SYNC
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -667,9 +625,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google).
|
||||
# Used by: open-sse/executors — replaces Node.js default TLS fingerprint.
|
||||
# ENABLE_TLS_FINGERPRINT=true
|
||||
# New proxied TLS routing requires an explicit, comma-separated provider allowlist.
|
||||
# Direct TLS keeps its legacy behavior when this is unset.
|
||||
# TLS_FINGERPRINT_PROVIDERS=codex,openai
|
||||
|
||||
# Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors.
|
||||
# Only enable for local debugging or trusted MITM/corporate proxy environments.
|
||||
@@ -823,16 +778,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Disable the proactive recovery scheduler entirely (default: false).
|
||||
# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false
|
||||
|
||||
# Proactive Claude warmup scheduler (#8848): fires a trivial request to opted-in
|
||||
# OAuth connections on a cron schedule (America/Los_Angeles) so accounts do not
|
||||
# hit the 5-hour sliding window cold. Off by default — set ENABLED=1 and flip
|
||||
# per-connection flags in settings.claudeWarmup.connections to activate.
|
||||
# Used by: src/lib/warmupScheduler.ts.
|
||||
# OMNIROUTE_WARMUP_ENABLED=false
|
||||
# OMNIROUTE_WARMUP_CRON="0 7 * * *"
|
||||
# OMNIROUTE_WARMUP_CONCURRENCY=3
|
||||
# OMNIROUTE_WARMUP_MODEL=
|
||||
|
||||
# Background job interval for budget reset checks (ms). Default: 600000 (10m).
|
||||
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
|
||||
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
|
||||
@@ -897,12 +842,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only).
|
||||
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2.
|
||||
#COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2
|
||||
# CCR durable block store (#9061). The in-memory store loses blocks to LRU eviction, the TTL, a
|
||||
# restart, or a retrieve landing on another instance, while the model is told it can retrieve them
|
||||
# verbatim. Set to false to keep blocks in memory only, at the cost of that promise. Blocks over
|
||||
# 512KB and cloud runtimes are memory-only regardless.
|
||||
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: true.
|
||||
#COMPRESSION_CCR_DURABLE_STORE=true
|
||||
# T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen
|
||||
# >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even
|
||||
# for providers the static cache-aware heuristic does not recognize (freeze = preserve, never
|
||||
@@ -988,17 +927,18 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s).
|
||||
# THEOLDLLM_NAV_TIMEOUT_MS=30000
|
||||
|
||||
# ── Gemini / Antigravity (Google-based) ──
|
||||
# These providers ship public OAuth client_id/secret values embedded in their
|
||||
# public CLIs. Defaults are baked into the code via
|
||||
# open-sse/utils/publicCreds.ts — leave the env vars unset to use them. Only
|
||||
# set these if you registered your own OAuth app and want to use your own
|
||||
# credentials instead. See docs/security/PUBLIC_CREDS.md for context.
|
||||
# ── Gemini / Antigravity / Windsurf (all Google-based) ──
|
||||
# These providers ship public OAuth client_id/secret values (or Firebase Web
|
||||
# keys) embedded in their public CLIs/binaries. Defaults are baked into the
|
||||
# code via open-sse/utils/publicCreds.ts — leave the env vars unset to use
|
||||
# them. Only set these if you registered your own OAuth app and want to use
|
||||
# your own credentials instead. See docs/security/PUBLIC_CREDS.md for context.
|
||||
#
|
||||
# GEMINI_OAUTH_CLIENT_ID=
|
||||
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
# WINDSURF_FIREBASE_API_KEY=
|
||||
|
||||
# ── Kimi Coding (Moonshot) ──
|
||||
KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098
|
||||
@@ -1086,17 +1026,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# VISION_BRIDGE_BASE_URL=
|
||||
# VISION_BRIDGE_API_KEY=
|
||||
|
||||
# ── Raycast Pro (local auto-import) ──
|
||||
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
|
||||
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
|
||||
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
|
||||
# vars are optional manual overrides used by open-sse/services/raycast.ts
|
||||
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
|
||||
# RAYCAST_BEARER_TOKEN=
|
||||
# RAYCAST_DEVICE_ID=
|
||||
# RAYCAST_AID=
|
||||
# RAYCAST_SIG_SECRET=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1183,12 +1112,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# Or enable for all providers at once:
|
||||
# CLI_COMPAT_ALL=1
|
||||
|
||||
# Allow the Antigravity request translator to skip its strict CLI request-signature
|
||||
# validation when the upstream refuses real signatures (debug/antiquated-CLI mode).
|
||||
# Default: real signatures enforced (unset) — signature bypass disabled.
|
||||
# Used by: open-sse/translator/request/openai-to-gemini.ts
|
||||
# ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS=0
|
||||
|
||||
# ── Kimi Coding CLI identity overrides ──
|
||||
# Used by: src/lib/oauth/providers/kimi-coding.ts — sent in OAuth + API headers.
|
||||
# Leave unset to use the captured defaults baked into the OmniRoute build.
|
||||
@@ -1243,24 +1166,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
||||
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
|
||||
|
||||
# ── Provider probe (credential validation / model discovery) ──
|
||||
# Timeout in ms for provider validationRead and modelsProbe presets.
|
||||
# Default: 8000 (was 5000). Raise it if slow endpoints (Cerebras, Cloudflare AI, Groq)
|
||||
# cause flapping between active/error in the dashboard.
|
||||
# Used by: src/shared/network/safeOutboundFetch.ts — centralized timeout resolution.
|
||||
# OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS=8000
|
||||
|
||||
# ── Proxy/relay fetch (connection pooling, #9158) ──
|
||||
# Used by: open-sse/utils/proxyFetch.ts.
|
||||
# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the
|
||||
# caller sees a relay-specific failure instead of a generic upstream timeout.
|
||||
# Capped at 29000ms so this timeout always fires first. Default: 25000 (25s).
|
||||
# OMNIROUTE_RELAY_FETCH_TIMEOUT_MS=25000
|
||||
|
||||
# Shared retry backoff (ms) for the direct/relay/proxy retry-once paths.
|
||||
# 0 = retry immediately. Default: 10.
|
||||
# OMNIROUTE_RETRY_BACKOFF_MS=10
|
||||
|
||||
# ── Firecrawl web-fetch executor ──
|
||||
# Point at a self-hosted Firecrawl instance (defaults to the public cloud API).
|
||||
# When set to a non-cloud base URL, the API key becomes optional.
|
||||
@@ -1322,14 +1227,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OMNIROUTE_BROWSER_POOL=on
|
||||
# WEB_COOKIE_USE_BROWSER=0
|
||||
|
||||
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
|
||||
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
|
||||
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
|
||||
# user can sign in interactively; the executable is auto-detected from common
|
||||
# install paths per OS. Set this to override that detection (e.g. a portable
|
||||
# install or a non-standard path) when auto-detection fails.
|
||||
# OMNIROUTE_LOGIN_BROWSER_PATH=
|
||||
|
||||
# ── Circuit breaker thresholds and reset windows ──
|
||||
# Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts.
|
||||
# Defaults match historical PROVIDER_PROFILES values (post-scaling for
|
||||
@@ -1342,28 +1239,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000
|
||||
|
||||
# ── Provider-level circuit breaker thresholds and cooldowns ──
|
||||
# Used by: open-sse/config/constants.ts (PROVIDER_PROFILES → accountFallback).
|
||||
# These control the provider-level fuse (entire provider cooldown after repeated
|
||||
# failures) — distinct from the per-key breaker above. Defaults match the
|
||||
# historical PROVIDER_PROFILES values. Raise to tolerate transient upstream
|
||||
# sheds without blacklisting the provider; lower to fail over faster.
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD=10
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS=900000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS=300000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD=5
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER=8
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT=2
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD=15
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS=1800000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS=600000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD=7
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER=4
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT=3
|
||||
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD=2
|
||||
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS=300000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS=60000
|
||||
|
||||
# ── Context-cache pin health gate ──
|
||||
# Used by: open-sse/services/combo.ts. When a context-cache pin points at a
|
||||
# provider that is durably unhealthy, the pin is dropped to allow failover.
|
||||
@@ -1463,10 +1338,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 100000
|
||||
# CALL_LOGS_TABLE_MAX_ROWS=100000
|
||||
|
||||
# Force detailed request logging on or off, overriding the dashboard setting.
|
||||
# Values: true | false | Default: unset (follow dashboard setting)
|
||||
# ENABLE_REQUEST_LOGS=false
|
||||
|
||||
# Maximum age for orphaned active request log entries before the in-memory
|
||||
# pending-request reaper removes them. Accepts milliseconds.
|
||||
# Default: 3600000 (1 hour)
|
||||
@@ -1474,7 +1345,7 @@ APP_LOG_TO_FILE=true
|
||||
|
||||
# Whether call log pipeline capture stores stream chunks when enabled in settings.
|
||||
# Only applies when call_log_pipeline_enabled=true.
|
||||
# Default: false (opt-in — saves disk: stream chunks are the biggest call-log artifact)
|
||||
# Default: true
|
||||
# CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=true
|
||||
|
||||
# Maximum call log artifact size for pipeline captures, in KB.
|
||||
@@ -1486,14 +1357,9 @@ APP_LOG_TO_FILE=true
|
||||
# bodies is retained in the database.
|
||||
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
|
||||
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=24 # Number of array items retained from tail (default: 24)
|
||||
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
|
||||
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
|
||||
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
|
||||
# {_truncated, messageCount, ...} summary instead of the full clone
|
||||
# (default: 1024 KB / 1MB). Raise this if the dashboard's "Full
|
||||
# Conversation" transcript panel shows a placeholder instead of the
|
||||
# actual messages for long agentic conversations.
|
||||
|
||||
# Maximum rows in the proxy_logs SQLite table.
|
||||
# Default: 100000
|
||||
@@ -1548,6 +1414,10 @@ APP_LOG_TO_FILE=true
|
||||
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
|
||||
# OMNIROUTE_PLUGIN_PATH=
|
||||
|
||||
# Allow plugins to request the 'exec' permission (spawn child processes from the
|
||||
# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only).
|
||||
# OMNIROUTE_PLUGINS_ALLOW_EXEC=0
|
||||
|
||||
# ── Prompt cache (system prompt deduplication) ──
|
||||
# Used by: open-sse/services — caches identical system prompts across requests.
|
||||
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
|
||||
@@ -1605,15 +1475,6 @@ APP_LOG_TO_FILE=true
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 19. MODEL SYNC (Dev)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Enable the models.dev capability sync. Default: false (opt-in only).
|
||||
# Also settable from Dashboard > Settings > AI. This variable wins over that
|
||||
# setting whenever it is set to anything non-empty, in either direction, so a
|
||||
# deployment can pin the sync on or off without depending on database state
|
||||
# surviving a rebuild. Leave it unset to let the dashboard toggle decide.
|
||||
# On: 1, true, yes or on (any casing). Any other value is off.
|
||||
# Used by: src/lib/modelsDevSync.ts
|
||||
# MODELS_DEV_SYNC_ENABLED=false
|
||||
|
||||
# Development-time model catalog sync interval in seconds.
|
||||
# Used by: src/lib/modelsDevSync.ts
|
||||
# Default: 86400 (24 hours)
|
||||
@@ -1630,31 +1491,12 @@ APP_LOG_TO_FILE=true
|
||||
# 20. PROVIDER-SPECIFIC SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Strict system-message-first providers ──
|
||||
# Comma-separated, case-insensitive provider ids that require the `system`
|
||||
# role message to be the first message (any later `system` message is
|
||||
# rejected with HTTP 400 by the upstream chat template) — the same
|
||||
# constraint documented for xiaomi-mimo/mimo (#6135, #7293). Extends the
|
||||
# built-in list without a source change; useful for self-hosted connections
|
||||
# in front of Qwen3.5+/3.6 or other strict-template backends.
|
||||
# Used by: src/lib/memory/injection.ts::systemMessageMustBeFirst
|
||||
# Default: unset (only xiaomi-mimo/mimo are flagged)
|
||||
# OMNIROUTE_STRICT_SYSTEM_PROVIDERS=coding-agent
|
||||
|
||||
# ── OpenRouter ──
|
||||
# OpenRouter model catalog cache TTL in ms.
|
||||
# Used by: src/lib/catalog/openrouterCatalog.ts
|
||||
# Default: 86400000 (24 hours)
|
||||
# OPENROUTER_CATALOG_TTL_MS=86400000
|
||||
|
||||
# Enrich the dashboard providers list with OpenRouter weekly ranking stats.
|
||||
# ON by default; set false to skip the background fetch entirely (#9324).
|
||||
# Used by: src/lib/catalog/openrouterProviderStats.ts
|
||||
# OPENROUTER_PROVIDER_STATS_ENABLED=true
|
||||
# Cache TTL for the OpenRouter provider stats snapshot, in ms.
|
||||
# Default: 86400000 (24 hours)
|
||||
# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000
|
||||
|
||||
# ── Model catalog response shape ──
|
||||
# Include display-friendly name fields in /v1/models responses.
|
||||
# Disable for clients that expect model IDs only.
|
||||
@@ -1669,32 +1511,12 @@ APP_LOG_TO_FILE=true
|
||||
# NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s)
|
||||
# NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s)
|
||||
|
||||
# ── Adobe Firefly (Image / Video Generation) ──
|
||||
# Optional absolute path to a system Chrome or Edge executable used for interactive sign-in
|
||||
# and off-screen risk-session renewal. Auto-detected when unset.
|
||||
# OMNIROUTE_LOGIN_BROWSER_PATH=
|
||||
# Browser renewal and durable session cache are enabled by default; set either to 0 to opt out.
|
||||
# ADOBE_FIREFLY_BROWSER_REFRESH=1
|
||||
# ADOBE_FIREFLY_SESSION_DISK=1
|
||||
# Minimum gap between generate submissions and extra gap after every third success (ms).
|
||||
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
|
||||
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
|
||||
# Base backoff after a transient 408 response (ms); five attempts maximum.
|
||||
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
|
||||
|
||||
# ── Microsoft Designer Web (Image Generation) ──
|
||||
# Polling config for the microsoft-designer-web submit-then-poll image job.
|
||||
# Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts
|
||||
# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s)
|
||||
# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s)
|
||||
|
||||
# ── Adobe Firefly (Image Upscale) ──
|
||||
# Base delay (ms) for the submit-retry exponential backoff when Adobe Firefly's
|
||||
# upscale job submission is rate-limited. Used by:
|
||||
# open-sse/services/adobeFireflyUpscale.ts::submitRetryDelayMs.
|
||||
# Default: 8000 (20 under NODE_ENV=test/VITEST/NODE_TEST_CONTEXT).
|
||||
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
|
||||
|
||||
# ── AWS Bedrock (Kiro / Audio) ──
|
||||
# Region used to construct AWS Bedrock endpoints. Used by:
|
||||
# src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts.
|
||||
@@ -1789,26 +1611,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
|
||||
# MUX_SERVICE_PORT=8322
|
||||
|
||||
# ── Dario embedded service ──
|
||||
# Override the host/port the embedded Dario (Claude Code subscription proxy)
|
||||
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
|
||||
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
|
||||
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
|
||||
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
|
||||
# open-sse/executors/dario.ts
|
||||
# DARIO_HOST=127.0.0.1
|
||||
# DARIO_PORT=3456
|
||||
|
||||
# ── Dario embedded service ──
|
||||
# Override the host/port the embedded Dario (Claude Code subscription proxy)
|
||||
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
|
||||
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
|
||||
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
|
||||
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
|
||||
# open-sse/executors/dario.ts
|
||||
# DARIO_HOST=127.0.0.1
|
||||
# DARIO_PORT=3456
|
||||
|
||||
# ── Local hostnames (Docker networking) ──
|
||||
# Comma-separated additional hostnames treated as "local" for provider routing.
|
||||
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
|
||||
@@ -1911,17 +1713,6 @@ APP_LOG_TO_FILE=true
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_MIDSTREAM_ENABLED=true
|
||||
|
||||
# Active-stream throughput watchdog (#9709). Detects streams that keep sending
|
||||
# heartbeats/chunks but produce too little useful assistant text. Separate from
|
||||
# STREAM_IDLE_TIMEOUT_MS (silence) and the hard upstream attempt deadline. OFF by
|
||||
# default. Tool-call/reasoning phases suspend judgement; post-commit streams are
|
||||
# never blindly replayed.
|
||||
# STREAM_THROUGHPUT_WATCHDOG_ENABLED=true
|
||||
# STREAM_THROUGHPUT_WATCHDOG_WARMUP_MS=30000
|
||||
# STREAM_THROUGHPUT_WATCHDOG_WINDOW_MS=30000
|
||||
# STREAM_THROUGHPUT_WATCHDOG_MIN_BYTES_PER_SECOND=4
|
||||
# STREAM_THROUGHPUT_WATCHDOG_MIN_USEFUL_BYTES=1
|
||||
|
||||
# Stagger interval (ms) between provider token healthchecks at startup.
|
||||
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
|
||||
# HEALTHCHECK_STAGGER_MS=3000
|
||||
@@ -1993,7 +1784,7 @@ APP_LOG_TO_FILE=true
|
||||
|
||||
# Log request shape (content-type + content-length) for large chat payloads.
|
||||
# Used by: src/app/api/v1/chat/completions/route.ts. Set to "0" to silence.
|
||||
# Default: disabled (opt-in).
|
||||
# Default: enabled.
|
||||
# OMNIROUTE_LOG_REQUEST_SHAPE=1
|
||||
|
||||
# Write raw (untruncated) request/response JSON in call log artifacts.
|
||||
@@ -2002,11 +1793,6 @@ APP_LOG_TO_FILE=true
|
||||
# WARNING: produces large files — use only for temporary debugging.
|
||||
# CHAT_DEBUG_FILE=true
|
||||
|
||||
# Surf empty textContent chunks in the Claude response translation path for debugging.
|
||||
# Used by: open-sse/handlers/responseTranslator.ts. Set to "true" to enable.
|
||||
# Default: disabled (opt-in).
|
||||
# DEBUG_CLAUDE_NONSTREAM=true
|
||||
|
||||
# Enable E2E test mode — relaxes auth and enables test harness hooks.
|
||||
# NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true
|
||||
|
||||
@@ -2040,35 +1826,6 @@ APP_LOG_TO_FILE=true
|
||||
# ALIBABA_CODING_PLAN_HOST=
|
||||
# ALIBABA_CODING_PLAN_QUOTA_URL=
|
||||
|
||||
# ── Qwen Cloud / Model Studio personal Token Plan quota ──
|
||||
# Cookie-authenticated console-gateway fetcher (issue #9603). Used by:
|
||||
# open-sse/services/qwenTokenPlanQuotaFetcher.ts. Prefer the per-connection
|
||||
# Dashboard fields (qwenCloudCookie / qwenCloudSecToken) — these env vars are
|
||||
# global fallbacks. Cookie/sec_token are SENSITIVE session credentials.
|
||||
# Getting the cookie: log in to home.qwencloud.com > Billing > Subscription,
|
||||
# press F12 > Network, reload, filter by api.json, click any request to
|
||||
# cs-data.qwencloud.com and copy the WHOLE Cookie value from Request Headers
|
||||
# (it contains login_qwencloud_ticket). Paste it on ONE line — the value may
|
||||
# contain '=' and ';'. It expires with the browser session; re-paste it when
|
||||
# the dashboard reports an expired session.
|
||||
# QWEN_CLOUD_COOKIE=
|
||||
# QWEN_CLOUD_SEC_TOKEN=
|
||||
# QWEN_TOKEN_PLAN_HOST=
|
||||
# QWEN_TOKEN_PLAN_DASHBOARD_URL=
|
||||
|
||||
# ── Alibaba Model Studio free-tier quota sync ──
|
||||
# Console front-end path overrides for the free-tier quota fetcher. Used by:
|
||||
# open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher
|
||||
# uses the production Bailian console paths.
|
||||
# ALIBABA_FREE_TIER_VISION_FE_PATH=
|
||||
# ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH=
|
||||
# ALIBABA_FREE_TIER_AUDIO_FE_PATH=
|
||||
# Optional path to a local JSON override for the built-in text free-tier
|
||||
# allowlist. Used by: open-sse/services/alibabaFreeTierAllowlist.ts. When
|
||||
# unset, the fetcher falls back to $DATA_DIR/alibaba-free-tier-allowlist.json
|
||||
# then config/alibaba-free-tier-allowlist.json.
|
||||
# ALIBABA_FREE_TIER_ALLOWLIST_PATH=
|
||||
|
||||
# ── Context window tuning ──
|
||||
# Tokens reserved for completion output when computing prompt budgets.
|
||||
# Used by: open-sse/services/contextManager.ts. Default: 1024.
|
||||
@@ -2086,25 +1843,6 @@ APP_LOG_TO_FILE=true
|
||||
# ── Devin CLI binary path ──
|
||||
# Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH.
|
||||
# CLI_DEVIN_BIN=devin
|
||||
# Agentic bridge-only binary override. The bridge still executes ACP stdio only.
|
||||
# CLI_DEVIN_AGENTIC_BIN=devin
|
||||
# Required isolated HOME for the agentic Devin child process.
|
||||
# DEVIN_AGENTIC_HOME=/home/bridge
|
||||
# Bounded ACP turn timeout in milliseconds. Default: 120000.
|
||||
# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000
|
||||
# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix.
|
||||
# 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
|
||||
|
||||
# ── Devin Desktop upstream compatibility versions ──
|
||||
# Desktop ide_version. Must use x.y.z format; invalid/unset values use 3.6.27.
|
||||
# DEVIN_DESKTOP_VERSION=3.6.27
|
||||
# Bundled Codeium/language-server extension_version, distinct from Desktop.
|
||||
# Must use x.y.z format; invalid/unset values use the bundled default 1.48.2.
|
||||
# DEVIN_DESKTOP_EXTENSION_VERSION=1.48.2
|
||||
|
||||
# ── Command Code (custom CLI) callback ──
|
||||
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
|
||||
@@ -2118,12 +1856,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 0.33.2
|
||||
# COMMAND_CODE_VERSION=0.33.2
|
||||
|
||||
# Base URL for the Command Code usage/quota upstream, used by smartphone
|
||||
# quota-fetcher telemetry.
|
||||
# Used by: open-sse/services/usage/command-code.ts
|
||||
# Default: https://api.commandcode.ai
|
||||
# COMMANDCODE_API_URL=https://api.commandcode.ai
|
||||
|
||||
# ── MITM debug proxy (development only) ──
|
||||
# Used by: src/mitm/server.cjs — captures upstream traffic for inspection.
|
||||
# MITM_LOCAL_PORT=443
|
||||
@@ -2165,15 +1897,6 @@ APP_LOG_TO_FILE=true
|
||||
# CHANGELOG_BASE_REF=origin/release/v0.0.0
|
||||
# ALLOW_CHANGELOG_REMOVALS=1
|
||||
|
||||
# ── Remote audio provider nodes ──
|
||||
# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/*
|
||||
# routes use an OpenAI-compatible provider node hosted outside localhost.
|
||||
# OFF by default: routing audio to a remote host changes egress identity, so it
|
||||
# must be an explicit operator decision. Loopback/private nodes (localhost,
|
||||
# 127.0.0.1, 172.16-31.x) are always allowed and unaffected by this flag.
|
||||
# When enabled, the node authenticates with the API key stored on its connection.
|
||||
# AUDIO_REMOTE_PROVIDER_NODES=false
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
# CrofAI 1Proxy service. Disable, override URL, or tune the import quality.
|
||||
@@ -2381,11 +2104,6 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
|
||||
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
|
||||
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
|
||||
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
|
||||
# ─── Memory Backend Connectors (Generic HTTP) ──────────────────────────────
|
||||
# NOTION_API_KEY=
|
||||
# NOTION_API_URL=
|
||||
# OBSIDIAN_API_KEY=
|
||||
# OBSIDIAN_API_URL=
|
||||
# AgentBridge + Traffic Inspector (Group A)
|
||||
|
||||
# AgentBridge
|
||||
@@ -2401,18 +2119,8 @@ INSPECTOR_MAX_BODY_KB=1024
|
||||
INSPECTOR_MASK_SECRETS=true
|
||||
INSPECTOR_LLM_HOSTS_EXTRA=
|
||||
INSPECTOR_INTERNAL_INGEST_TOKEN=
|
||||
# Shared secret for identity-preserving internal REST hops (#9260): when an
|
||||
# OmniRoute component calls another local OmniRoute route, this token (sent as
|
||||
# x-omniroute-internal-service-token) marks the request as internal so the
|
||||
# original caller identity is preserved. OPT-IN: unset disables the mechanism.
|
||||
# Used by: src/lib/api/internalServiceAuth.ts
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
|
||||
# File-based variant (secret-file pattern; wins only when the inline var is
|
||||
# unset): path to a file whose trimmed content is the token.
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
|
||||
# Quota Sharing (Group B — planos 16+22)
|
||||
# sqlite | redis
|
||||
QUOTA_STORE_DRIVER=sqlite
|
||||
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
|
||||
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
|
||||
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
|
||||
@@ -2521,11 +2229,6 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
|
||||
# already binds 6379. The container's internal port stays 6379.
|
||||
# OMNIROUTE_REDIS_HOST_PORT=
|
||||
# Host interface the 1-click Redis launcher publishes on. Default: 127.0.0.1
|
||||
# (loopback only). The launcher starts Redis WITHOUT a password, so binding
|
||||
# 0.0.0.0 hands every host on your LAN an unauthenticated Redis — only widen
|
||||
# this if you also set a password on the instance yourself.
|
||||
# OMNIROUTE_REDIS_BIND_HOST=
|
||||
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
|
||||
# Override to redis:8-alpine or a private registry mirror as needed.
|
||||
# OMNIROUTE_REDIS_IMAGE=
|
||||
@@ -2601,18 +2304,6 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HYPERAGENT_USAGE_URL=https://hyperagent.com/api/settings/billing/usage
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ChatGPT Web (Codex) headless browser and outbound tool tunnel
|
||||
# Used by: open-sse/executors/chatgpt-web-codex.ts
|
||||
# Connection values entered in the dashboard override these global defaults.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CHATGPT_WEB_CODEX_CHROME_PATH=/usr/bin/chromium
|
||||
# CHROME_PATH=/usr/bin/chromium
|
||||
# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
# CHATGPT_WEB_CODEX_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef
|
||||
# CHATGPT_WEB_CODEX_RUNTIME_KEY=
|
||||
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts)
|
||||
# Containerized Chromium+VNC used for interactive browser-login credential
|
||||
@@ -2639,93 +2330,3 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# OMNIROUTE_DATA_DIR are both unset. Locates the Notion web-thread session cache.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VIBEPROXY_DATA_DIR=
|
||||
|
||||
# ── Internal service auth (management-plane service-to-service calls) ─────────
|
||||
# Inline token for internal service authentication; prefer the _FILE variant in
|
||||
# containerized deployments so the secret never lands in the environment table.
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
|
||||
# Path to a file containing the internal service token (overrides the inline var).
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 26. RADAR FEED (SELF-HOSTING)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag
|
||||
# settings, not an env var) that overlays a signed, freshly-curated free-model
|
||||
# catalog on top of the release baseline. All four variables below are optional
|
||||
# and only needed to point the client at a self-hosted/forked feed or
|
||||
# supporter-key flow instead of the default OmniRoute Radar service. Used by:
|
||||
# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts.
|
||||
|
||||
# Base URL of the Radar feed service. Overrides the built-in default so forks
|
||||
# and self-hosters can point at their own signed feed.
|
||||
# RADAR_FEED_URL=https://radar.omniroute.online
|
||||
|
||||
# Ed25519 public key (base64-DER SPKI or PEM) used to verify the feed
|
||||
# signature, replacing the pinned default key. Required when self-hosting a
|
||||
# feed signed with a different key pair.
|
||||
# RADAR_FEED_PUBKEY=
|
||||
|
||||
# URL the dashboard's "I'm a contributor" button opens (GitHub OAuth
|
||||
# supporter-key claim flow). No pricing/value lives in this repo — only the
|
||||
# link.
|
||||
# RADAR_CONTRIBUTOR_CLAIM_URL=https://radar.omniroute.online/auth/github
|
||||
|
||||
# URL the dashboard's "Support the project" button opens (payment/plans
|
||||
# page). No pricing/value lives in this repo — only the link.
|
||||
# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 27. RELEASE v3.8.50 ADDITIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Heavy chat admission queue wait before returning retryable 503. Set 0 for the
|
||||
# legacy immediate rejection. Used by: src/shared/middleware/chatBodyAdmission.ts.
|
||||
# Default: 5000 (5 seconds)
|
||||
# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000
|
||||
|
||||
# Timeout for /api/jobs/:id/run-now while it waits for an in-flight run.
|
||||
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
|
||||
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
|
||||
|
||||
# Adobe Firefly browser renewal and durable session cache (enabled by default).
|
||||
# Used by: open-sse/services/adobeFireflySession.ts.
|
||||
# ADOBE_FIREFLY_BROWSER_REFRESH=1
|
||||
# ADOBE_FIREFLY_SESSION_DISK=1
|
||||
# Minimum spacing between submissions and the extra pause after every third success.
|
||||
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
|
||||
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
|
||||
# Chrome CDP runtime used by Adobe Firefly renewal. True headless is debug-only:
|
||||
# Adobe colligo normally rejects risk tokens minted without a headed browser.
|
||||
# ADOBE_FIREFLY_CHROME_CDP_PORT=9334
|
||||
# ADOBE_FIREFLY_CHROME_VISIBLE=0
|
||||
# ADOBE_FIREFLY_CHROME_HEADED=0 # Legacy alias for ADOBE_FIREFLY_CHROME_VISIBLE=1
|
||||
# ADOBE_FIREFLY_CHROME_HEADLESS=0
|
||||
# ADOBE_FIREFLY_CHROME_FORCE_RESTART=0
|
||||
# ADOBE_FIREFLY_CHROME_PING=auto
|
||||
# ADOBE_FIREFLY_LOGIN_WAIT_MS=0
|
||||
# ADOBE_FIREFLY_FORTER_WAIT_MS=45000
|
||||
# Optional absolute Chrome executable; auto-detected when unset.
|
||||
# CHROME_PATH=
|
||||
|
||||
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
|
||||
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_DEFAULT_MODEL=auto/chat
|
||||
# TELEGRAM_BOT_API_BASE=https://api.telegram.org
|
||||
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000
|
||||
|
||||
# ── OmniConductor bridge (Conductor PRD RF1) ──────────────────────────────────
|
||||
# Mirrors the OmniConductor hub's tasks into the local A2A TaskManager via SSE.
|
||||
# Opt-in: the bridge only starts when CONDUCTOR_HUB_URL is set.
|
||||
# Token: emit a `spokesperson`-kind credential on the hub (POST /v1/peers, admin) —
|
||||
# server-side only, never exposed to the browser.
|
||||
# Used by: src/lib/conductor/boot.ts, src/lib/conductor/bridge.ts
|
||||
# CONDUCTOR_HUB_URL=http://127.0.0.1:7910
|
||||
# CONDUCTOR_HUB_TOKEN=
|
||||
# Inbound A2A→hub delegation credential (falls back to CONDUCTOR_HUB_TOKEN when unset).
|
||||
# Used by: src/lib/conductor/hubProxy.ts
|
||||
# CONDUCTOR_ORCHESTRATOR_TOKEN=
|
||||
# Spokesperson (Faro) base URL for the dashboard chat proxy (/api/conductor/ask).
|
||||
# Used by: src/lib/conductor/faroProxy.ts
|
||||
# CONDUCTOR_SPOKESPERSON_URL=http://127.0.0.1:7920
|
||||
|
||||
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -39,17 +39,6 @@ updates:
|
||||
# the duplication gate — migrate the gate intentionally, not via dependabot.
|
||||
- dependency-name: "jscpd"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# ioredis is a SOFT/optional dependency loaded through a dynamic import
|
||||
# (src/lib/quota/redisQuotaStore.ts — "Redis driver requires ioredis package"),
|
||||
# so a breaking major never fails at build or typecheck time: the only consumers
|
||||
# are the distributed quota store (redisQuotaStore.ts, storeFactory.ts) and the
|
||||
# `import type Redis` in src/shared/utils/rateLimiter.ts. Nothing in the unit or
|
||||
# vitest suites exercises a live Redis connection, so a v5→v6 API break would ship
|
||||
# green and only surface at runtime for operators running distributed quota — the
|
||||
# exact users least able to absorb it. #9310 grouped that major with 9 harmless
|
||||
# bumps; majors here need their own PR and a deliberate migration review.
|
||||
- dependency-name: "ioredis"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is 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)
|
||||
|
||||
11
.github/pull_request_template.md
vendored
11
.github/pull_request_template.md
vendored
@@ -9,14 +9,11 @@
|
||||
|
||||
## Validation
|
||||
|
||||
Choose the change type and focused loop from the
|
||||
[Contribution Golden Path](../docs/ops/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
||||
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
|
||||
Run only the focused loop for what you changed — 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
|
||||
- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||
- [ ] `npm run lint`
|
||||
- [ ] Reconciled with the current active release base; focused checks rerun afterward
|
||||
- [ ] Production-code changes include a new or updated automated test in this PR
|
||||
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
|
||||
|
||||
@@ -32,4 +29,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
|
||||
39
.github/workflows/build-rinseaid-image.yml
vendored
Normal file
39
.github/workflows/build-rinseaid-image.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Build Rinseaid OmniRoute image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [build-k3-reasoning-image]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- open-sse/**
|
||||
- scripts/build/**
|
||||
- .github/workflows/build-rinseaid-image.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ghcr.io/rinseaid/omniroute:k3-reasoning-${{ github.sha }}
|
||||
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
|
||||
167
.github/workflows/ci.yml
vendored
167
.github/workflows/ci.yml
vendored
@@ -42,18 +42,6 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
# Refuse a PR that targets its own head branch before spending anything on it. #8912 has
|
||||
# head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with
|
||||
# a full check board attached on every push to that branch. One field comparison.
|
||||
- name: Reject a PR that targets its own branch
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: node scripts/check/check-pr-self-target.mjs
|
||||
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -137,17 +125,6 @@ jobs:
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:tracked-artifacts
|
||||
# (gap 30) Also lives in quality.yml's PR-only "Merge integrity" job — because the
|
||||
# CHANGELOG half of that job needs a base to diff against. This half does NOT: the
|
||||
# generator either reproduces the committed SKILL.md files or it does not.
|
||||
#
|
||||
# Keeping it PR-only left a real hole. This cycle's merge trains landed in batches with
|
||||
# `--admin`, which bypasses required checks, so three SKILL.md files drifted from the route
|
||||
# catalog, rode the release squash into `main`, and the next cycle's sync-back turned them
|
||||
# into a base-red that blocked EVERY PR into release/v3.8.50 until #8954. Running it here
|
||||
# means a push to `main` catches the drift at the source instead of the next cycle
|
||||
# inheriting it.
|
||||
- run: npm run check:agent-skills-sync
|
||||
# WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest).
|
||||
# failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/
|
||||
# DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails.
|
||||
@@ -353,16 +330,8 @@ jobs:
|
||||
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
|
||||
# actionlint — official download script
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin"
|
||||
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin.
|
||||
# PINNED on purpose. Unpinned, the runner installed whatever PyPI served that day and
|
||||
# measured 1 finding MORE than the devbox on the identical commit (190 vs 189) during
|
||||
# the v3.8.49 cycle — which cost a second rebaseline push per release, chasing a
|
||||
# number that was never the code's. The ratchet compares counts across machines, so
|
||||
# the auditor version has to be the same on both. Bump this deliberately, and
|
||||
# rebaseline in the same commit: check-workflows.mjs now prints `zizmorVersion=` next
|
||||
# to the count so the new number is traceable to the tool that produced it.
|
||||
ZIZMOR_VERSION=1.25.2
|
||||
pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION"
|
||||
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin
|
||||
pipx install zizmor || pip install --user zizmor
|
||||
# oasdiff — download latest linux amd64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
|
||||
gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
|
||||
@@ -501,13 +470,11 @@ jobs:
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: node scripts/i18n/check-ui-value-drift.mjs
|
||||
|
||||
# #8038: cheap glossary/protected-terms consistency gate —
|
||||
# #8038: cheap single-locale glossary/protected-terms consistency gate —
|
||||
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
|
||||
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
|
||||
# ko added after the #8224 ko.json mistranslation cleanup so the fixed
|
||||
# terminology cannot silently regress on the next machine-translation run.
|
||||
i18n-glossary-zhcn:
|
||||
name: i18n Glossary (zh-CN, ko)
|
||||
name: i18n Glossary (zh-CN)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
@@ -753,13 +720,7 @@ jobs:
|
||||
test-unit:
|
||||
name: Unit Tests (${{ matrix.shard }}/8)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback 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
|
||||
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' }}
|
||||
timeout-minutes: 25
|
||||
# needs: changes (not build) — this job never downloads the next-build artifact;
|
||||
# gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that
|
||||
@@ -813,12 +774,7 @@ jobs:
|
||||
|
||||
test-bun-sqlite:
|
||||
name: Bun SQLite Compatibility
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.os == 'windows-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
@@ -831,27 +787,12 @@ jobs:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Install Bun (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
powershell -c "iwr bun.sh/install.ps1 -useb | iex"
|
||||
echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
- name: Install Bun (non-Windows)
|
||||
if: runner.os != 'Windows'
|
||||
run: npm install -g bun
|
||||
- run: npm run test:bun:db
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
|
||||
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
|
||||
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
|
||||
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
|
||||
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ (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' }}
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
@@ -1229,10 +1170,8 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Integration tests (shard ${{ matrix.shard }}/2)
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/2
|
||||
run: npm run test:integration:ci
|
||||
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
|
||||
- run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
@@ -1256,63 +1195,6 @@ jobs:
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:security
|
||||
|
||||
# Live-server E2E. Both suites boot a real OmniRoute via their own runner and
|
||||
# drive it over HTTP; neither needs provider credentials. They were documented in
|
||||
# AGENTS.md's test matrix but wired to NO workflow, and had additionally been
|
||||
# unrunnable (vitest.config.ts excluded the very files their runners passed as a
|
||||
# positional filter) — so nothing had executed them for as long as that was true.
|
||||
test-ecosystem:
|
||||
name: Ecosystem E2E (live server)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# needs: changes (not build) — the runner boots its own dev server.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:ecosystem
|
||||
|
||||
test-protocols-e2e:
|
||||
name: Protocol Clients E2E (live server, advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
# ADVISORY until #10049 is resolved. Restoring this suite immediately surfaced a
|
||||
# real discrepancy that had been invisible while it could not run: GET
|
||||
# /api/mcp/audit answers 403 over loopback where the suite expects 200|401. That
|
||||
# is a pre-existing contract question, not a defect introduced by wiring the job
|
||||
# up, so it must not block every PR in the meantime. Flip to blocking (drop this
|
||||
# continue-on-error) the moment #10049 lands.
|
||||
continue-on-error: true
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:protocols:e2e
|
||||
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1335,8 +1217,6 @@ jobs:
|
||||
- test-e2e
|
||||
- test-integration
|
||||
- test-security
|
||||
- test-ecosystem
|
||||
- test-protocols-e2e
|
||||
steps:
|
||||
- name: Download i18n results
|
||||
continue-on-error: true
|
||||
@@ -1349,8 +1229,6 @@ jobs:
|
||||
- name: Generate dashboard
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
# Workflow-controlled data (job results), not user input — safe to read here.
|
||||
NEEDS_JSON: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
status() {
|
||||
case "$1" in
|
||||
@@ -1365,29 +1243,6 @@ jobs:
|
||||
echo "# 🚀 CI Dashboard" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# (gap 12) A cancelled job never reported a verdict, and in a long table that reads the
|
||||
# same as a green one. `cancel-in-progress` plus incremental fixing cancels jobs on every
|
||||
# push, and this cycle the Vitest job was cancelled in rounds 1, 2 and 3 — it only ran to
|
||||
# completion in round 4, where it revealed a suite that had been broken the whole cycle
|
||||
# plus two production bugs. A gate that never finishes is indistinguishable from one that
|
||||
# passes, so name them at the TOP instead of leaving them to be spotted mid-table.
|
||||
CANCELLED_JOBS=$(printf '%s' "$NEEDS_JSON" \
|
||||
| jq -r 'to_entries | map(select(.value.result == "cancelled")) | .[].key' 2>/dev/null \
|
||||
| sort | paste -sd", " -) || CANCELLED_JOBS=""
|
||||
if [ -n "$CANCELLED_JOBS" ]; then
|
||||
{
|
||||
echo "> ### ⚫ Cancelled — no verdict was reported"
|
||||
echo ">"
|
||||
echo "> \`$CANCELLED_JOBS\`"
|
||||
echo ">"
|
||||
echo "> These did not fail; they never finished, so nothing was checked. Treat this"
|
||||
echo "> run as INCOMPLETE for those gates. If the cancellation came from"
|
||||
echo "> \`cancel-in-progress\` on a newer push, the newer run covers it — otherwise"
|
||||
echo "> re-run them before reading this dashboard as green."
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1395,7 +1250,7 @@ jobs:
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n Glossary (zh-CN, ko) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n Glossary (zh-CN) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1417,8 +1272,6 @@ jobs:
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Ecosystem E2E | $(status '${{ needs.test-ecosystem.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Protocol Clients E2E (advisory, #10049) | $(status '${{ needs.test-protocols-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
54
.github/workflows/docker-publish.yml
vendored
54
.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."
|
||||
@@ -91,10 +109,10 @@ jobs:
|
||||
fi
|
||||
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 3) Skip immutable version tags that already exist. Floating `main`
|
||||
# and `next` channels are intentionally rebuilt on every matching push.
|
||||
# 3) Skip if this exact version is already published in Docker Hub.
|
||||
# `main` is always rebuilt (mutable floating tag).
|
||||
SKIP="false"
|
||||
if [ "$VERSION" != "main" ] && [ "$VERSION" != "next" ]; then
|
||||
if [ "$VERSION" != "main" ]; then
|
||||
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
|
||||
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
|
||||
SKIP="true"
|
||||
@@ -137,13 +155,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 +255,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -372,14 +390,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:
|
||||
|
||||
89
.github/workflows/electron-release.yml
vendored
89
.github/workflows/electron-release.yml
vendored
@@ -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
|
||||
@@ -229,16 +217,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 +227,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:
|
||||
@@ -319,47 +275,6 @@ jobs:
|
||||
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]
|
||||
|
||||
4
.github/workflows/nightly-release-green.yml
vendored
4
.github/workflows/nightly-release-green.yml
vendored
@@ -193,7 +193,7 @@ jobs:
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
|
||||
57
.github/workflows/npm-publish.yml
vendored
57
.github/workflows/npm-publish.yml
vendored
@@ -159,17 +159,6 @@ jobs:
|
||||
# `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
|
||||
@@ -179,36 +168,14 @@ jobs:
|
||||
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"
|
||||
RUN=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
|
||||
--jq '[.workflow_runs[] | select(.name == "CI" and .conclusion == "success")] | .[0].id // empty') || RUN=""
|
||||
if [ -z "$RUN" ]; then
|
||||
echo "::notice::no successful CI run 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"
|
||||
if ! gh run download "$RUN" --repo "$REPO" --name next-build --dir /tmp/next-build; then
|
||||
echo "::notice::next-build artifact unavailable for run $RUN (expired?) — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
|
||||
@@ -256,18 +223,6 @@ jobs:
|
||||
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
|
||||
|
||||
278
.github/workflows/quality.yml
vendored
278
.github/workflows/quality.yml
vendored
@@ -60,49 +60,13 @@ jobs:
|
||||
build:
|
||||
name: Build (advisory)
|
||||
needs: changes
|
||||
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
|
||||
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
|
||||
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
|
||||
# never fires here, and this is the only pre-merge build signal they get. Measured
|
||||
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
|
||||
# the majority of the traffic, not the exception — this job earns its place, it just should
|
||||
# not duplicate build.yml for the own-origin 28%.
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
|
||||
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
|
||||
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
|
||||
# Measured 2026-08-14 over the last 25
|
||||
# quality.yml runs: not one Build (advisory) reached a conclusion. Every sample was either
|
||||
# queued on the self-hosted pool (2 runners, `omniroute-113-6/7`, both permanently busy — one
|
||||
# job sat queued 2h+ and was still unclaimed) or, when it did land, killed mid-build by this
|
||||
# workflow's own `cancel-in-progress` concurrency. 6/6 sampled "failures" are exit 143 /
|
||||
# "The runner has received a shutdown signal" at ~3.5 min into `npm run build` — zero OOM,
|
||||
# zero build errors. So the job burned a scarce runner that the gates actually need while
|
||||
# reporting a permanent red on every PR.
|
||||
#
|
||||
# Gap 19 left USE_VPS_RUNNER governing build-like jobs on the premise that "the build needs
|
||||
# the .113's RAM". That premise no longer holds: `Fast Production Build` (build.yml) runs
|
||||
# `build:release` — a SUPERSET of this job's `npm run build`, plus the CLI bundle — on plain
|
||||
# ubuntu-latest and passed 24/25 of its last runs in ~15 min. What it has and this job did
|
||||
# not is memory PROVISIONING: a 10 GB swapfile plus a 12 GB V8 heap. That matters because
|
||||
# --max-old-space-size only bounds V8's JS heap, never Turbopack's native (Rust) allocation
|
||||
# (#6409) — swap is what absorbs the native peak. Both are mirrored below.
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# 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:
|
||||
# Mirrors build.yml: Turbopack's native peak is not bounded by --max-old-space-size, so
|
||||
# the hosted runner needs swap headroom before the build starts.
|
||||
- name: Expand virtual memory (10 GB swap)
|
||||
run: |
|
||||
sudo swapoff -a || true
|
||||
sudo rm -f /mnt/swapfile /swapfile
|
||||
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
|
||||
sudo chmod 600 /mnt/swapfile
|
||||
sudo mkswap /mnt/swapfile
|
||||
sudo swapon /mnt/swapfile
|
||||
free -h
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -115,10 +79,6 @@ jobs:
|
||||
- run: npm run build
|
||||
env:
|
||||
OMNIROUTE_USE_TURBOPACK: "1"
|
||||
# Same heap build.yml proves sufficient. build-next-isolated.mjs defaults to 8192 and
|
||||
# honours OMNIROUTE_BUILD_MEMORY_MB; NODE_OPTIONS is set for parity with build.yml.
|
||||
NODE_OPTIONS: "--max-old-space-size=12288"
|
||||
OMNIROUTE_BUILD_MEMORY_MB: "12288"
|
||||
# No artifact upload here: the PR-to-release quality workflow has no
|
||||
# downstream package/e2e jobs that consume the Next.js build output.
|
||||
|
||||
@@ -152,20 +112,7 @@ jobs:
|
||||
# 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
|
||||
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' }}
|
||||
# tsx gates (known-symbols, route-guard-membership) import modules that open
|
||||
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
env:
|
||||
@@ -191,149 +138,52 @@ jobs:
|
||||
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
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
|
||||
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
|
||||
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
|
||||
- run: npm run check:mutation-test-coverage
|
||||
- run: npm run check:any-budget:t11
|
||||
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
||||
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
||||
- run: npm run check:build-scope
|
||||
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||
# the release PR's heavy Package Artifact job.
|
||||
- run: npm run check:pack-policy
|
||||
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
||||
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||
- run: npm run check:complexity-ratchets
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
||||
- name: Typecheck (dashboard)
|
||||
run: npm run check:dashboard-typecheck
|
||||
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
|
||||
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
|
||||
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
|
||||
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
|
||||
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
|
||||
# The full result stays advisory while #8484 has a backlog. The blocking
|
||||
# base-relative ratchet (folded into the non-fail-fast gates step above)
|
||||
# rejects only diagnostics added by the PR, so existing release debt does
|
||||
# not block unrelated work.
|
||||
# Promote to the blocking gate after ~1 week of parity with the step above.
|
||||
- name: Typecheck (core) — TS7 native shadow (advisory)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
RC=0
|
||||
START=$(date +%s)
|
||||
npm exec --yes --package=typescript@7.0.2 -- tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
||||
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
||||
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
|
||||
exit $RC
|
||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||
@@ -350,8 +200,7 @@ jobs:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
# The advisory sibling-test step generates the same map earlier in this job.
|
||||
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
# Shadow evidence (#8084): persist every selection so TIA false negatives can
|
||||
# be measured against fast-unit's full-suite verdict across releases BEFORE
|
||||
@@ -411,13 +260,7 @@ jobs:
|
||||
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
|
||||
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' }}
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
@@ -453,13 +296,7 @@ jobs:
|
||||
# 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
|
||||
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' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -502,12 +339,6 @@ jobs:
|
||||
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:
|
||||
@@ -529,29 +360,6 @@ jobs:
|
||||
- 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
|
||||
|
||||
48
.gitignore
vendored
48
.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/
|
||||
|
||||
@@ -71,7 +73,6 @@ yarn-error.log*
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.homolog.example
|
||||
!.env.devin-bridge.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
@@ -84,7 +85,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# data and logs
|
||||
/data/
|
||||
data/
|
||||
.data/
|
||||
logs/*
|
||||
test_output.log
|
||||
@@ -106,7 +107,7 @@ open-sse/test/*
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
/cloud/
|
||||
cloud/
|
||||
.tmp/
|
||||
|
||||
# Security Analysis (standalone project with own git)
|
||||
@@ -120,8 +121,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,7 +156,6 @@ vscode-extension/
|
||||
|
||||
# Empty/dangling files
|
||||
typescript
|
||||
/MAX
|
||||
|
||||
# Gemini Antigravity agent data
|
||||
.gemini/
|
||||
@@ -173,6 +171,7 @@ config/quality/test-impact-map.json
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
.worktrees
|
||||
bin/omniroute.mjs
|
||||
|
||||
# Consistent with .dockerignore / .npmignore
|
||||
.omc/
|
||||
@@ -201,16 +200,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 +221,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
|
||||
@@ -263,13 +239,10 @@ _artifacts/ # release-green artifacts
|
||||
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
/.eslintcache-*
|
||||
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.artifacts/
|
||||
/perf-audit*.md
|
||||
/quality-ratchet/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
@@ -282,6 +255,3 @@ docker-compose.yml.bak
|
||||
# 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
|
||||
|
||||
@@ -87,10 +87,9 @@
|
||||
'''latencyP\d{2}Ms''',
|
||||
'''interleaved-thinking-2025-05-14''',
|
||||
# v3.8.49 pre-flight (2026-07-28). Nenhum dos dois e credencial:
|
||||
# - chave de localStorage do banner de patrocinio (#8723; #10200 bumpou v1->v2,
|
||||
# generalizado para -v\d+ no round 3 de base-reds #9985), so um identificador de UI;
|
||||
# - chave de localStorage do banner de patrocinio (#8723), so um identificador de UI;
|
||||
# - x-api-key PUBLICO do Firefly web (documentado em open-sse/utils/publicCreds.ts:207);
|
||||
# as duas ocorrencias sinalizadas estao em COMENTARIOS JSDoc, o runtime le de resolvePublicCred().
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v\d+''',
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v1''',
|
||||
'''SunbreakWebUI1''',
|
||||
]
|
||||
|
||||
29
.mergify.yml
29
.mergify.yml
@@ -17,13 +17,6 @@
|
||||
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
|
||||
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
|
||||
|
||||
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
|
||||
# merge_protections_settings — the rules-based queue action / autoqueue path is
|
||||
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
|
||||
merge_protections_settings:
|
||||
auto_merge_conditions:
|
||||
- label = queue
|
||||
|
||||
queue_rules:
|
||||
- name: release
|
||||
# Any current or future release branch — the reason GitHub's native queue was
|
||||
@@ -41,26 +34,14 @@ queue_rules:
|
||||
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
|
||||
# approval in this repo's single-maintainer model (see governance header).
|
||||
merge_conditions:
|
||||
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
|
||||
# continue-on-error by design, and its GH-hosted Turbopack build hangs
|
||||
# recurrently mid-"Creating an optimized production build" (100% failure rate
|
||||
# across every sampled PR since the job was added 2026-07-27, always killed by
|
||||
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
|
||||
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
|
||||
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
|
||||
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
|
||||
# carrying its tolerance forward would mask problems it no longer causes.
|
||||
- or:
|
||||
- "#check-failure=0"
|
||||
- and:
|
||||
- "#check-failure=1"
|
||||
- check-failure=Build (advisory)
|
||||
- "#check-failure=0"
|
||||
- "#check-pending=0"
|
||||
- "#check-success>=1"
|
||||
- check-success=Merge integrity (changelog + generated skills)
|
||||
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
|
||||
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
|
||||
# the free plan). Serial queue (1 PR at a time) still automates the train.
|
||||
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
|
||||
# don't hold a lone PR hostage waiting for siblings.
|
||||
batch_size: 10
|
||||
batch_max_wait_time: 5 min
|
||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||
merge_method: squash
|
||||
|
||||
|
||||
19
.npmignore
19
.npmignore
@@ -4,14 +4,11 @@ data/
|
||||
**/db.json
|
||||
|
||||
# VS Code extension test runtime (large binary, not needed in npm package)
|
||||
app/vscode-extension/
|
||||
**/data/
|
||||
**/db.json
|
||||
|
||||
# Source code (pre-built dist/ is published instead)
|
||||
#
|
||||
# NOTA (2026-08-05): as entradas `app/*` foram removidas — o diretorio `app/`
|
||||
# foi renomeado para `dist/` na Layer 1 e nao existe mais. Elas sugeriam um
|
||||
# layout que ja nao e o do projeto.
|
||||
# Source code (pre-built app/ is published instead)
|
||||
#
|
||||
# NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what
|
||||
# ships. It now allowlists the backend source closure the MCP server needs at runtime
|
||||
@@ -52,6 +49,8 @@ scripts/
|
||||
.vscode/
|
||||
.agents/
|
||||
.env*
|
||||
app/.env
|
||||
app/.env*
|
||||
eslint.config.mjs
|
||||
prettier.config.mjs
|
||||
postcss.config.mjs
|
||||
@@ -83,6 +82,8 @@ bun.lock
|
||||
*.deb
|
||||
*.rpm
|
||||
electron/
|
||||
app/electron/
|
||||
app/vscode-extension/
|
||||
|
||||
# Subprojects
|
||||
clipr/
|
||||
@@ -92,12 +93,12 @@ vscode-extension/
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never publish)
|
||||
/_*/
|
||||
app/_*/
|
||||
app/coverage/
|
||||
app/logs/
|
||||
app/tests/
|
||||
|
||||
# Consistent with .gitignore and .dockerignore
|
||||
.claude/
|
||||
.fakebin-*
|
||||
.eslintcache*
|
||||
_tasks/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.config/
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
|
||||
docs/reference/ENVIRONMENT.md
|
||||
|
||||
# Generated by `npm run gen:provider-reference`; the generator aligns the tables and
|
||||
# is their formatter of record. Without this, lint-staged reformats the file whenever
|
||||
# it is staged and the next generator run reverts it — a diff ping-pong.
|
||||
docs/reference/PROVIDER_REFERENCE.md
|
||||
|
||||
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
|
||||
open-sse/config/freeModelCatalog.data.ts
|
||||
|
||||
|
||||
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
|
||||
};
|
||||
@@ -30,7 +30,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
|
||||
@@ -84,7 +84,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
|
||||
```
|
||||
|
||||
@@ -164,8 +164,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.
|
||||
@@ -196,7 +196,6 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
|
||||
| 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 |
|
||||
@@ -227,8 +226,6 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
|
||||
| `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 |
|
||||
@@ -301,45 +298,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.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts",
|
||||
"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",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,47 +36,39 @@ function fmt(level: LogLevel, msg: string, tag?: string): string {
|
||||
return `${prefix} [${level.toUpperCase()}] ${msg}`;
|
||||
}
|
||||
|
||||
function buildLogger(getLevel: () => LogLevel) {
|
||||
return {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
export const logger = {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
|
||||
// ── Tagged child loggers ────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof buildLogger>;
|
||||
|
||||
/** Create an instance-scoped logger whose level cannot be changed by other plugin instances. */
|
||||
export function createLogger(level: LogLevel): Logger {
|
||||
return buildLogger(() => level);
|
||||
}
|
||||
|
||||
/** Backward-compatible module-global logger controlled by setLogLevel(). */
|
||||
export const logger: Logger = buildLogger(() => _level);
|
||||
// ── Tagged child loggers ──────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "error") &&
|
||||
console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "warn") &&
|
||||
console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "info") &&
|
||||
console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "debug") &&
|
||||
console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,22 +13,6 @@ import {
|
||||
forceSyncOmniRouteModels,
|
||||
type OmniRouteFetchCache,
|
||||
} from "../src/index.js";
|
||||
import { getLogLevel, setLogLevel } from "../src/logger.js";
|
||||
|
||||
async function captureConsole(run: () => Promise<void>): Promise<string[]> {
|
||||
const lines: string[] = [];
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
console.error = (...args: unknown[]) => lines.push(args.map(String).join(" "));
|
||||
console.warn = (...args: unknown[]) => lines.push(args.map(String).join(" "));
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
@@ -51,10 +35,7 @@ test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
|
||||
|
||||
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
|
||||
assert.equal(
|
||||
parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs,
|
||||
120_000
|
||||
);
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
|
||||
@@ -131,76 +112,6 @@ test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async
|
||||
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels suppresses successful lifecycle output at error level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
combos: false,
|
||||
compressionMetadata: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "error",
|
||||
usableOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
setLogLevel("error");
|
||||
const lines = await captureConsole(async () => {
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
|
||||
fetcher: async () => [{ id: "model-a", object: "model" }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
assert.deepEqual(lines, []);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels preserves successful lifecycle output at info level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
combos: false,
|
||||
compressionMetadata: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "info",
|
||||
usableOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
setLogLevel("info");
|
||||
const lines = await captureConsole(async () => {
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
|
||||
fetcher: async () => [{ id: "model-a", object: "model" }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("force sync ok")).length, 1);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
|
||||
@@ -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
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -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["opencode-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");
|
||||
@@ -482,10 +471,10 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, [
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"opencode-omniroute/gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["claude-tier"], undefined, "no combo entry");
|
||||
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"
|
||||
@@ -734,7 +723,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["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -759,39 +748,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"]);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: expected raw auto twin does not warn and auto combo wins", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
const warnings: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
|
||||
|
||||
let block: OmniRouteStaticProviderEntry;
|
||||
try {
|
||||
block = buildStaticProviderEntry(
|
||||
[{ id: "auto/coding" }],
|
||||
[],
|
||||
resolved,
|
||||
"https://or.example/v1",
|
||||
"sk-test",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[{ id: "auto/coding", name: "Auto Coding", variant: "coding", candidateCount: 5 }]
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert.equal(Object.keys(block.models).filter((key) => key === "auto/coding").length, 1);
|
||||
assert.equal(block.models["auto/coding"].tool_call, true, "auto-combo entry wins over raw twin");
|
||||
assert.deepEqual(
|
||||
warnings.filter((warning) => warning.includes("collides with an existing model")),
|
||||
[]
|
||||
);
|
||||
assert.equal(block.models["omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -807,7 +765,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["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
});
|
||||
@@ -821,7 +779,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["opencode-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");
|
||||
@@ -849,7 +807,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
enrichment
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-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);
|
||||
@@ -870,8 +828,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["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -900,7 +858,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"]);
|
||||
@@ -1009,10 +967,10 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
"opencode-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["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-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);
|
||||
});
|
||||
|
||||
@@ -1042,7 +1000,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
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["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1069,7 +1027,7 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
];
|
||||
assert.ok(entry, "static block still published on enrichment failure");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1271,20 +1229,17 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(
|
||||
entry.models["claude-sonnet-4-6"],
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-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"
|
||||
);
|
||||
});
|
||||
@@ -1326,7 +1281,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1377,12 +1332,12 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/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["opencode-omniroute/gemini-3-flash"].name, "Gemini - 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 () => {
|
||||
@@ -1409,7 +1364,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1441,7 +1396,7 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
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");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
|
||||
@@ -1468,7 +1423,7 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
|
||||
@@ -1496,7 +1451,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["claude-sonnet-4-6"].name,
|
||||
entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
|
||||
@@ -1507,7 +1462,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["claude-sonnet-4-6"].name,
|
||||
entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
});
|
||||
@@ -1561,7 +1516,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);
|
||||
});
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { Config } from "@opencode-ai/plugin";
|
||||
|
||||
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
|
||||
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
|
||||
|
||||
type ConsoleMethod = "error" | "info" | "log" | "warn";
|
||||
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
|
||||
|
||||
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
const consoleMethods: ConsoleMethod[] = ["error", "info", "log", "warn"];
|
||||
|
||||
async function captureConsole(run: () => Promise<void>): Promise<ConsoleEntries> {
|
||||
const entries: ConsoleEntries = { error: [], info: [], log: [], warn: [] };
|
||||
const originals = Object.fromEntries(
|
||||
consoleMethods.map((method) => [method, console[method]])
|
||||
) as Record<ConsoleMethod, typeof console.warn>;
|
||||
|
||||
for (const method of consoleMethods) {
|
||||
console[method] = (...args: unknown[]) => {
|
||||
entries[method].push(args);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const method of consoleMethods) console[method] = originals[method];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function rendered(entries: ConsoleEntries): string[] {
|
||||
return consoleMethods.flatMap((method) =>
|
||||
entries[method].map((args) => args.map((arg) => String(arg)).join(" "))
|
||||
);
|
||||
}
|
||||
|
||||
async function capturePluginLifecycle(args: {
|
||||
level: LogLevel;
|
||||
autoSyncIntervalMs: number;
|
||||
invokeConfig?: boolean;
|
||||
}): Promise<string[]> {
|
||||
const previousDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
const previousLevel = getLogLevel();
|
||||
const dataDir = await mkdtemp(join(tmpdir(), "omniroute-log-level-"));
|
||||
process.env.OPENCODE_DATA_DIR = dataDir;
|
||||
|
||||
try {
|
||||
const entries = await captureConsole(async () => {
|
||||
const hooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: args.autoSyncIntervalMs,
|
||||
features: { logLevel: args.level },
|
||||
});
|
||||
if (args.invokeConfig) {
|
||||
assert.equal(typeof hooks.config, "function");
|
||||
await hooks.config!({} as Config);
|
||||
}
|
||||
});
|
||||
return rendered(entries);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = previousDataDir;
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("logLevel error suppresses the initialization banner", async () => {
|
||||
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 0 });
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("initialized")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel error suppresses the auto-sync enabled lifecycle message", async () => {
|
||||
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 60_000 });
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("auto-sync enabled")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel error suppresses factory config-shim diagnostics", async () => {
|
||||
const lines = await capturePluginLifecycle({
|
||||
level: "error",
|
||||
autoSyncIntervalMs: 0,
|
||||
invokeConfig: true,
|
||||
});
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel debug preserves startup and config-shim diagnostics", async () => {
|
||||
const lines = await capturePluginLifecycle({
|
||||
level: "debug",
|
||||
autoSyncIntervalMs: 60_000,
|
||||
invokeConfig: true,
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("initialized")),
|
||||
"initialization banner emitted"
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("auto-sync enabled")),
|
||||
"auto-sync message emitted"
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("config shim skipped")),
|
||||
"config breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("debug instance retains config diagnostics after an error instance is created", async () => {
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
const debugHooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "debug" },
|
||||
});
|
||||
await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "error" },
|
||||
});
|
||||
await debugHooks.config!({} as Config);
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 1);
|
||||
});
|
||||
|
||||
test("error instance keeps config diagnostics suppressed after a debug instance is created", async () => {
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
const errorHooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "error" },
|
||||
});
|
||||
await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "debug" },
|
||||
});
|
||||
await errorHooks.config!({} as Config);
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
|
||||
});
|
||||
|
||||
test("error-level config fetch failures remain visible as concise injected-logger messages", async () => {
|
||||
const entries: unknown[][] = [];
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"opencode-omniroute": { type: "api", key: "test-key" },
|
||||
}),
|
||||
fetcher: async () => {
|
||||
throw new Error("models unavailable");
|
||||
},
|
||||
combosFetcher: async () => {
|
||||
throw new Error("combos unavailable");
|
||||
},
|
||||
logger: {
|
||||
warn: (...args: unknown[]) => {
|
||||
entries.push(args);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await hook({} as Config);
|
||||
|
||||
assert.equal(entries.length, 2, "both genuine fetch failures remain visible");
|
||||
assert.deepEqual(
|
||||
entries.map((args) => args.length),
|
||||
[1, 1],
|
||||
"each failure is emitted as one concise argument"
|
||||
);
|
||||
const lines = entries.map(([message]) => String(message));
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("/v1/models") && line.includes("models unavailable"))
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("/api/combos") && line.includes("combos unavailable"))
|
||||
);
|
||||
assert.equal(
|
||||
entries.flat().some((arg) => arg instanceof Error),
|
||||
false,
|
||||
"no raw Error object emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("logger error output remains visible at error level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
try {
|
||||
setLogLevel("error");
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
logger.error("genuine startup failure");
|
||||
})
|
||||
);
|
||||
assert.ok(lines.some((line) => line.includes("genuine startup failure")));
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -111,9 +111,7 @@ test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID
|
||||
// `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)", () => {
|
||||
test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
assert.equal(resolved.providerId, "opencode-omniroute");
|
||||
assert.equal(resolved.omnirouteProviderId, "omniroute");
|
||||
@@ -133,7 +131,7 @@ test("#7976/#9175: buildStaticProviderEntry keys combos by bare slug (no prefix
|
||||
"sk-test"
|
||||
);
|
||||
|
||||
assert.deepEqual(Object.keys(block.models), ["hermes-smart-stack"]);
|
||||
assert.deepEqual(Object.keys(block.models), ["omniroute/hermes-smart-stack"]);
|
||||
assert.equal(
|
||||
block.models["opencode-omniroute/hermes-smart-stack"],
|
||||
undefined,
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
1321
CHANGELOG.md
1321
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
577
CLAUDE.md
577
CLAUDE.md
@@ -1,42 +1,406 @@
|
||||
# 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
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 290 LLM providers, auto-fallback.
|
||||
|
||||
| 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 (95 files, 110 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 104 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 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 |
|
||||
|
||||
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
|
||||
|
||||
---
|
||||
|
||||
## 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`): 18 strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table 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` = **error** in `open-sse/` and `tests/` (since #6218 — pre-existing violations are frozen in `config/quality/eslint-suppressions.json`, new ones must be fixed; `npm run lint` applies the suppressions and is what CI runs)
|
||||
- **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/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 (12-factor scoring, 18 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/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.
|
||||
|
||||
---
|
||||
|
||||
## Planning & Research Artifacts (superpowers, deep-research)
|
||||
|
||||
`_tasks/` is a **separate, isolated git repository** that is gitignored by the main
|
||||
repo (`.gitignore` → `_tasks/`). It is the canonical home for working artifacts —
|
||||
plans, specs/designs, research, hand-offs — so they stay **versioned in their own
|
||||
repo** instead of polluting the main OmniRoute tree.
|
||||
|
||||
**Hard rule — never write superpowers / planning / research output under `docs/` or
|
||||
the repo root.** The superpowers skills ship with defaults that point at `docs/…`
|
||||
(`writing-plans` → `docs/superpowers/plans/`, `brainstorming` → `docs/superpowers/specs/`).
|
||||
Those defaults are **overridden here**. Whenever you invoke superpowers (or any
|
||||
plan/spec/research generator) in this project, save to `_tasks/` instead, using the
|
||||
same filename convention:
|
||||
|
||||
| Artifact (skill) | Default (do NOT use) | Save here instead |
|
||||
| ---------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
@@ -45,11 +409,156 @@ rewrite it to the `_tasks/…` equivalent before writing:
|
||||
| Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` |
|
||||
| Hand-offs (`/handoff`) | — | `_tasks/hands-off/<YYYY-MM-DD>_<branch>_v<versão>_sess-<id>/` |
|
||||
|
||||
Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
|
||||
When a superpowers skill announces a path like "saved to `docs/superpowers/plans/…`",
|
||||
rewrite it to the `_tasks/…` equivalent before writing. Commit those artifacts inside
|
||||
the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
|
||||
|
||||
## Base-green before opening PRs
|
||||
## Git Workflow
|
||||
|
||||
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.
|
||||
```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` + `check:tracked-artifacts`
|
||||
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
|
||||
already run on pre-commit; re-running them on every push was pure double-pay. CI still
|
||||
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)
|
||||
|
||||
### 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).
|
||||
**🔴 MANDATORY PATH: every worktree lives under `.claude/worktrees/` — and nowhere else.**
|
||||
This is the single canonical location (the same dir the native `EnterWorktree` tool uses). It
|
||||
is gitignored AND in the `tsconfig.json` / `.dockerignore` excludes, so worktrees never leak
|
||||
into the build scope. **Never** use `.worktrees/`, repo-root, or any other path — a worktree
|
||||
outside `.claude/worktrees/` (a) escapes the build-scope excludes and poisons `next build` (the
|
||||
`tsconfig` `include: **/*` globs ~70× the codebase → OOM; incident 2026-06-25) and (b) scatters
|
||||
worktrees across two dirs.
|
||||
|
||||
```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 ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||
cd ".claude/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 (it already creates worktrees under
|
||||
`.claude/worktrees/`): 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 .claude/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. This is the **only supported** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only.
|
||||
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.3.14` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`).
|
||||
- **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".
|
||||
20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`.
|
||||
21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-prs`, `/review-group-prs`, `/merge-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit <N> --base release/vX+1`, then VERIFY with `gh pr view <N> --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view <N> --json state`) is the authorized captain freeze — hold, don't touch.
|
||||
22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening):
|
||||
- **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show <ref>:<path>` or `git diff <ref> -- <path>`; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:<path>`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent).
|
||||
- **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view <N> --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -209,11 +198,10 @@ 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 a PR, 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):
|
||||
|
||||
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||
- Run `npm run lint`
|
||||
@@ -283,7 +271,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 +281,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)
|
||||
|
||||
73
Dockerfile
73
Dockerfile
@@ -15,46 +15,14 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
&& 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
|
||||
# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
|
||||
# ship the patched versions. These are npm's own internals — not application
|
||||
# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
|
||||
# but the container scanner flags the stale copies under
|
||||
# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
|
||||
# the runner stages, so this is hygiene, not an exploitable runtime path.
|
||||
RUN npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
@@ -109,7 +77,7 @@ RUN test -f package-lock.json \
|
||||
# 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 \
|
||||
npm ci --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()" \
|
||||
@@ -125,15 +93,7 @@ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
# 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.
|
||||
# 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}"
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# 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.
|
||||
@@ -159,9 +119,7 @@ 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);"
|
||||
mkdir -p /app/data && npm run build
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
@@ -221,8 +179,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"]
|
||||
@@ -278,11 +236,6 @@ 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 \
|
||||
|
||||
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** ≥ 60 % statements / lines / functions / branches — the official CI gate (`npm run test:coverage`). The ratchet baseline in `quality-baseline.json` may freeze a higher floor; never regress it.
|
||||
|
||||
## 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
|
||||
216
README.md
216
README.md
@@ -7,19 +7,19 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 339 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 339 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 290 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 290 AI providers · 90+ free tiers · ~1.53B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 💰 ~1.51B Free Tokens / Month
|
||||
## 💰 ~1.53B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **42 provider pools / 495 models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **43 provider pools / 516 models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from the documented free tiers of 42 provider pools / 495 models behind one endpoint. Honest pool-deduped math — each shared pool counted once (counting every rate limit 24/7 would read ~10B; not published), 15 providers ToS-flagged so you decide. Budget bar of the countable free pools with per-model grid (Mistral Large 3 1B, GPT-4o mini 150M, Gemini 2.5 Flash 60M … Claude Sonnet 4.5 25K), one-time first-month signup credits (vertex 300M, agentrouter 200M, predibase 25M, together 25M, glm-cn 20M, doubao 15M, ai21 10M, longcat 10M, deepseek 5M, hyperbolic 5M, nscale 5M), plus permanently-free no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen, baidu …) and a $10 OpenRouter top-up unlocking +24M/mo — surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.53B free tokens per month steady, up to ~2.15B in the first month with signup credits, from the documented free tiers of 43 provider pools / 516 models behind one endpoint. Honest pool-deduped math — each shared pool counted once (counting every rate limit 24/7 would read ~10B; not published), 15 providers ToS-flagged so you decide. Budget bar of the countable free pools with per-model grid (Mistral Large 3 1B, GPT-4o mini 150M, Gemini 2.5 Flash 60M … Claude Sonnet 4.5 25K), one-time first-month signup credits (vertex 300M, agentrouter 200M, predibase 25M, together 25M, glm-cn 20M, doubao 15M, ai21 10M, longcat 10M, deepseek 5M, hyperbolic 5M, nscale 5M), plus permanently-free no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen, baidu …) and a $10 OpenRouter top-up unlocking +24M/mo — surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
|
||||
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
|
||||
>
|
||||
@@ -38,7 +38,6 @@
|
||||
[](https://github.com/diegosouzapw/OmniRoute)
|
||||
<a href="https://trendshift.io/repositories/23589" target="_blank"><img src="https://trendshift.io/api/badge/repositories/23589" alt="diegosouzapw%2FOmniRoute | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
[](https://www.star-history.com/diegosouzapw/omniroute)
|
||||
[](https://olud.ai/project/diegosouzapw-omniroute.html)
|
||||
|
||||
### 💬 Join the community
|
||||
|
||||
@@ -57,25 +56,6 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## 📈 The Gateway Keeps Growing
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
|
||||
| ------------------------- | :-----: | :---------: | :---------: |
|
||||
| 🌐 Providers | 290 | **339** | more queued |
|
||||
| 🧠 Documented models | 1185 | **1202** | — |
|
||||
| 🖼️ Modality Bridge | — | 🆕 vision | video |
|
||||
| 📡 Radar free catalog | — | — | 🔭 next |
|
||||
| ⚖️ Quota-aware scheduling | — | — | 🔭 next |
|
||||
| 📊 Quota telemetry | — | — | 🔭 next |
|
||||
|
||||
**→ [Roadmap](ROADMAP.md) — riding the rail to `v3.9.0 LTS`**
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
## 🧩 Available
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
@@ -87,46 +67,32 @@
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="right"><b>🚀 Start</b></td>
|
||||
<td align="center"><a href="#-quick-start">🚀 Quick Start</a></td>
|
||||
<td align="center"><a href="#-more-install-methods--docker-source-pnpm-arch">📦 Install</a></td>
|
||||
<td align="center"><a href="#-works-the-second-you-install-it--no-keys-no-config">🆓 Zero-config</a></td>
|
||||
<td align="center"><a href="#-quick-start"><b>🚀 Quick Start</b></a></td>
|
||||
<td align="center"><a href="#-combos--the-flagship"><b>🎯 Combos</b></a></td>
|
||||
<td align="center"><a href="#-290-ai-providers--90-free"><b>🌐 Providers</b></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right"><b>💡 Learn</b></td>
|
||||
<td align="center"><a href="#-full-cli--a2a--mcp"><b>🔌 CLI & MCP</b></a></td>
|
||||
<td align="center"><a href="#%EF%B8%8F-save-1595-tokens--automatically"><b>🗜️ Compression</b></a></td>
|
||||
<td align="center"><a href="https://omniroute.online"><b>🌍 Website</b></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="#-the-promise">💥 The Promise</a></td>
|
||||
<td align="center"><a href="#-why-omniroute">🤔 Why OmniRoute</a></td>
|
||||
<td align="center"><a href="#-why-omniroute">🤔 Why</a></td>
|
||||
<td align="center"><a href="#-what-sets-omniroute-apart">🏆 What Sets Apart</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right"><b>⚙️ Features</b></td>
|
||||
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
||||
<td align="center"><a href="#-339-ai-providers--90-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right"></td>
|
||||
<td align="center"><a href="#%EF%B8%8F-save-1595-tokens--automatically">🗜️ Compression</a></td>
|
||||
<td align="center"><a href="#-compatible-clis--coding-agents">🤖 Compatible CLIs</a></td>
|
||||
<td align="center"><a href="#%EF%B8%8F-where-omniroute-runs--anywhere">🖥️ Where It Runs</a></td>
|
||||
<td align="center"><a href="#-private--local-first">🔒 Private</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right"><b>👀 See it</b></td>
|
||||
<td align="center"><a href="#-omniroute-in-action">🎬 In Action</a></td>
|
||||
<td align="center"><a href="#-whats-new">✨ What's New</a></td>
|
||||
<td align="center"><a href="#-compatible-clis--coding-agents">🤖 Compatible CLIs</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right"><b>💚 Support</b></td>
|
||||
<td align="center"><a href="#-support-omniroute">💚 Support / Donate</a></td>
|
||||
<td align="center"><a href="#-community--help">💬 Community</a></td>
|
||||
<td align="center"><a href="#-sponsors">💖 Sponsors</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right"><b>📦 Project</b></td>
|
||||
<td align="center"><a href="#%EF%B8%8F-tech-stack">🛠️ Tech Stack</a></td>
|
||||
<td align="center"><a href="#-documentation">📖 Docs</a></td>
|
||||
<td align="center"><a href="#-500-contributors">👥 Contributors</a></td>
|
||||
<td align="center"><a href="#-dashboard-screenshots">📸 Screenshots</a></td>
|
||||
<td align="center"><a href="#-support--community">📧 Support</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -200,8 +166,6 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
<sub>Prefer a specific free backend? Call it directly, e.g. `oc/…` (OpenCode Free) or `felo/…` (Felo). Then graduate to `auto` and let OmniRoute pick.</sub>
|
||||
|
||||
<sub>📦 Copy-paste quickstart scripts for **Python, Node.js, PHP, and cURL** → [`examples/quickstart/`](examples/quickstart/)</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
@@ -210,7 +174,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 339 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 339 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 105 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 290 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 290 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 104 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -238,7 +202,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
|
||||
<a href="https://www.kimi.com/code?aff=omniroute">
|
||||
<img src="public/sponsors/kimi-k3-banner.png" width="100%" alt="Kimi K3 — Open Frontier Intelligence · 2.8T parameters · 1M-token context"/>
|
||||
</a>
|
||||
</p>
|
||||
@@ -248,7 +212,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
|
||||
<a href="https://www.kimi.com/code?aff=omniroute">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/providers/kimi-logomark-dark.svg">
|
||||
<img src="public/providers/kimi-logomark-light.svg" width="64" alt="Kimi (Moonshot AI)"/>
|
||||
@@ -260,21 +224,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<td>
|
||||
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
|
||||
<br/><br/>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<a href="https://cheaperinference.com/?utm_source=omniroute">
|
||||
<img src="public/providers/cheaperinference.svg" width="64" alt="Cheaper Inference"/>
|
||||
</a>
|
||||
<br/><b>Cheaper Inference</b><br/><sub>cheaperinference.com</sub><br/><br/>
|
||||
<img src="https://img.shields.io/badge/Open_Source_Friend-31f889?style=flat-square&labelColor=04170d" alt="Open Source Friend"/>
|
||||
</td>
|
||||
<td>
|
||||
Thanks to <b>Cheaper Inference</b>, an OmniRoute Open Source Friend, for backing this project! Cheaper Inference is a cost-ranked gateway that resells 42 frontier models — Claude, GPT-5.x, Gemini, Kimi K3, GLM, DeepSeek, Grok and MiniMax — behind one OpenAI-compatible endpoint, routing each request to the cheapest eligible provider without ever charging above the model maker's list price.
|
||||
<br/><br/>
|
||||
<b>First-class support in OmniRoute:</b> Chat Completions, the native <code>/v1/responses</code> endpoint, vision, tool calling and 3 image models (<code>grok-imagine</code>, <code>nano-banana-pro</code>, <code>nano-banana-2</code>, reachable as <code>cheaperinference/<model></code>). <a href="https://cheaperinference.com/?utm_source=omniroute"><b>Get an API key →</b></a>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -283,33 +233,6 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
<br/>
|
||||
|
||||
<details open>
|
||||
<summary><sub><b>🎟️ Affiliates Promo</b> — free signup coupons from providers we don't sponsor (click to expand)</sub></summary>
|
||||
|
||||
<sub><i>This section is for referral/coupon codes only. Sponsored partnerships live in <b>🤝 Supported by our Open Source Friends</b> above. OmniRoute has no sponsorship or partnership with the providers listed here — these are public coupons anyone can use.</i></sub>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<a href="https://agentrouter.org/register?aff=70LM">
|
||||
<img src="public/providers/agentrouter.png" width="32" alt="AgentRouter"/>
|
||||
</a>
|
||||
<br/><sub><b>AgentRouter</b></sub><br/><sub>agentrouter.org</sub>
|
||||
</td>
|
||||
<td>
|
||||
<sub><b><a href="https://agentrouter.org/register?aff=70LM">AgentRouter</a></b> — affiliate signup · <b>$100 free credits</b> on signup (free server, expect higher latency — best for testing, not production). First-class support in OmniRoute since <b>v3.8.50</b>: Chat Completions, the Anthropic-compatible wire format and the OpenAI-compatible path. Available models include <code>claude-opus-4-8</code>, <code>claude-opus-5</code>, <code>gpt-5.6-sol</code> and more. <b><a href="https://agentrouter.org/register?aff=70LM">Grab your $100 →</a></b></sub>
|
||||
<br/><br/>
|
||||
<sub>⚠️ <i>Affiliate link — OmniRoute has no sponsorship or partnership with this provider.</i></sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Know another provider with a generous free signup coupon that benefits OmniRoute users? Open an issue and we'll add it here.</sub>
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🎯 Combos — The Flagship
|
||||
@@ -429,7 +352,7 @@ All **19** strategies — mix & match per combo step:
|
||||
<tr>
|
||||
<td align="center">17</td>
|
||||
<td nowrap><code>auto</code></td>
|
||||
<td>14-factor live scoring across every connection 🤖</td>
|
||||
<td>12-factor live scoring across every connection 🤖</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">18</td>
|
||||
@@ -443,13 +366,13 @@ All **19** strategies — mix & match per combo step:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>The Auto-Combo engine scores every candidate on **14 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
<sub>The Auto-Combo engine scores every candidate on **12 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
|
||||
##
|
||||
|
||||
### 🧱 Resilience is built in (3 independent layers)
|
||||
|
||||
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 10× / API-key 15× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
|
||||
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 3× / API-key 5× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
|
||||
|
||||
<sub>📖 [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)</sub>
|
||||
|
||||
@@ -461,55 +384,19 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 339 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 105 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 290 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 104 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
## 💚 Support OmniRoute
|
||||
## ❤️ Support
|
||||
|
||||
OmniRoute is MIT-licensed and maintained in the open. If it saves you time or money, here's how to keep it independent — pick whatever fits you. Sponsorship never affects routing priority; it buys visibility, not ranking.
|
||||
OmniRoute is free and open source, built and maintained in the open. If it saves you time or money, consider supporting development:
|
||||
|
||||
<table>
|
||||
<tr><td nowrap>⭐ <b>Star the repo</b></td><td>Free — genuinely helps visibility</td><td><a href="https://github.com/diegosouzapw/OmniRoute">Star OmniRoute</a></td></tr>
|
||||
<tr><td nowrap>🐙 <b>GitHub Sponsors</b></td><td>One-off or monthly · zero platform fee</td><td><a href="https://github.com/sponsors/diegosouzapw">github.com/sponsors/diegosouzapw</a></td></tr>
|
||||
<tr><td nowrap>☕ <b>Ko-fi</b></td><td>Quick one-off tip, no signup for the donor</td><td><a href="https://ko-fi.com/diegosouzapw">ko-fi.com/diegosouzapw</a></td></tr>
|
||||
<tr><td nowrap>🧋 <b>Buy Me a Coffee</b></td><td>Small, informal gesture</td><td><a href="https://www.buymeacoffee.com/diegosouzapw">buymeacoffee.com/diegosouzapw</a></td></tr>
|
||||
<tr><td nowrap>🖐 <b>Liberapay</b></td><td>Recurring · non-profit · open source</td><td><a href="https://liberapay.com/diegosouzapw">liberapay.com/diegosouzapw</a></td></tr>
|
||||
<tr><td nowrap>🇧🇷 <b>PIX</b> (Brazil)</td><td>Instant, no fees</td><td>key & QR below</td></tr>
|
||||
<tr><td nowrap>₿ <b>Crypto</b></td><td>BTC · ETH · USDT-TRC20 · USDC-Solana</td><td>addresses below</td></tr>
|
||||
</table>
|
||||
|
||||
**🇧🇷 PIX** — instant, no fees (Brazil)
|
||||
|
||||
<img src="docs/assets/pix-qr.png" width="140" align="right" alt="OmniRoute PIX QR code"/>
|
||||
|
||||
Key (random): `5d865059-bc44-483a-962d-43ceb80126eb`
|
||||
|
||||
Pix copia-e-cola:
|
||||
|
||||
```
|
||||
00020101021126580014br.gov.bcb.pix01365d865059-bc44-483a-962d-43ceb80126eb5204000053039865802BR5922OMNIROUTE CONTRIBUICAO6006BRASIL62070503***630475DD
|
||||
```
|
||||
|
||||
<br clear="right"/>
|
||||
|
||||
<details>
|
||||
<summary><b>₿ Crypto</b> — BTC · ETH · USDT-TRC20 · USDC-Solana (click to expand)</summary>
|
||||
|
||||
<table>
|
||||
<tr><td nowrap><b>₿ BTC</b></td><td nowrap>Bitcoin (SegWit)</td><td><code>bc1qh00smz004sy85wyl28v77tenkt3ckl6eaep7fd</code></td></tr>
|
||||
<tr><td nowrap><b>Ξ ETH</b></td><td nowrap>Ethereum (ERC20)</td><td><code>0x64Cf6B68A6Ff34288e89172950a2d00102337a84</code></td></tr>
|
||||
<tr><td nowrap><b>₮ USDT</b></td><td nowrap>Tron (TRC20)</td><td><code>TKAF41JpuQrHbKTnsQa9svJE2T192Hvsc2</code></td></tr>
|
||||
<tr><td nowrap><b>$ USDC</b></td><td nowrap>Solana</td><td><code>2emNNZzVVWQc3FQ2wk9M6qXUQmW8AKdjjL174fXR28Tu</code></td></tr>
|
||||
</table>
|
||||
|
||||
<sub>⚠️ Send each coin only on the network shown — sending on the wrong network can lose the funds.</sub>
|
||||
|
||||
</details>
|
||||
|
||||
🐛 Found a bug or have feedback? Open a [Discussion](https://github.com/diegosouzapw/OmniRoute/discussions).
|
||||
- ⭐ **Star the repo** — it genuinely helps visibility
|
||||
- 💖 **[GitHub Sponsors](https://github.com/sponsors/diegosouzapw)** — fund ongoing maintenance and new providers
|
||||
- 🐛 **Report bugs and share feedback** in [Discussions](https://github.com/diegosouzapw/OmniRoute/discussions)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -519,11 +406,8 @@ Pix copia-e-cola:
|
||||
|
||||
</div>
|
||||
|
||||
> Recent highlights from **v3.8.20 → v3.8.50**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
> Recent highlights from **v3.8.20 → v3.8.49**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
- **🎛️ OmniConductor** — inbound A2A delegation to your agent fleet, Conductor skills on the Agent Card, and a dashboard panel with Faro push-to-talk voice chat. → [A2A Server](docs/frameworks/A2A-SERVER.md)
|
||||
- **🛂 Adaptive admission & overload protection** — heavyweight chat requests queue instead of 503ing, with atomic RPM rolling leases per connection. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **🗂️ Canonical `/v1/models` ordering** — one contiguous provider-grouped block per provider (combos pinned first), stable across every catalog source. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
@@ -538,7 +422,7 @@ Pix copia-e-cola:
|
||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Google Imagen, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **339-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **290-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
|
||||
|
||||
@@ -557,7 +441,7 @@ Pix copia-e-cola:
|
||||
<td align="center" width="76"><a href="https://github.com/openai/codex"><img src="./public/providers/codex.svg" width="40" alt="Codex CLI"/><br/><sub><b>Codex CLI</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/cline.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/cline.svg" width="40" alt="Cline"/></picture><br/><sub><b>Cline</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Kilo-Org/kilocode"><img src="./public/providers/kilocode.svg" width="40" alt="Kilo Code"/><br/><sub><b>Kilo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Zoo-Code-Org/Zoo-Code"><img src="./public/providers/zoocode.png" width="40" alt="Zoo Code"/><br/><sub><b>Zoo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/roocode.png#gh-dark-mode-only" width="40" alt="Roo Code"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/roocode.svg#gh-light-mode-only" width="40" alt="Roo Code"/><br/><sub><b>Roo Code</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/continue.svg" width="40" alt="Continue"/><br/><sub><b>Continue</b></sub><br/><sub> </sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -599,11 +483,11 @@ Pix copia-e-cola:
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🌐 339 AI Providers — 90+ Free
|
||||
## 🌐 290 AI Providers — 90+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> The most complete catalog of any open-source router: **339 providers**, **90+ with a free tier**, **56 free forever**.
|
||||
> The most complete catalog of any open-source router: **290 providers**, **90+ with a free tier**, **40+ free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -748,7 +632,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
|
||||
<table>
|
||||
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
|
||||
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>105 tools</b>, 31 scopes, full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>104 tools</b>, 31 scopes, full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
|
||||
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
|
||||
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
|
||||
@@ -867,7 +751,7 @@ npm install -g omniroute
|
||||
omniroute
|
||||
```
|
||||
|
||||
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/guides/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
|
||||
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/getting-started/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
|
||||
|
||||
Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
|
||||
|
||||
@@ -915,12 +799,6 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
|
||||
```
|
||||
|
||||
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
|
||||
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
|
||||
> branch. These mutable tags are intended only for testing unreleased fixes and
|
||||
> are **not supported for production**. See
|
||||
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
|
||||
|
||||
**🛠️ From source**
|
||||
|
||||
```bash
|
||||
@@ -1027,7 +905,7 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 📧 Community & Help
|
||||
# 📧 Support & Community
|
||||
|
||||
> Everything in one place — follow the maintainer, chat with the community, or open an issue.
|
||||
|
||||
@@ -1043,7 +921,7 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
| 📦 **Source code** | [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute) |
|
||||
| 🐛 **Report a bug** | [open an issue](https://github.com/diegosouzapw/OmniRoute/issues) — attach `npm run system-info` output |
|
||||
| 🤝 **Contribute** | [CONTRIBUTING.md](CONTRIBUTING.md) · [Branching & Release Model](docs/ops/BRANCHING_MODEL.md) · pick a `good first issue` |
|
||||
| 💚 **Support the project** | [Ways to support ↑](#-support-omniroute) · [GitHub Sponsors](https://github.com/sponsors/diegosouzapw) |
|
||||
| ⭐ **Support the project** | [Star the repo](https://github.com/diegosouzapw/OmniRoute) · [GitHub Sponsors](https://github.com/sponsors/diegosouzapw) |
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1061,7 +939,7 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 145 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 95 domain modules, 110 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
@@ -1122,9 +1000,9 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_RULES_FORMAT.md">Compression Rules Format</a></b></td><td>JSON rule-pack schemas for Caveman and RTK filters</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_LANGUAGE_PACKS.md">Compression Language Packs</a></b></td><td>Language detection and Caveman rule-pack authoring</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>14-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>12-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>90+ free providers consolidated directory (42 documented token pools / 495 models)</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>25+ free API providers consolidated directory</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
|
||||
</table>
|
||||
@@ -1135,7 +1013,7 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
<tr><th align="left">Document</th><th align="left">Description</th></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/API_REFERENCE.md">API Reference</a></b></td><td>All endpoints with examples</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/openapi.yaml">OpenAPI Spec</a></b></td><td>OpenAPI 3.0 specification</td></tr>
|
||||
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>105 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
|
||||
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>104 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/frameworks/MCP-SERVER.md">MCP Server Guide</a></b></td><td>MCP installation, transports, and tool reference</td></tr>
|
||||
<tr><td nowrap><b><a href="src/lib/a2a/README.md">A2A Server</a></b></td><td>JSON-RPC 2.0 protocol, skills, streaming, task mgmt</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/frameworks/A2A-SERVER.md">A2A Server Guide</a></b></td><td>A2A agent card, tasks, skills, and streaming</td></tr>
|
||||
@@ -1149,7 +1027,7 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
<tr><td nowrap><b><a href="docs/ops/BRANCHING_MODEL.md">Branching & Release Model</a></b></td><td>Where PRs target (<code>release/*</code>), what <code>main</code> and tags mean</td></tr>
|
||||
<tr><td nowrap><b><a href="CHANGELOG.md">Changelog</a></b></td><td>Full per-version release history</td></tr>
|
||||
<tr><td nowrap><b><a href="SECURITY.md">Security Policy</a></b></td><td>Vulnerability reporting and security practices</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>43-language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>40+ language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/RELEASE_CHECKLIST.md">Release Checklist</a></b></td><td>Pre-release validation steps</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy and 25,000+ test suite</td></tr>
|
||||
</table>
|
||||
@@ -1292,7 +1170,7 @@ A heartfelt thank-you to the people who fund OmniRoute out of their own pocket
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 👥 320+ Contributors
|
||||
## 👥 500+ Contributors
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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** | 13 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, 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
|
||||
|
||||
|
||||
@@ -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,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/
|
||||
|
||||
@@ -139,21 +139,6 @@ export function shouldRetryError(err, opts = {}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a non-2xx status means "this server does not serve this route"
|
||||
* rather than "your request was wrong".
|
||||
*
|
||||
* Commands that keep a local SQLite fallback must not treat these as fatal:
|
||||
* a CLI newer (or older) than the server it is talking to will hit routes that
|
||||
* simply are not mounted, and aborting there strands the user with an
|
||||
* unactionable `HTTP 404` even though the local path would have worked.
|
||||
* Genuine client errors (400/401/403/409/422 …) stay fatal — retrying them
|
||||
* locally would paper over a real problem.
|
||||
*/
|
||||
export function isRouteUnavailableStatus(status) {
|
||||
return status === 404 || status === 405 || status === 501;
|
||||
}
|
||||
|
||||
export function statusToExitCode(status) {
|
||||
if (status >= 200 && status < 300) return 0;
|
||||
if (status === 408) return 124;
|
||||
|
||||
@@ -152,7 +152,6 @@ export async function runComboListCommand(opts = {}) {
|
||||
return await withRuntime(async ({ kind, api, db }) => {
|
||||
let combos = [];
|
||||
let activeCombo = null;
|
||||
let listError = null;
|
||||
|
||||
if (kind === "http") {
|
||||
const [listRes, activeRes] = await Promise.all([
|
||||
@@ -162,12 +161,6 @@ export async function runComboListCommand(opts = {}) {
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json();
|
||||
combos = Array.isArray(data) ? data : (data.combos ?? []);
|
||||
} else {
|
||||
// The server answered, but not with a combo list. Falling through to
|
||||
// an empty array here rendered "No combos configured" — which is
|
||||
// indistinguishable from genuine emptiness and reads as real state,
|
||||
// so a transport/auth failure looked like a wiped configuration.
|
||||
listError = listRes.status;
|
||||
}
|
||||
if (activeRes.ok) {
|
||||
const settings = await activeRes.json();
|
||||
@@ -178,25 +171,11 @@ export async function runComboListCommand(opts = {}) {
|
||||
}
|
||||
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ combos, active: activeCombo, error: listError && `HTTP ${listError}` },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return listError ? 1 : 0;
|
||||
console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
printHeading(t("combo.title"));
|
||||
if (listError) {
|
||||
console.error(
|
||||
t("common.error", {
|
||||
message: `could not list combos from the server (HTTP ${listError})`,
|
||||
})
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (combos.length === 0) {
|
||||
console.log(t("combo.noCombos"));
|
||||
return 0;
|
||||
|
||||
@@ -288,44 +288,18 @@ async function checkNodeRuntime(rootDir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the prebuilt binary better-sqlite3 ships for this platform, e.g.
|
||||
* `linux-x64.node`. Musl-based Linux uses a distinct `linuxmusl-` prefix.
|
||||
* Mirrors the lookup `prebuild-install`/`node-gyp-build` perform at require time.
|
||||
*/
|
||||
export function prebuiltBinaryName(
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
report = process.report
|
||||
) {
|
||||
let prefix = platform;
|
||||
if (platform === "linux") {
|
||||
let isMusl = false;
|
||||
try {
|
||||
// glibc builds expose `glibcVersionRuntime`; musl builds do not.
|
||||
isMusl = !report?.getReport?.()?.header?.glibcVersionRuntime;
|
||||
} catch {
|
||||
isMusl = false;
|
||||
}
|
||||
prefix = isMusl ? "linuxmusl" : "linux";
|
||||
}
|
||||
return `${prefix}-${arch}.node`;
|
||||
}
|
||||
|
||||
async function checkNativeBinary(rootDir) {
|
||||
// node-gyp layout — present only when better-sqlite3 was compiled locally.
|
||||
const buildRoots = [
|
||||
path.join(rootDir, "app", "node_modules", "better-sqlite3"),
|
||||
path.join(rootDir, "dist", "node_modules", "better-sqlite3"),
|
||||
path.join(rootDir, "node_modules", "better-sqlite3"),
|
||||
];
|
||||
const prebuildName = prebuiltBinaryName();
|
||||
const candidates = [
|
||||
...buildRoots.map((root) => path.join(root, "build", "Release", "better_sqlite3.node")),
|
||||
// Prebuilt layout — what `npm i -g omniroute` actually installs. Without
|
||||
// these, doctor warns on every prebuilt install even though the binary is
|
||||
// present and loading fine.
|
||||
...buildRoots.map((root) => path.join(root, "prebuilds", prebuildName)),
|
||||
path.join(
|
||||
rootDir,
|
||||
"app",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
),
|
||||
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
|
||||
];
|
||||
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!binaryPath) {
|
||||
@@ -421,10 +395,7 @@ async function checkServerLiveness(options = {}) {
|
||||
// First attempt: configured health endpoint (may require auth token).
|
||||
const primary = await probeUrl(url);
|
||||
if (primary.ok) {
|
||||
return ok("Server liveness", "Server health endpoint is reachable", {
|
||||
url,
|
||||
status: primary.status,
|
||||
});
|
||||
return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status });
|
||||
}
|
||||
|
||||
// #6162: /api/health and /api/health/degradation require a management token.
|
||||
@@ -455,12 +426,7 @@ async function checkServerLiveness(options = {}) {
|
||||
return ok(
|
||||
"Server liveness",
|
||||
`Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`,
|
||||
{
|
||||
primaryUrl: url,
|
||||
primaryStatus: primary.status,
|
||||
fallbackUrl,
|
||||
fallbackStatus: fallback.status,
|
||||
}
|
||||
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -473,7 +439,8 @@ async function checkServerLiveness(options = {}) {
|
||||
|
||||
export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
const rootDir =
|
||||
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
context.rootDir ||
|
||||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const dataDir = resolveDataDir();
|
||||
const dbPath = resolveStoragePath(dataDir);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../provider-store.mjs";
|
||||
import { openOmniRouteDb } from "../sqlite.mjs";
|
||||
import { loadAvailableProviders } from "../provider-catalog.mjs";
|
||||
import { apiFetch, isServerUp, isRouteUnavailableStatus } from "../api.mjs";
|
||||
import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
function getValidProviderIds() {
|
||||
@@ -184,10 +184,7 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) {
|
||||
console.log(t("keys.added", { provider: providerLower }));
|
||||
return 0;
|
||||
}
|
||||
// A missing route means this server does not implement the endpoint —
|
||||
// fall through to the local SQLite path below rather than stranding the
|
||||
// user. Real client errors still abort.
|
||||
if (res.status >= 400 && res.status < 500 && !isRouteUnavailableStatus(res.status)) {
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
|
||||
|
||||
/**
|
||||
* Probe PATH for a Windows executable via `where.exe`, preferring a `.exe` over
|
||||
* a `.cmd`/`.bat` shim. Returns the absolute path to the preferred binary, or
|
||||
* `null` when `where.exe` finds nothing (or cannot run). Mirrors the same probe
|
||||
* in launch.mjs and `locateCommand()` in `src/shared/services/cliRuntime.ts`.
|
||||
*
|
||||
* @param {string} command bare command name to look up
|
||||
* @returns {Promise<string|null>} absolute path to the preferred match, or null
|
||||
*/
|
||||
function probeWindowsBinary(command) {
|
||||
try {
|
||||
const out = execFileSync("where.exe", [command], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const lines = out
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
const winExt = /\.(exe|cmd|bat|com)$/i;
|
||||
return lines.find((l) => winExt.test(l)) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url
|
||||
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
|
||||
* free-claude-code's codex adapter. NOTE: this does NOT silence codex's
|
||||
@@ -52,25 +23,11 @@ const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
// On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve
|
||||
// without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263):
|
||||
// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere.
|
||||
//
|
||||
// #9454: the native codex installer may ship a real `codex.exe` instead of the
|
||||
// npm `.cmd` shim. Probe PATH for `codex` first: when `where.exe` resolves a
|
||||
// `.exe`, spawn it directly (no shell — cmd.exe would split an absolute path
|
||||
// with spaces); otherwise fall back to `codex.cmd` + shell. Off Windows the bare
|
||||
// binary is spawned unchanged (no shell, no probe).
|
||||
/**
|
||||
* @param {NodeJS.Platform|string} platform
|
||||
* @param {{ probe?: (command: string) => Promise<string|null> }} [opts] injectable probe for tests
|
||||
* @returns {Promise<{ command: string, shell: true|undefined }>}
|
||||
*/
|
||||
export async function resolveCodexSpawn(platform, opts = {}) {
|
||||
if (platform !== "win32") return { command: "codex", shell: undefined };
|
||||
const probe = opts.probe ?? probeWindowsBinary;
|
||||
const located = await probe("codex");
|
||||
if (located && /\.exe$/i.test(located)) {
|
||||
return { command: located, shell: undefined };
|
||||
export function resolveCodexSpawn(platform) {
|
||||
if (platform === "win32") {
|
||||
return { command: "codex.cmd", shell: true };
|
||||
}
|
||||
return { command: "codex.cmd", shell: true };
|
||||
return { command: "codex", shell: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,9 +169,8 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
|
||||
const { command: codexLaunch, shell: shellValue } = await resolveCodexSpawn(process.platform);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform);
|
||||
const child = spawn(codexLaunch, quoteCodexArgs(extraArgs, process.platform), {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
@@ -92,61 +92,17 @@ export function resolveLaunchTarget(opts = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe PATH for a Windows executable via `where.exe`, preferring a `.exe` over
|
||||
* a `.cmd`/`.bat` shim. Returns the absolute path to the preferred binary, or
|
||||
* `null` when `where.exe` finds nothing (or cannot run).
|
||||
*
|
||||
* The native Anthropic installer (#9454) creates only `claude.exe` (no npm
|
||||
* `.cmd` shim), so the launcher must look for the real PE and spawn it without
|
||||
* a shell. Mirrors the existing `locateCommand()` probe in
|
||||
* `src/shared/services/cliRuntime.ts`.
|
||||
*
|
||||
* @param {string} command bare command name to look up
|
||||
* @returns {Promise<string|null>} absolute path to the preferred match, or null
|
||||
*/
|
||||
function probeWindowsBinary(command) {
|
||||
try {
|
||||
const out = execFileSync("where.exe", [command], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const lines = out
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
const winExt = /\.(exe|cmd|bat|com)$/i;
|
||||
return lines.find((l) => winExt.test(l)) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #8246 / #9454: on Windows, npm installs claude as a `.cmd` shim — spawn()
|
||||
* without a shell cannot resolve PATHEXT shims (and Node refuses to exec `.cmd`
|
||||
* directly since CVE-2024-27980), so the npm-shim path must go through cmd.exe.
|
||||
* But the native installer creates only `claude.exe`, which is a real PE that
|
||||
* must NOT go through a shell (cmd.exe would split an absolute path with spaces).
|
||||
*
|
||||
* So probe PATH for `claude` first: when `where.exe` resolves a `.exe`, spawn it
|
||||
* directly (no shell); otherwise fall back to the npm `claude.cmd` + shell. Off
|
||||
* Windows the bare binary is spawned unchanged (no shell, no probe).
|
||||
* #8246: on Windows, npm installs claude as a `.cmd` shim — spawn() without a
|
||||
* shell cannot resolve PATHEXT shims (and Node refuses to exec `.cmd` directly
|
||||
* since CVE-2024-27980), so the Windows path must go through cmd.exe.
|
||||
*
|
||||
* @param {NodeJS.Platform|string} platform
|
||||
* @param {{ probe?: (command: string) => Promise<string|null> }} [opts] injectable probe for tests
|
||||
* @returns {Promise<{ command: string, shell: true|undefined }>}
|
||||
* @returns {{ command: string, shell: true|undefined }}
|
||||
*/
|
||||
export async function resolveClaudeSpawn(platform, opts = {}) {
|
||||
if (platform !== "win32") return { command: "claude", shell: undefined };
|
||||
const probe = opts.probe ?? probeWindowsBinary;
|
||||
const located = await probe("claude");
|
||||
if (located && /\.exe$/i.test(located)) {
|
||||
return { command: located, shell: undefined };
|
||||
}
|
||||
return { command: "claude.cmd", shell: true };
|
||||
export function resolveClaudeSpawn(platform) {
|
||||
return platform === "win32"
|
||||
? { command: "claude.cmd", shell: true }
|
||||
: { command: "claude", shell: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,9 +148,8 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
: undefined;
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
||||
|
||||
const { command, shell } = await resolveClaudeSpawn(process.platform);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const { command, shell } = resolveClaudeSpawn(process.platform);
|
||||
const child = spawn(command, quoteClaudeArgs(claudeArgs, process.platform), {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -19,19 +19,6 @@ import { randomUUID } from "node:crypto";
|
||||
*
|
||||
* It talks ONLY to Google (no OmniRoute server needed locally), so it works even
|
||||
* if the remote VPS is firewalled from the user's machine.
|
||||
*
|
||||
* Push mode: when an active remote context exists (`omniroute connect <host>`), the
|
||||
* blob is POSTed straight to that install instead of being printed for a manual
|
||||
* copy-paste — every piece was already in place:
|
||||
*
|
||||
* - the context carries an admin-scoped token, and `apiFetch()` injects it;
|
||||
* - `/api/oauth` requires admin scope (src/server/authz/accessScopes.ts) and stays
|
||||
* remote-reachable — routeGuard.ts loopback-gates only `/api/oauth/cursor/auto-import`;
|
||||
* - `/api/oauth/<provider>/paste-credentials` already decodes the blob and persists.
|
||||
*
|
||||
* The push NEVER becomes a hard requirement: this helper exists precisely because it
|
||||
* needs no route to the VPS, so a failed push falls back to printing the blob rather
|
||||
* than losing an authorization the operator just completed in their browser.
|
||||
*/
|
||||
|
||||
const PROVIDER = "antigravity";
|
||||
@@ -67,7 +54,7 @@ function defaultStartServer(preferredPort) {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(
|
||||
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
|
||||
'<body style="font-family:system-ui;padding:2rem">' +
|
||||
"<body style=\"font-family:system-ui;padding:2rem\">" +
|
||||
"<h2>✅ Authorization received</h2>" +
|
||||
"<p>Return to your terminal — you can close this tab.</p></body>"
|
||||
);
|
||||
@@ -86,51 +73,6 @@ function defaultStartServer(preferredPort) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this context pointing at another machine? Loopback (and an unresolvable value)
|
||||
* counts as local, so we never auto-push somewhere we cannot reason about.
|
||||
*/
|
||||
export function isRemoteBaseUrl(baseUrl) {
|
||||
if (!baseUrl) return false;
|
||||
try {
|
||||
const { hostname } = new URL(baseUrl);
|
||||
const host = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
||||
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a credential blob to the active context's install. Never throws: the caller
|
||||
* decides whether a failure is fatal (it is not — it falls back to printing).
|
||||
*/
|
||||
export async function pushCredentialBlob(provider, blob, deps = {}) {
|
||||
try {
|
||||
const fetchImpl = deps.fetchImpl ?? (await import("../api.mjs")).apiFetch;
|
||||
const res = await fetchImpl(`/api/oauth/${provider}/paste-credentials`, {
|
||||
method: "POST",
|
||||
body: { blob },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.success === false) {
|
||||
const message =
|
||||
(typeof data?.error === "string" ? data.error : data?.error?.message) ||
|
||||
`HTTP ${res.status}`;
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
return { ok: true, connectionId: data?.connection?.id };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the active CLI context (baseUrl + scoped token) written by `omniroute connect`. */
|
||||
async function defaultResolveContext(overrideName) {
|
||||
const { resolveActiveContext } = await import("../contexts.mjs");
|
||||
return resolveActiveContext(overrideName);
|
||||
}
|
||||
|
||||
/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */
|
||||
async function loadDeps() {
|
||||
const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts");
|
||||
@@ -211,41 +153,10 @@ export async function runAntigravityLogin(opts = {}, deps = {}) {
|
||||
const tokens = await exchange(params.code, redirectUri);
|
||||
const blob = encodeCredentialBlob({ provider: PROVIDER, tokens });
|
||||
|
||||
// Push when the operator explicitly asked, or when the active context already points
|
||||
// at another machine — that is exactly the situation this helper was built for.
|
||||
const resolveContext = deps.resolveContext ?? defaultResolveContext;
|
||||
const push = deps.push ?? pushCredentialBlob;
|
||||
let context = null;
|
||||
try {
|
||||
context = await resolveContext(opts.context);
|
||||
} catch {
|
||||
// No usable context store — fall through to printing.
|
||||
}
|
||||
const wantsPush =
|
||||
opts.push === true || (opts.push !== false && isRemoteBaseUrl(context?.baseUrl));
|
||||
|
||||
if (wantsPush) {
|
||||
log(`\nSending the credential to ${context?.baseUrl || "the active context"}...\n`);
|
||||
const result = await push(PROVIDER, blob, { context });
|
||||
if (result?.ok) {
|
||||
log(
|
||||
`Antigravity connected on ${context?.baseUrl || "the remote install"}` +
|
||||
`${result.connectionId ? ` (connection ${result.connectionId})` : ""}.\n` +
|
||||
"Nothing to paste — you can close this terminal.\n"
|
||||
);
|
||||
// Deliberately NOT printed: the blob wraps a refresh token and it already landed.
|
||||
return blob;
|
||||
}
|
||||
log(
|
||||
`\nCould not deliver the credential automatically: ${result?.error || "unknown error"}\n` +
|
||||
"Falling back to manual paste — the authorization itself is still valid.\n"
|
||||
);
|
||||
}
|
||||
|
||||
print(
|
||||
"\n" +
|
||||
"Antigravity authorized. Copy the line below and paste it into your remote\n" +
|
||||
'OmniRoute dashboard: Providers → Antigravity → Connect → "Paste credentials".\n' +
|
||||
"OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" +
|
||||
"(This contains a refresh token — treat it like a password.)\n\n" +
|
||||
blob +
|
||||
"\n\n"
|
||||
@@ -259,8 +170,6 @@ async function runLoginAntigravity(opts) {
|
||||
browser: opts.browser,
|
||||
timeout: opts.timeout,
|
||||
port: opts.port,
|
||||
push: opts.push,
|
||||
context: opts.context,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`\nLogin failed: ${err?.message || err}\n`);
|
||||
@@ -279,11 +188,5 @@ export function registerLogin(program) {
|
||||
.option("--no-browser", "Do not auto-open the browser; print the URL instead")
|
||||
.option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10))
|
||||
.option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000)
|
||||
.option(
|
||||
"--push",
|
||||
"Send the credential to the active context instead of printing it (default when that context is remote)"
|
||||
)
|
||||
.option("--no-push", "Always print the blob, never contact the server")
|
||||
.option("--context <name>", "Push to this context instead of the active one")
|
||||
.action(runLoginAntigravity);
|
||||
}
|
||||
|
||||
@@ -6,31 +6,15 @@ import { t } from "../i18n.mjs";
|
||||
const PROVIDERS_WITH_OAUTH = [
|
||||
{ id: "gemini", name: "Google Gemini", flow: "browser" },
|
||||
{ id: "antigravity", name: "Antigravity", flow: "browser" },
|
||||
{ id: "windsurf", name: "Windsurf", flow: "browser" },
|
||||
{ id: "cursor", name: "Cursor", flow: "import" },
|
||||
{ id: "zed", name: "Zed", flow: "import" },
|
||||
{ id: "kiro", name: "Amazon Kiro", flow: "social" },
|
||||
{ id: "claude-code", name: "Claude Code (OAuth)", flow: "browser" },
|
||||
{ id: "claude-code", name: "Claude Code (OAuth)", flow: "device" },
|
||||
{ id: "codex", name: "OpenAI Codex (OAuth)", flow: "device" },
|
||||
{ id: "copilot", name: "GitHub Copilot", flow: "device" },
|
||||
];
|
||||
|
||||
// The user-facing provider id (the one shown by `omniroute oauth providers`)
|
||||
// is NOT always the backend OAuth provider key the server's /api/oauth/[provider]/...
|
||||
// route expects. `claude-code` is the CLI-facing alias for Anthropic's Claude
|
||||
// OAuth, which the server registers under the key `claude` (see
|
||||
// src/lib/oauth/providers/index.ts). Routing `claude-code` to the unrelated
|
||||
// `command-code` (CommandCode.ai) provider — as the previous code did — sent
|
||||
// the device-flow request to /api/providers/command-code/auth/start, which is
|
||||
// gated by requireManagementAuth and returned 401 for a fresh CLI context
|
||||
// (issue #9474). Map the alias to the real backend key instead.
|
||||
const BACKEND_OAUTH_KEY = {
|
||||
"claude-code": "claude",
|
||||
};
|
||||
|
||||
function resolveBackendKey(id) {
|
||||
return BACKEND_OAUTH_KEY[id] ?? id;
|
||||
}
|
||||
|
||||
const oauthProviderSchema = [
|
||||
{ key: "id", header: "Provider ID", width: 16 },
|
||||
{ key: "name", header: "Name", width: 28 },
|
||||
@@ -72,107 +56,32 @@ async function pollStatus(endpoint, timeoutMs) {
|
||||
}
|
||||
|
||||
async function runBrowserFlow(def, opts) {
|
||||
// The user-facing id (`def.id`, e.g. "claude-code") must be translated to the
|
||||
// backend OAuth provider key the server's /api/oauth/[provider]/... route
|
||||
// expects (e.g. "claude"). The previous implementation called a non-existent
|
||||
// `/api/oauth/${def.id}/start` action — no such action exists on the server
|
||||
// (src/app/api/oauth/[provider]/[action]/route.ts), so the browser flow was
|
||||
// broken for every browser-flow provider. Use the real `authorize` action and
|
||||
// complete the PKCE (authorization_code / authorization_code_pkce) flow with a
|
||||
// manual code paste, mirroring the dashboard's manual "input" step.
|
||||
const backendKey = resolveBackendKey(def.id);
|
||||
const redirectUri = opts.redirectUri ?? null;
|
||||
const authorizeUrl = `/api/oauth/${backendKey}/authorize${
|
||||
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
|
||||
}`;
|
||||
const startRes = await apiFetch(authorizeUrl, { method: "GET" });
|
||||
const startRes = await apiFetch(`/api/oauth/${def.id}/start`, { method: "POST" });
|
||||
if (!startRes.ok) {
|
||||
const detail = await safeErrorBody(startRes);
|
||||
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
|
||||
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const start = await startRes.json();
|
||||
const url = start.authUrl ?? start.authorizeUrl ?? start.url;
|
||||
if (!url) {
|
||||
const hint = start.error ?? "no authUrl returned by the server";
|
||||
process.stderr.write(`OAuth unavailable for ${def.id}: ${hint}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { codeVerifier, state, redirectUri: returnedRedirectUri } = start;
|
||||
const finalRedirectUri = returnedRedirectUri || redirectUri;
|
||||
const url = start.authorizeUrl ?? start.url;
|
||||
|
||||
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
|
||||
if (opts.browser !== false) await openBrowser(url);
|
||||
if (process.stdout.isTTY && opts.browser !== false) {
|
||||
const { startOAuthTui } = await import("../tui/OAuthFlow.jsx");
|
||||
await openBrowser(url);
|
||||
const tuiResult = await startOAuthTui({ provider: def.name ?? def.id, url });
|
||||
if (tuiResult.status === "cancelled") return;
|
||||
} else {
|
||||
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
|
||||
if (opts.browser !== false) await openBrowser(url);
|
||||
process.stderr.write("Waiting for authorization... (Ctrl+C to cancel)\n");
|
||||
}
|
||||
|
||||
const result = await pollStatus(
|
||||
`/api/oauth/${def.id}/status?state=${encodeURIComponent(start.state ?? "")}`,
|
||||
opts.timeout ?? 300000
|
||||
);
|
||||
process.stdout.write(
|
||||
"After authorizing, paste the callback URL (or the Authentication Code\n" +
|
||||
"shown on the confirmation page) here:\n"
|
||||
`Authorized: ${result.email ?? result.userId ?? result.account ?? "connected"}\n`
|
||||
);
|
||||
|
||||
const { createPrompt } = await import("../io.mjs");
|
||||
const prompt = createPrompt();
|
||||
const input = await prompt.ask("Callback URL or code");
|
||||
prompt.close();
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
process.stderr.write("No authorization code provided.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The Anthropic Claude confirmation page (platform.claude.com/oauth/code/callback)
|
||||
// shows a raw "Authentication Code" like `code#state` rather than a full URL.
|
||||
// The dashboard's manual submit (src/shared/components/OAuthModal.tsx) parses
|
||||
// both forms; mirror that here.
|
||||
let code = null;
|
||||
let codeState = state || null;
|
||||
try {
|
||||
const cbUrl = new URL(trimmed);
|
||||
code = cbUrl.searchParams.get("code");
|
||||
const stateParam = cbUrl.searchParams.get("state") || cbUrl.hash.replace(/^#/, "");
|
||||
if (stateParam) codeState = stateParam;
|
||||
} catch {
|
||||
const [rawCode, rawState] = trimmed.split("#", 2);
|
||||
code = rawCode || null;
|
||||
if (rawState) codeState = rawState;
|
||||
}
|
||||
if (!code) {
|
||||
process.stderr.write(
|
||||
"No authorization code found. Paste the callback URL or the Authentication Code.\n"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
code,
|
||||
redirectUri: finalRedirectUri,
|
||||
codeVerifier,
|
||||
...(codeState ? { state: codeState } : {}),
|
||||
},
|
||||
});
|
||||
if (!exchangeRes.ok) {
|
||||
const detail = await safeErrorBody(exchangeRes);
|
||||
process.stderr.write(`Token exchange failed: ${exchangeRes.status}${detail}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = await exchangeRes.json();
|
||||
const conn = result.connection ?? {};
|
||||
process.stdout.write(`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`);
|
||||
}
|
||||
|
||||
async function safeErrorBody(res) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error) {
|
||||
const msg = typeof data.error === "string" ? data.error : data.error?.message;
|
||||
if (msg) return `: ${msg}`;
|
||||
}
|
||||
if (data?.message) return `: ${data.message}`;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function runImportFlow(def, opts) {
|
||||
@@ -215,7 +124,7 @@ async function runSocialFlow(def, opts) {
|
||||
}
|
||||
|
||||
async function runDeviceFlow(def, opts) {
|
||||
const providerKey = resolveBackendKey(def.id);
|
||||
const providerKey = def.id === "claude-code" ? "command-code" : def.id;
|
||||
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" });
|
||||
if (!startRes.ok) {
|
||||
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
|
||||
|
||||
@@ -41,83 +41,11 @@ function toYaml(obj, indent = 0) {
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
// Keys that live alongside operations inside a Path Item Object but are not
|
||||
// themselves operations (OpenAPI 3.x Path Item fields).
|
||||
const NON_OPERATION_PATH_KEYS = new Set([
|
||||
"parameters",
|
||||
"summary",
|
||||
"description",
|
||||
"servers",
|
||||
"$ref",
|
||||
]);
|
||||
|
||||
/**
|
||||
* `GET /api/openapi/spec` answers with a compact catalog
|
||||
* (`{ info, servers, tags, endpoints[], schemas }`) rather than an OpenAPI
|
||||
* document with a `paths` object, while `dist/docs/openapi.yaml` is a real
|
||||
* spec. Normalize either shape into the flat rows the CLI renders so the
|
||||
* commands work against both instead of silently printing nothing.
|
||||
*/
|
||||
export function extractEndpoints(spec) {
|
||||
if (!spec || typeof spec !== "object") return [];
|
||||
|
||||
if (spec.paths && typeof spec.paths === "object") {
|
||||
const rows = [];
|
||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||
if (!pathItem || typeof pathItem !== "object") continue;
|
||||
for (const [method, def] of Object.entries(pathItem)) {
|
||||
if (NON_OPERATION_PATH_KEYS.has(method)) continue;
|
||||
if (!def || typeof def !== "object") continue;
|
||||
rows.push({
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: def.summary ?? def.description ?? "",
|
||||
operationId: def.operationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (Array.isArray(spec.endpoints)) {
|
||||
return spec.endpoints
|
||||
.filter((entry) => entry && typeof entry === "object" && entry.path)
|
||||
.map((entry) => ({
|
||||
method: String(entry.method ?? "GET").toUpperCase(),
|
||||
path: entry.path,
|
||||
summary: entry.summary ?? entry.description ?? "",
|
||||
operationId: entry.operationId,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Sorted, de-duplicated list of paths across either shape. */
|
||||
export function extractPaths(spec) {
|
||||
return [...new Set(extractEndpoints(spec).map((row) => row.path))].sort();
|
||||
}
|
||||
|
||||
function matchesSearch(row, query) {
|
||||
if (!query) return true;
|
||||
const needle = query.toLowerCase();
|
||||
return row.path.includes(query) || String(row.summary).toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
function validateBasic(spec) {
|
||||
if (!spec || typeof spec !== "object") throw new Error("spec is not an object");
|
||||
if (!spec.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field");
|
||||
if (!spec.info) throw new Error("missing info object");
|
||||
|
||||
// A real OpenAPI document must carry a version field and a paths object.
|
||||
if (spec.openapi || spec.swagger) {
|
||||
if (!spec.paths) throw new Error("missing paths object");
|
||||
return;
|
||||
}
|
||||
|
||||
// The compact catalog served by /api/openapi/spec carries endpoints[] instead.
|
||||
if (Array.isArray(spec.endpoints)) return;
|
||||
|
||||
throw new Error("missing openapi/swagger version field and no endpoints[] catalog");
|
||||
if (!spec.paths) throw new Error("missing paths object");
|
||||
}
|
||||
|
||||
const endpointSchema = [
|
||||
@@ -204,7 +132,20 @@ export function registerOpenapi(program) {
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = await res.json();
|
||||
const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search));
|
||||
const rows = [];
|
||||
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, def] of Object.entries(methods)) {
|
||||
if (["parameters", "summary"].includes(method)) continue;
|
||||
const summary = def.summary ?? def.description ?? "";
|
||||
if (
|
||||
opts.search &&
|
||||
!path.includes(opts.search) &&
|
||||
!summary.toLowerCase().includes(opts.search.toLowerCase())
|
||||
)
|
||||
continue;
|
||||
rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId });
|
||||
}
|
||||
}
|
||||
emit(rows, cmd.optsWithGlobals(), endpointSchema);
|
||||
});
|
||||
|
||||
@@ -218,8 +159,9 @@ export function registerOpenapi(program) {
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = await res.json();
|
||||
const paths = Object.keys(spec.paths ?? {}).sort();
|
||||
emit(
|
||||
extractPaths(spec).map((p) => ({ path: p })),
|
||||
paths.map((p) => ({ path: p })),
|
||||
cmd.optsWithGlobals()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,33 +129,9 @@ function buildTestInput(connection, apiKey) {
|
||||
}
|
||||
|
||||
async function runProviderTest(db, connection) {
|
||||
// Only API-key connections can be probed with a stored credential. OAuth /
|
||||
// no-auth connections have nothing for testProviderApiKey() to send, and
|
||||
// getProviderApiKey() throws for them by design — reporting that as a FAILED
|
||||
// test marked perfectly healthy OAuth connections as broken *and* persisted
|
||||
// that verdict to provider_connections.test_status.
|
||||
if (connection.authType !== "apikey") {
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
valid: false,
|
||||
skipped: true,
|
||||
error: `No API-key probe for ${connection.authType || "unknown"} connections`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = getProviderApiKey(connection);
|
||||
const result = await testProviderApiKey(buildTestInput(connection, apiKey));
|
||||
// PROVIDER_TEST_CONFIGS only knows a handful of providers; "unsupported"
|
||||
// means the CLI has no probe recipe, not that the provider is unhealthy.
|
||||
// Persisting it would overwrite a good test_status with a failure.
|
||||
if (result.unsupported) {
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
...result,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
updateProviderTestResult(db, connection.id, result);
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
|
||||
@@ -10,25 +10,9 @@ const DEFAULT_IMAGE = "docker.io/redis:7-alpine";
|
||||
const DEFAULT_NAME = "omniroute-redis";
|
||||
const DEFAULT_PORT = "6379";
|
||||
const DEFAULT_VOLUME = "omniroute-redis-data";
|
||||
// The launcher starts Redis without AUTH unless --password is given, so the
|
||||
// published port stays on loopback. `-p 6379:6379` would bind 0.0.0.0 and hand
|
||||
// the whole LAN an unauthenticated Redis.
|
||||
const DEFAULT_BIND = "127.0.0.1";
|
||||
|
||||
const RUNTIME_PREFERENCE = ["podman", "docker"];
|
||||
|
||||
/**
|
||||
* Build the `-p` publish spec for the Redis container.
|
||||
* Always host-qualified so the runtime never falls back to 0.0.0.0.
|
||||
*/
|
||||
export function buildRedisPublishSpec(bind = DEFAULT_BIND, port = DEFAULT_PORT) {
|
||||
const host = String(bind || DEFAULT_BIND).trim() || DEFAULT_BIND;
|
||||
const hostPort = String(port || DEFAULT_PORT).trim() || DEFAULT_PORT;
|
||||
// Bracket IPv6 literals (e.g. ::1) so `host:port:port` stays unambiguous.
|
||||
const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
return `${normalizedHost}:${hostPort}:6379`;
|
||||
}
|
||||
|
||||
async function detectRuntime() {
|
||||
for (const candidate of RUNTIME_PREFERENCE) {
|
||||
try {
|
||||
@@ -43,14 +27,7 @@ async function detectRuntime() {
|
||||
|
||||
async function containerExists(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, [
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
`name=^${name}$`,
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
]);
|
||||
const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -59,13 +36,7 @@ async function containerExists(runtime, name) {
|
||||
|
||||
async function containerRunning(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, [
|
||||
"ps",
|
||||
"--filter",
|
||||
`name=^${name}$`,
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
]);
|
||||
const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -129,11 +100,6 @@ export function registerRedis(program) {
|
||||
.command("up")
|
||||
.description("Start the local Redis container")
|
||||
.option("-p, --port <port>", "Host port to expose", DEFAULT_PORT)
|
||||
.option(
|
||||
"-b, --bind <host>",
|
||||
"Host interface to publish on (use 0.0.0.0 only together with --password)",
|
||||
DEFAULT_BIND
|
||||
)
|
||||
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
|
||||
.option("-i, --image <image>", "Container image", DEFAULT_IMAGE)
|
||||
.option("--no-pull", "Skip pulling the image if it is missing")
|
||||
@@ -194,7 +160,6 @@ export async function runRedisUpCommand(opts = {}) {
|
||||
|
||||
const name = opts.name || DEFAULT_NAME;
|
||||
const port = opts.port || DEFAULT_PORT;
|
||||
const bind = opts.bind || DEFAULT_BIND;
|
||||
const image = opts.image || DEFAULT_IMAGE;
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
@@ -221,11 +186,7 @@ export async function runRedisUpCommand(opts = {}) {
|
||||
info(`Checking if image '${image}' is present locally…`);
|
||||
let present = false;
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, [
|
||||
"images",
|
||||
"--format",
|
||||
"{{.Repository}}:{{.Tag}}",
|
||||
]);
|
||||
const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]);
|
||||
present = stdout.split("\n").some((line) => line.trim() === image);
|
||||
} catch {
|
||||
// ignore — fall through to pull
|
||||
@@ -244,14 +205,10 @@ export async function runRedisUpCommand(opts = {}) {
|
||||
const args = [
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"-p",
|
||||
buildRedisPublishSpec(bind, port),
|
||||
"-v",
|
||||
`${DEFAULT_VOLUME}:/data`,
|
||||
"--name", name,
|
||||
"--restart", "unless-stopped",
|
||||
"-p", `${port}:6379`,
|
||||
"-v", `${DEFAULT_VOLUME}:/data`,
|
||||
];
|
||||
if (opts.password) {
|
||||
args.push("-e", `REDIS_PASSWORD=${opts.password}`);
|
||||
@@ -262,13 +219,8 @@ export async function runRedisUpCommand(opts = {}) {
|
||||
info(`Launching ${runtime} run ${args.join(" ")}`);
|
||||
try {
|
||||
await execFile(runtime, args);
|
||||
success(`Container '${name}' is now running on redis://${bind}:${port}`);
|
||||
info(`Set OMNIROUTE_REDIS_URL=redis://${bind}:${port} in your .env to wire OmniRoute to it.`);
|
||||
if (bind !== DEFAULT_BIND && !opts.password) {
|
||||
info(
|
||||
`Warning: '${bind}' publishes Redis beyond loopback without AUTH. Re-run with --password <secret>.`
|
||||
);
|
||||
}
|
||||
success(`Container '${name}' is now running on redis://127.0.0.1:${port}`);
|
||||
info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
fail(`Failed to launch container: ${err.message}`);
|
||||
@@ -315,13 +267,7 @@ export async function runRedisStatusCommand(opts = {}) {
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
if (!exists) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ runtime, name, port, exists: false, running: false, reachable: false },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -339,12 +285,10 @@ export async function runRedisStatusCommand(opts = {}) {
|
||||
console.log(` Running: ${running ? "yes" : "no"}`);
|
||||
console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`);
|
||||
if (running && !reachable) {
|
||||
warn(
|
||||
"Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?"
|
||||
);
|
||||
warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?");
|
||||
}
|
||||
if (!running) {
|
||||
info(`Run 'omniroute redis up' to launch it.`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,7 @@ async function runRepairAction(opts, cmd) {
|
||||
if (ok) {
|
||||
process.stdout.write("✓ better-sqlite3 repaired OK\n");
|
||||
} else {
|
||||
process.stderr.write("✗ Repair failed\n");
|
||||
process.stderr.write(
|
||||
" Possible causes:\n" +
|
||||
" • npm not available — check that Node.js/npm are on your PATH\n" +
|
||||
" • npm install scripts are blocked — run: npm install-scripts approve better-sqlite3\n" +
|
||||
" • Network issue — check your internet connection\n" +
|
||||
" Try: npm install-scripts ls (to see if better-sqlite3 is blocked)\n"
|
||||
);
|
||||
process.stderr.write("✗ Repair failed — check npm availability\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,12 +269,7 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
||||
const server = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
[...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs],
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
@@ -294,12 +289,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
||||
const server = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
[...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs],
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
|
||||
@@ -156,15 +156,7 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const errorBody = await res.json();
|
||||
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
|
||||
if (serverMsg) detail += ` — ${serverMsg}`;
|
||||
} catch {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
models = body.data ?? body.models ?? [];
|
||||
} catch (err) {
|
||||
|
||||
@@ -218,26 +218,6 @@ function registerPluginInOpenCodeConfig({
|
||||
* a clear "could not run opencode" message instead of a hard import
|
||||
* failure.
|
||||
*/
|
||||
/**
|
||||
* Resolve the provider id used for `opencode auth login --provider <id>`.
|
||||
*
|
||||
* The bundled @omniroute/opencode-plugin registers its provider under
|
||||
* `opencode-<id>` (the `opencode-` prefix is required by OpenCode >=1.17.8's
|
||||
* native-adapter gate). The auth login command must use the prefixed form
|
||||
* because OpenCode resolves `--provider <id>` against the provider id the
|
||||
* plugin actually registered.
|
||||
*
|
||||
* Idempotent: if the id already starts with `opencode-`, it passes through
|
||||
* unchanged. This protects users who manually worked around the bug with
|
||||
* `--provider opencode-omniroute`.
|
||||
*
|
||||
* @param {string} providerId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveOpenCodeAuthProviderId(providerId) {
|
||||
return providerId.startsWith("opencode-") ? providerId : `opencode-${providerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure resolver for the `opencode auth login` spawn descriptor. Extracted so the
|
||||
* platform-branching logic is unit-testable without mocking child_process or
|
||||
@@ -251,23 +231,21 @@ export function resolveOpenCodeAuthProviderId(providerId) {
|
||||
*/
|
||||
export function resolveOpenCodeAuthSpawn(providerId, platform = process.platform) {
|
||||
const isWin = platform === "win32";
|
||||
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
|
||||
return {
|
||||
command: isWin ? "opencode.cmd" : "opencode",
|
||||
args: ["auth", "login", "--provider", authProviderId],
|
||||
args: ["auth", "login", "--provider", providerId],
|
||||
options: { stdio: "inherit", shell: isWin },
|
||||
};
|
||||
}
|
||||
|
||||
export function runOpenCodeAuth(providerId) {
|
||||
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
|
||||
const { command, args, options } = resolveOpenCodeAuthSpawn(providerId);
|
||||
const res = spawnSync(command, args, options);
|
||||
if (res.error) {
|
||||
// ENOENT = opencode is not on PATH
|
||||
if (res.error.code === "ENOENT") {
|
||||
printInfo(
|
||||
`opencode CLI not found on PATH. Run \`opencode auth login --provider ${authProviderId}\` manually after installing OpenCode.`
|
||||
`opencode CLI not found on PATH. Run \`opencode auth login --provider ${providerId}\` manually after installing OpenCode.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -365,8 +343,7 @@ export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
if (wantsAuth) {
|
||||
if (nonInteractive) {
|
||||
printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`);
|
||||
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
|
||||
printInfo(`Run manually: opencode auth login --provider ${authProviderId}`);
|
||||
printInfo(`Run manually: opencode auth login --provider ${providerId}`);
|
||||
} else {
|
||||
printHeading("Authenticating with OpenCode");
|
||||
const authExit = runOpenCodeAuth(providerId);
|
||||
@@ -375,9 +352,8 @@ export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
|
||||
printInfo(
|
||||
`Next step: opencode auth login --provider ${authProviderId} (pass --auth to do this automatically)`
|
||||
`Next step: opencode auth login --provider ${providerId} (pass --auth to do this automatically)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* omniroute setup-opencode — Remote-aware OpenCode provider generator
|
||||
* (openai-compatible). Distinct from `omniroute setup opencode` (which wires the
|
||||
* @omniroute/opencode-plugin). This writes the `omniroute` provider into
|
||||
* the active OpenCode JSON/JSONC config with every catalog model, so you can run
|
||||
* ~/.config/opencode/opencode.json with every catalog model, so you can run
|
||||
* `opencode -m omniroute/<model>`.
|
||||
*
|
||||
* Reuses the proven server-side generator (config-generator/opencode.ts) for the
|
||||
@@ -10,13 +10,12 @@
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
|
||||
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 };
|
||||
|
||||
/** Resolve baseUrl + (literal) apiKey from flags → active context → localhost. */
|
||||
export function resolveOpencodeTarget(opts = {}) {
|
||||
@@ -30,8 +29,7 @@ export function resolveOpencodeTarget(opts = {}) {
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
if (!baseUrl)
|
||||
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
@@ -50,61 +48,32 @@ export function resolveOpencodeTarget(opts = {}) {
|
||||
/**
|
||||
* Post-process the generator output: reference the API key by env var (keep the
|
||||
* secret off disk) and optionally keep only models whose id matches `only`.
|
||||
* Pure + testable. Returns the final JSONC string while preserving comments
|
||||
* outside the OmniRoute-managed fields.
|
||||
* Pure + testable. Returns the final JSON string.
|
||||
*
|
||||
* @param {string} rawJson output of generateOpencodeConfig
|
||||
* @param {{ only?: string[] }} [opts]
|
||||
* @returns {{ json: string, modelCount: number }}
|
||||
*/
|
||||
export function postProcessOpencodeConfig(rawJson, opts = {}) {
|
||||
const errors = [];
|
||||
const config = parse(rawJson, errors, { allowTrailingComma: true, disallowComments: false });
|
||||
if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
|
||||
const details = errors
|
||||
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||
.join(", ");
|
||||
throw new Error(`Failed to parse generated OpenCode config${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
const config = JSON.parse(rawJson);
|
||||
const prov = config.provider?.omniroute;
|
||||
let json = rawJson;
|
||||
if (prov?.options) {
|
||||
json = applyEdits(
|
||||
json,
|
||||
modify(json, ["provider", "omniroute", "options", "apiKey"], ENV_KEY_REF, {
|
||||
formattingOptions: JSON_FORMATTING_OPTIONS,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (prov?.options) prov.options.apiKey = ENV_KEY_REF;
|
||||
|
||||
let models = prov?.models;
|
||||
if (opts.only && opts.only.length && prov?.models) {
|
||||
const kept = {};
|
||||
for (const [id, entry] of Object.entries(prov.models)) {
|
||||
if (opts.only.some((f) => id.includes(f))) kept[id] = entry;
|
||||
}
|
||||
models = kept;
|
||||
json = applyEdits(
|
||||
json,
|
||||
modify(json, ["provider", "omniroute", "models"], kept, {
|
||||
formattingOptions: JSON_FORMATTING_OPTIONS,
|
||||
})
|
||||
);
|
||||
prov.models = kept;
|
||||
}
|
||||
const modelCount = models ? Object.keys(models).length : 0;
|
||||
return { json: json.endsWith("\n") ? json : `${json}\n`, modelCount };
|
||||
const modelCount = prov?.models ? Object.keys(prov.models).length : 0;
|
||||
return { json: JSON.stringify(config, null, 2) + "\n", modelCount };
|
||||
}
|
||||
|
||||
export async function runSetupOpencodeCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveOpencodeTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only
|
||||
? opts.only
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
|
||||
printHeading("OmniRoute → OpenCode provider (openai-compatible)");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
@@ -112,28 +81,20 @@ export async function runSetupOpencodeCommand(opts = {}) {
|
||||
// Deferred import: opencode.ts is TypeScript; tsx is registered by
|
||||
// bin/omniroute.mjs before any command runs, so importing here is safe.
|
||||
let raw;
|
||||
let configPath;
|
||||
try {
|
||||
const { generateOpencodeConfig } =
|
||||
await import("../../../src/lib/cli-helper/config-generator/opencode.ts");
|
||||
const { resolveOpencodeConfigPath } =
|
||||
await import("../../../src/shared/services/opencodeConfigPath.ts");
|
||||
configPath = resolveOpencodeConfigPath();
|
||||
raw = await generateOpencodeConfig({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model: opts.model,
|
||||
providerId: "omniroute",
|
||||
configPath,
|
||||
});
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
raw = await generateOpencodeConfig({ baseUrl, apiKey, model: opts.model, providerId: "omniroute" });
|
||||
} catch (err) {
|
||||
printError(`Failed to generate OpenCode config: ${err?.message || err}`);
|
||||
printError(`Failed to generate opencode.json: ${err?.message || err}`);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { json, modelCount } = postProcessOpencodeConfig(raw, { only });
|
||||
const configDir = dirname(configPath);
|
||||
const configDir = join(os.homedir(), ".config", "opencode");
|
||||
const configPath = join(configDir, "opencode.json");
|
||||
|
||||
if (dryRun) {
|
||||
console.log(json.length > 4000 ? json.slice(0, 4000) + "\n… (truncated)" : json);
|
||||
@@ -143,9 +104,7 @@ export async function runSetupOpencodeCommand(opts = {}) {
|
||||
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(configPath, json, "utf8");
|
||||
printSuccess(
|
||||
`${basename(configPath)} updated at ${configPath} (${modelCount} models under 'omniroute')`
|
||||
);
|
||||
printSuccess(`opencode.json updated at ${configPath} (${modelCount} models under 'omniroute')`);
|
||||
printInfo('Use it: opencode -m omniroute/<model> "..." (export OMNIROUTE_API_KEY first)');
|
||||
return 0;
|
||||
}
|
||||
@@ -154,7 +113,7 @@ export function registerSetupOpencode(program) {
|
||||
program
|
||||
.command("setup-opencode")
|
||||
.description(
|
||||
"Generate the OmniRoute openai-compatible provider in the active OpenCode config " +
|
||||
"Generate the OmniRoute openai-compatible provider in ~/.config/opencode/opencode.json " +
|
||||
"from the live model catalog (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
|
||||
@@ -24,35 +24,18 @@ export function registerStop(program) {
|
||||
|
||||
export async function runStopCommand(opts = {}) {
|
||||
const pid = readPidFile("server");
|
||||
// #9455: when the server was started with a supervisor (the default), killing only
|
||||
// the child lets the supervisor respawn it immediately. The supervisor's PID is
|
||||
// persisted separately by serve.mjs; SIGTERM it FIRST so its handler sets
|
||||
// isShuttingDown=true and stops the child cleanly without respawning.
|
||||
const supervisorPid = readPidFile("supervisor");
|
||||
|
||||
if (pid && isPidRunning(pid)) {
|
||||
console.log(t("stop.stopping", { pid }));
|
||||
try {
|
||||
if (supervisorPid && isPidRunning(supervisorPid)) {
|
||||
try {
|
||||
process.kill(supervisorPid, "SIGTERM");
|
||||
} catch {}
|
||||
// Give the supervisor a moment to cascade the shutdown to its child so we
|
||||
// don't race the child kill against the supervisor's own child stop.
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
|
||||
// the target instead of delivering an interceptable signal, racing (and beating)
|
||||
// the server's own async graceful shutdown / WAL checkpoint. stopProcessGracefully
|
||||
// skips the immediate SIGTERM on win32 and just polls before escalating to SIGKILL.
|
||||
if (isPidRunning(pid)) {
|
||||
await stopProcessGracefully({ pid, timeoutMs: 5000, isPidRunning, sleep });
|
||||
}
|
||||
await stopProcessGracefully({ pid, timeoutMs: 5000, isPidRunning, sleep });
|
||||
|
||||
killAllSubprocesses();
|
||||
cleanupPidFile("server");
|
||||
cleanupPidFile("supervisor");
|
||||
console.log(t("stop.stopped"));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
@@ -66,24 +49,10 @@ export async function runStopCommand(opts = {}) {
|
||||
const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
|
||||
if (pid === null) {
|
||||
console.log(t("stop.portFallback"));
|
||||
// #9455: a stale supervisor PID file would let the port-fallback stop also
|
||||
// leave the supervisor running and respawning. Stop it first.
|
||||
if (supervisorPid && isPidRunning(supervisorPid)) {
|
||||
try {
|
||||
process.kill(supervisorPid, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
const portFreed = await killByPort(port);
|
||||
await killByPort(port);
|
||||
killAllSubprocesses();
|
||||
cleanupPidFile("server");
|
||||
cleanupPidFile("supervisor");
|
||||
// #9455: only report success when the port is actually free — previously stop
|
||||
// printed "Server stopped." even when killByPort was a no-op (win32).
|
||||
if (portFreed) {
|
||||
console.log(t("stop.stopped"));
|
||||
} else {
|
||||
console.log(t("stop.notRunning"));
|
||||
}
|
||||
console.log(t("stop.stopped"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -91,84 +60,31 @@ export async function runStopCommand(opts = {}) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the process listening on `port`. Returns true once the port is free
|
||||
* (or no listener was found), false if it could not be freed.
|
||||
*
|
||||
* #9455: previously this was a no-op on win32 (`if (win32) return;`) yet the
|
||||
* caller still reported "Server stopped." — a lie. The win32 branch now uses
|
||||
* `netstat -ano` to find LISTENING PIDs and `process.kill()` (SIGTERM then
|
||||
* SIGKILL), mirroring the POSIX `lsof` path.
|
||||
*/
|
||||
export async function killByPort(port, deps = {}) {
|
||||
const exec = deps.execFileAsync || execFileAsync;
|
||||
const kill = deps.processKill || ((p, sig) => process.kill(p, sig));
|
||||
const running = deps.isPidRunning || isPidRunning;
|
||||
const wait = deps.sleep || sleep;
|
||||
const platform = deps.platform || process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
return killByPortWin32(port, { exec, kill, running, wait });
|
||||
}
|
||||
return killByPortPosix(port, { exec, kill, running, wait });
|
||||
}
|
||||
|
||||
async function killByPortPosix(port, { exec, kill, running, wait }) {
|
||||
let pids = [];
|
||||
async function killByPort(port) {
|
||||
if (process.platform === "win32") return;
|
||||
try {
|
||||
const { stdout } = await exec("lsof", ["-ti", `:${port}`]);
|
||||
pids = stdout
|
||||
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`]);
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((p) => Number.isFinite(p) && p > 0);
|
||||
|
||||
for (const p of pids) {
|
||||
try {
|
||||
process.kill(p, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (pids.length > 0) {
|
||||
await sleep(1000);
|
||||
for (const p of pids) {
|
||||
try {
|
||||
if (isPidRunning(p)) process.kill(p, "SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// lsof not available or no process on port
|
||||
}
|
||||
return terminatePids(pids, { kill, running, wait });
|
||||
}
|
||||
|
||||
async function killByPortWin32(port, { exec, kill, running, wait }) {
|
||||
let pids = [];
|
||||
try {
|
||||
const { stdout } = await exec("netstat", ["-ano"]);
|
||||
pids = parseNetstatPids(stdout, port);
|
||||
} catch {
|
||||
// netstat not available or empty
|
||||
}
|
||||
return terminatePids(pids, { kill, running, wait });
|
||||
}
|
||||
|
||||
function parseNetstatPids(stdout, port) {
|
||||
const portCol = `:${port}`;
|
||||
const pids = [];
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
// Expected columns: Proto LocalAddress ForeignAddress State PID
|
||||
if (cols.length < 5) continue;
|
||||
if (cols[0] !== "TCP" && cols[0] !== "TCPv6") continue;
|
||||
const local = cols[1] || "";
|
||||
if (!local.endsWith(portCol)) continue;
|
||||
if ((cols[cols.length - 2] || "").toUpperCase() !== "LISTENING") continue;
|
||||
const pid = parseInt(cols[cols.length - 1], 10);
|
||||
if (Number.isFinite(pid) && pid > 0 && !pids.includes(pid)) pids.push(pid);
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
async function terminatePids(pids, { kill, running, wait }) {
|
||||
if (pids.length === 0) return true;
|
||||
for (const p of pids) {
|
||||
try {
|
||||
kill(p, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
await wait(1000);
|
||||
for (const p of pids) {
|
||||
try {
|
||||
if (running(p)) kill(p, "SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
// Confirm the port is free: any PID still alive means we failed.
|
||||
return pids.every((p) => !running(p));
|
||||
}
|
||||
|
||||
@@ -181,28 +181,6 @@ export async function runUpdateCommand(opts = {}) {
|
||||
// --include=optional keeps the optionalDependencies (better-sqlite3, keytar,
|
||||
// tls-client, llmlingua SLM stack) on update so an omit=optional config can't drop them.
|
||||
execSync("npm install -g omniroute@latest --include=optional", { stdio: "inherit" });
|
||||
// Trust-but-verify: `npm install -g` exits 0 even when a shadowing local install
|
||||
// (e.g. ~/node_modules/omniroute ahead of the global prefix on PATH) means the
|
||||
// binary the user actually runs was not touched. Re-read the running binary's
|
||||
// version and warn instead of lying about success (#9475).
|
||||
const afterVersion = await getCurrentVersion();
|
||||
if (afterVersion && compareVersions(afterVersion, latest) < 0) {
|
||||
printError(
|
||||
`Global install updated to ${latest}, but the running binary still reports ${afterVersion}.`
|
||||
);
|
||||
console.log(
|
||||
" A local `node_modules/omniroute` is likely shadowing the global install on PATH."
|
||||
);
|
||||
console.log(" Diagnose with:");
|
||||
console.log(" which -a omniroute");
|
||||
console.log(" command -v omniroute");
|
||||
console.log(" npm prefix -g");
|
||||
console.log(
|
||||
" Then remove the shadowing local copy (e.g. `npm uninstall omniroute` from its directory)"
|
||||
);
|
||||
console.log(" or reorder PATH so the global bin comes first.");
|
||||
return 1;
|
||||
}
|
||||
printSuccess(`Updated to version ${latest}`);
|
||||
printInfo("Run `omniroute --version` to verify.");
|
||||
return 0;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const CLI_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_ROOT_DIR = join(CLI_DIR, "..", "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export const COMMON_PROVIDERS = [
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
@@ -15,201 +17,94 @@ export const COMMON_PROVIDERS = [
|
||||
];
|
||||
|
||||
function normalizeCatalogCategory(exportName) {
|
||||
const raw = exportName.split("_PROVIDERS")[0].toLowerCase().replaceAll("_", "-");
|
||||
const raw = exportName
|
||||
.replace(/_PROVIDERS$/, "")
|
||||
.toLowerCase()
|
||||
.replaceAll("_", "-");
|
||||
if (raw === "apikey") return "api-key";
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance past a string literal, template literal, or comment starting at `i`.
|
||||
* Returns the index just after it, or -1 when `i` does not start one. Keeping
|
||||
* the scanner string/comment aware is what lets it walk braces safely — provider
|
||||
* notes routinely contain `{`, `}` and apostrophes.
|
||||
*/
|
||||
function skipNonCode(source, i) {
|
||||
const c = source[i];
|
||||
function loadTypeScript() {
|
||||
try {
|
||||
return require("typescript");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
for (let j = i + 1; j < source.length; j++) {
|
||||
if (source[j] === "\\") {
|
||||
j++;
|
||||
function getPropertyName(ts, name) {
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
||||
return name.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getObjectProperty(ts, objectLiteral, propertyName) {
|
||||
return objectLiteral.properties.find(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) && getPropertyName(ts, property.name) === propertyName
|
||||
);
|
||||
}
|
||||
|
||||
function getStringProperty(ts, objectLiteral, propertyName) {
|
||||
const property = getObjectProperty(ts, objectLiteral, propertyName);
|
||||
const initializer = property?.initializer;
|
||||
if (!initializer) return null;
|
||||
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
|
||||
return initializer.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBooleanProperty(ts, objectLiteral, propertyName) {
|
||||
const property = getObjectProperty(ts, objectLiteral, propertyName);
|
||||
const initializer = property?.initializer;
|
||||
return initializer?.kind === ts.SyntaxKind.TrueKeyword;
|
||||
}
|
||||
|
||||
function extractProviderBlocks(source, filePath) {
|
||||
const ts = loadTypeScript();
|
||||
if (!ts) return [];
|
||||
|
||||
const providers = [];
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (!ts.isVariableStatement(node)) return;
|
||||
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name)) continue;
|
||||
const exportName = declaration.name.text;
|
||||
if (!exportName.endsWith("_PROVIDERS")) continue;
|
||||
if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) {
|
||||
continue;
|
||||
}
|
||||
if (source[j] === c) return j + 1;
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
if (c === "/" && source[i + 1] === "/") {
|
||||
const nl = source.indexOf("\n", i);
|
||||
return nl === -1 ? source.length : nl;
|
||||
}
|
||||
const category = normalizeCatalogCategory(exportName);
|
||||
for (const property of declaration.initializer.properties) {
|
||||
if (!ts.isPropertyAssignment(property)) continue;
|
||||
if (!ts.isObjectLiteralExpression(property.initializer)) continue;
|
||||
|
||||
if (c === "/" && source[i + 1] === "*") {
|
||||
const close = source.indexOf("*/", i + 2);
|
||||
return close === -1 ? source.length : close + 2;
|
||||
}
|
||||
const key = getPropertyName(ts, property.name);
|
||||
if (!key) continue;
|
||||
|
||||
return -1;
|
||||
}
|
||||
const id = getStringProperty(ts, property.initializer, "id") || key;
|
||||
const name = getStringProperty(ts, property.initializer, "name") || id;
|
||||
|
||||
/** Index of the `}` matching the `{` at `openIdx`, or -1. */
|
||||
function findMatchingBrace(source, openIdx) {
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < source.length; i++) {
|
||||
const skipped = skipNonCode(source, i);
|
||||
if (skipped !== -1) {
|
||||
i = skipped - 1;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const MEMBER_KEY = /(?:([A-Za-z_$][\w$]*)|"([^"]*)"|'([^']*)')\s*:/y;
|
||||
|
||||
/**
|
||||
* Parse the direct members of the object literal whose `{` is at `openIdx`.
|
||||
* Returns `[{ key, value }]` with `value` as the raw source slice.
|
||||
*/
|
||||
function parseObjectMembers(source, openIdx) {
|
||||
// An unbalanced literal (a missing `},` in a large data file — see #10093)
|
||||
// should not blank the whole catalog: scan to end-of-source so the entries
|
||||
// before the damage are still recovered.
|
||||
const matching = findMatchingBrace(source, openIdx);
|
||||
const close = matching === -1 ? source.length : matching;
|
||||
|
||||
const members = [];
|
||||
let i = openIdx + 1;
|
||||
|
||||
while (i < close) {
|
||||
if (/[\s,;]/.test(source[i])) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The key match MUST be attempted before skipNonCode: quoted keys such as
|
||||
// `"duckduckgo-web":` start with a quote, and skipping them as string
|
||||
// literals both loses the entry and desynchronizes the walk, which then
|
||||
// reports nested keys (`notice`, …) as top-level providers.
|
||||
MEMBER_KEY.lastIndex = i;
|
||||
const match = MEMBER_KEY.exec(source);
|
||||
if (!match) {
|
||||
const skipped = skipNonCode(source, i);
|
||||
i = skipped !== -1 ? skipped : i + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1] ?? match[2] ?? match[3];
|
||||
let valueStart = MEMBER_KEY.lastIndex;
|
||||
while (valueStart < close && /\s/.test(source[valueStart])) valueStart++;
|
||||
|
||||
let valueEnd;
|
||||
if (source[valueStart] === "{" || source[valueStart] === "[") {
|
||||
const openChar = source[valueStart];
|
||||
const closeChar = openChar === "{" ? "}" : "]";
|
||||
let depth = 0;
|
||||
let j = valueStart;
|
||||
for (; j < close; j++) {
|
||||
const s2 = skipNonCode(source, j);
|
||||
if (s2 !== -1) {
|
||||
j = s2 - 1;
|
||||
continue;
|
||||
}
|
||||
if (source[j] === openChar) depth++;
|
||||
else if (source[j] === closeChar) {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
providers.push({
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
alias: getStringProperty(ts, property.initializer, "alias"),
|
||||
website: getStringProperty(ts, property.initializer, "website"),
|
||||
deprecated: getBooleanProperty(ts, property.initializer, "deprecated"),
|
||||
hasFree: getBooleanProperty(ts, property.initializer, "hasFree"),
|
||||
passthroughModels: getBooleanProperty(ts, property.initializer, "passthroughModels"),
|
||||
});
|
||||
}
|
||||
valueEnd = j + 1;
|
||||
} else {
|
||||
let j = valueStart;
|
||||
for (; j < close; j++) {
|
||||
const s2 = skipNonCode(source, j);
|
||||
if (s2 !== -1) {
|
||||
j = s2 - 1;
|
||||
continue;
|
||||
}
|
||||
if (source[j] === ",") break;
|
||||
}
|
||||
valueEnd = j;
|
||||
}
|
||||
|
||||
members.push({ key, value: source.slice(valueStart, valueEnd), valueStart });
|
||||
// Guarantee forward progress even on malformed input.
|
||||
i = valueEnd > i ? valueEnd : i + 1;
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
/** First string literal in a raw value (handles `"a" + "b"` continuations). */
|
||||
function readString(raw) {
|
||||
if (raw == null) return null;
|
||||
const match = raw.match(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/);
|
||||
if (!match) return null;
|
||||
return (match[1] ?? match[2]).replace(/\\(.)/g, "$1");
|
||||
}
|
||||
|
||||
function readBoolean(raw) {
|
||||
return String(raw).trim() === "true";
|
||||
}
|
||||
|
||||
const PROVIDER_EXPORT =
|
||||
/(?:export\s+)?const\s+([A-Z0-9_]*_PROVIDERS[A-Z0-9_]*)\s*(?::[^=]+)?=\s*\{/g;
|
||||
|
||||
/**
|
||||
* Extract provider entries from a catalog source file.
|
||||
*
|
||||
* Deliberately dependency-free: `typescript` is a devDependency, so requiring it
|
||||
* at runtime made this silently return [] on every published install (#10080).
|
||||
* These files are pure data literals, so a string/comment-aware brace walk is
|
||||
* both sufficient and stable.
|
||||
*/
|
||||
export function extractProviderBlocks(source) {
|
||||
const providers = [];
|
||||
PROVIDER_EXPORT.lastIndex = 0;
|
||||
|
||||
let exportMatch;
|
||||
while ((exportMatch = PROVIDER_EXPORT.exec(source)) !== null) {
|
||||
const exportName = exportMatch[1];
|
||||
const openIdx = source.indexOf("{", exportMatch.index + exportMatch[0].length - 1);
|
||||
if (openIdx === -1) continue;
|
||||
|
||||
const category = normalizeCatalogCategory(exportName);
|
||||
|
||||
for (const entry of parseObjectMembers(source, openIdx)) {
|
||||
if (!entry.value.startsWith("{")) continue; // spread / non-object member
|
||||
const fields = new Map(
|
||||
parseObjectMembers(source, entry.valueStart).map((f) => [f.key, f.value])
|
||||
);
|
||||
|
||||
const id = readString(fields.get("id")) || entry.key;
|
||||
providers.push({
|
||||
id,
|
||||
name: readString(fields.get("name")) || id,
|
||||
category,
|
||||
alias: readString(fields.get("alias")),
|
||||
website: readString(fields.get("website")),
|
||||
deprecated: readBoolean(fields.get("deprecated")),
|
||||
hasFree: readBoolean(fields.get("hasFree")),
|
||||
passthroughModels: readBoolean(fields.get("passthroughModels")),
|
||||
});
|
||||
}
|
||||
|
||||
// An unbalanced literal (see #10093) yields -1 here. Resetting lastIndex to
|
||||
// 0 would restart the scan from the top forever, so stop instead — the
|
||||
// entries recovered above are still returned.
|
||||
const closeIdx = findMatchingBrace(source, openIdx);
|
||||
if (closeIdx === -1) break;
|
||||
PROVIDER_EXPORT.lastIndex = closeIdx + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return providers;
|
||||
}
|
||||
@@ -231,31 +126,9 @@ function resolveProviderCatalogPath(rootDir, options = {}) {
|
||||
if (configuredPath) {
|
||||
return isAbsolute(configuredPath) ? configuredPath : resolve(rootDir, configuredPath);
|
||||
}
|
||||
|
||||
// The catalog used to be one god-file at constants/providers.ts. It was
|
||||
// decomposed into constants/providers/**, leaving the barrel with nothing but
|
||||
// re-exports and an empty `FREE_PROVIDERS = {}` — so parsing it alone yielded
|
||||
// zero providers and the CLI silently fell back to COMMON_PROVIDERS (#10080).
|
||||
// Prefer the directory; keep the legacy file for older trees.
|
||||
const catalogDir = join(rootDir, "src", "shared", "constants", "providers");
|
||||
if (existsSync(catalogDir)) return catalogDir;
|
||||
return join(rootDir, "src", "shared", "constants", "providers.ts");
|
||||
}
|
||||
|
||||
/** Every .ts catalog file under `dir`, one level of subdirectories deep. */
|
||||
function collectCatalogFiles(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectCatalogFiles(full));
|
||||
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export function loadAvailableProviders(options = {}) {
|
||||
const rootDir = typeof options === "string" ? options : options.rootDir || DEFAULT_ROOT_DIR;
|
||||
const providersPath = resolveProviderCatalogPath(rootDir, options);
|
||||
@@ -265,10 +138,8 @@ export function loadAvailableProviders(options = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const sources = statSync(providersPath).isDirectory()
|
||||
? collectCatalogFiles(providersPath)
|
||||
: [providersPath];
|
||||
const providers = sources.flatMap((file) => extractProviderBlocks(readFileSync(file, "utf-8")));
|
||||
const source = readFileSync(providersPath, "utf-8");
|
||||
const providers = extractProviderBlocks(source, providersPath);
|
||||
if (providers.length === 0) return fallbackAvailableProviders();
|
||||
|
||||
const seen = new Set();
|
||||
|
||||
@@ -94,12 +94,10 @@ export function isBetterSqliteBinaryValid() {
|
||||
const magic = buf.toString("hex");
|
||||
const os = platform();
|
||||
let formatOk;
|
||||
if (os === "linux")
|
||||
formatOk = magic.startsWith("7f454c46"); // ELF
|
||||
if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF
|
||||
else if (os === "darwin")
|
||||
formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
|
||||
else if (os === "win32")
|
||||
formatOk = magic.startsWith("4d5a"); // PE/MZ
|
||||
else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ
|
||||
else formatOk = true;
|
||||
if (!formatOk) return false;
|
||||
// File-format magic bytes alone do not guarantee the binary was built for the Node ABI
|
||||
@@ -154,18 +152,9 @@ export function ensureBetterSqliteRuntime({ silent = false, force = false } = {}
|
||||
if (!silent) process.stdout.write("[omniroute][runtime] better-sqlite3 OK\n");
|
||||
return { betterSqlite: true };
|
||||
}
|
||||
if (!silent) {
|
||||
process.stdout.write(
|
||||
`[omniroute][runtime] Installing better-sqlite3@${BETTER_SQLITE3_VERSION} into runtime...\n`
|
||||
);
|
||||
}
|
||||
const ok = npmInstallRuntime([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { silent });
|
||||
if (!ok && !silent) {
|
||||
process.stderr.write(
|
||||
"[omniroute][runtime] better-sqlite3 install failed.\n" +
|
||||
" This usually means npm install scripts are blocked.\n" +
|
||||
" Try: npm install-scripts approve better-sqlite3\n"
|
||||
);
|
||||
process.stderr.write("[omniroute][runtime] better-sqlite3 install failed\n");
|
||||
}
|
||||
return { betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid() };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
|
||||
import {
|
||||
RESTART_RESET_MS,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
computeRestartDelayMs,
|
||||
waitUntilPortFree,
|
||||
} from "./supervisorPolicy.mjs";
|
||||
import { buildNodeRuntimeArgs } from "../../../scripts/build/runtime-env.mjs";
|
||||
import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs";
|
||||
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
|
||||
import {
|
||||
isFatalInstrumentationHookFailure,
|
||||
@@ -44,26 +44,18 @@ export class ServerSupervisor {
|
||||
this.instrumentationFailureHintPrinted = false;
|
||||
|
||||
const showLog = process.env.OMNIROUTE_SHOW_LOG === "1";
|
||||
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value). The
|
||||
// calibrated heap is already carried by env.NODE_OPTIONS either way.
|
||||
const heapArgs = buildNodeHeapArgs(process.env, this.memoryLimit);
|
||||
// #6321: stdout used to be discarded (`"ignore"`) whenever `--log`/OMNIROUTE_SHOW_LOG
|
||||
// wasn't set (the default) — any debug/pino output written to stdout vanished
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
// #9156: always spawn via process.execPath (absolute path to the running
|
||||
// runtime — node or bun). Bare "node" is unresolvable under macOS launchd's
|
||||
// minimal PATH; #9761's Bun ternary accidentally regressed the Node branch.
|
||||
// Node args come from buildNodeRuntimeArgs (#9209 IPv4-first DNS + #5238
|
||||
// heap flag handling); the Bun branch keeps #9761's polyfill preload —
|
||||
// Bun does not accept the Node-only flags.
|
||||
this.child = spawn(
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [
|
||||
"--preload",
|
||||
join(dirname(this.serverPath), "open-sse/utils/setupPolyfill.ts"),
|
||||
this.serverPath,
|
||||
]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[...(process.versions.bun ? [] : heapArgs), this.serverPath],
|
||||
{
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const SYSTRAY_VERSION = "2.1.4";
|
||||
const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`;
|
||||
|
||||
export function resolveSystrayBinName(platform: NodeJS.Platform): string | null {
|
||||
if (platform === "win32") return "tray_windows_release.exe";
|
||||
if (platform === "win32") return null;
|
||||
if (platform === "darwin") return "tray_darwin_release";
|
||||
return "tray_linux_release";
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> {
|
||||
if (process.platform === "win32") return null; // Windows uses tray.ps1 instead
|
||||
ensureRuntimeDir();
|
||||
if (!isInstalled()) {
|
||||
try {
|
||||
|
||||
@@ -130,7 +130,7 @@ async function openSqliteDatabase(dbPath, options = {}) {
|
||||
try {
|
||||
return new loaded.Database(dbPath, options);
|
||||
} catch (error) {
|
||||
return openWithSyncDriverFallback(dbPath, options, error);
|
||||
throw createSqliteNativeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,10 +167,6 @@ export function getAutostartStatus() {
|
||||
linger: tryReadLingerEnabled(),
|
||||
};
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const winMechanism = isAutostartEnabled() ? "vbs-startup" : null;
|
||||
return { enabled: isAutostartEnabled(), mechanism: winMechanism };
|
||||
}
|
||||
return { enabled: isAutostartEnabled(), mechanism: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isTraySupported, initSystrayUnix, killSystrayUnix } from "./traySystray.mjs";
|
||||
import { initWinTray, killWinTray } from "./trayWindows.mjs";
|
||||
|
||||
let active = null;
|
||||
|
||||
@@ -9,17 +10,15 @@ export async function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
|
||||
const ctx = { port, onQuit, onOpenDashboard, onShowLogs };
|
||||
// initSystrayUnix is async: it lazily installs/loads systray2 from the runtime
|
||||
// dir (trayRuntime.ts) rather than from node_modules. (#4605)
|
||||
// Use systray2 on all platforms including Windows — the tarball ships
|
||||
// tray_windows_release.exe, avoiding the Norton/AVG IDP.HELU.PSE85 heuristic
|
||||
// that fires on temp-dir PowerShell scripts. (#8609)
|
||||
active = await initSystrayUnix(ctx);
|
||||
active = process.platform === "win32" ? initWinTray(ctx) : await initSystrayUnix(ctx);
|
||||
return active;
|
||||
}
|
||||
|
||||
export function killTray() {
|
||||
if (!active) return;
|
||||
try {
|
||||
killSystrayUnix(active);
|
||||
if (process.platform === "win32") killWinTray(active);
|
||||
else killSystrayUnix(active);
|
||||
} catch {}
|
||||
active = null;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Parse a `.env` value with dotenv-compatible comment handling.
|
||||
*
|
||||
* Without this, `KEY=value # note` stored the comment text as part of the
|
||||
* value. The shipped .env ships exactly such a line for QUOTA_STORE_DRIVER, and
|
||||
* consumers compare it with `===`, so annotating a variable inline silently
|
||||
* disabled it (#10100).
|
||||
*
|
||||
* Quoted values are returned verbatim — a `#` inside quotes is data. For
|
||||
* unquoted values a `#` *preceded by whitespace* starts a comment, so
|
||||
* `pass#word` is preserved.
|
||||
*/
|
||||
export function parseEnvValue(raw) {
|
||||
const value = String(raw).trim();
|
||||
|
||||
const quoted = value.match(/^(['"])([\s\S]*)\1\s*(?:#.*)?$/);
|
||||
if (quoted) return quoted[2];
|
||||
|
||||
const commentIdx = value.search(/\s#/);
|
||||
return (commentIdx === -1 ? value : value.slice(0, commentIdx)).trim();
|
||||
}
|
||||
@@ -2,9 +2,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
|
||||
// #9455: "supervisor" must be tracked so killAllSubprocesses() can stop the
|
||||
// supervisor process, not just the child server it spawned (and respawns).
|
||||
const SERVICES = ["server", "supervisor", "mitm", "tunnel/cloudflared", "tunnel/tailscale"];
|
||||
const SERVICES = ["server", "mitm", "tunnel/cloudflared", "tunnel/tailscale"];
|
||||
|
||||
function getServicePidPath(service) {
|
||||
return join(resolveDataDir(), service, ".pid");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -43,15 +43,7 @@ export async function startMcpCli(rootDir = ROOT) {
|
||||
}
|
||||
|
||||
// `tsx` loader is only required for local `.ts` fallback; JS entry works without it.
|
||||
const tsxLoaderArgs = mcpEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
||||
// Preload the stdout/stderr console guard before mcpEntry's own module graph evaluates —
|
||||
// DB init (a side effect of createMcpServer()'s tool registration) logs via plain
|
||||
// console.log, and by the time any code inside mcpEntry itself could redirect it, that
|
||||
// module's own (hoisted) imports have already run. Loading the guard first, in a separate
|
||||
// module, is the only point early enough to guarantee it never leaks into the JSON-RPC
|
||||
// stream on stdout.
|
||||
const consoleGuard = pathToFileURL(join(__dirname, "mcpStdioConsoleGuard.mjs")).href;
|
||||
const loaderArgs = ["--import", consoleGuard, ...tsxLoaderArgs];
|
||||
const loaderArgs = mcpEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [...loaderArgs, mcpEntry], {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
// Preloaded (via `node --import`) before open-sse/mcp-server/server.ts and its entire
|
||||
// import graph evaluate. The stdio MCP transport uses stdout exclusively for JSON-RPC
|
||||
// messages, but DB init (getDbInstance(), triggered as a side effect of evaluating the
|
||||
// server's module graph — e.g. tool registration reading compression settings) logs via
|
||||
// plain console.log. A redirect placed *inside* server.ts (even at the top of its first
|
||||
// executed function) is too late: static imports are hoisted and fully evaluated before
|
||||
// any of that function's own code runs, so earlier console.log calls during import-time
|
||||
// side effects already escaped to the real stdout by then. Redirecting here, in a module
|
||||
// that loads before server.ts is even requested, is the only point early enough to
|
||||
// guarantee no startup output leaks into the JSON-RPC stream and corrupts it client-side
|
||||
// (e.g. Claude Desktop: "Unexpected token 'D', \"[DB] Changi\"... is not valid JSON").
|
||||
import { Console } from "node:console";
|
||||
|
||||
const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr });
|
||||
console.log = stderrConsole.log.bind(stderrConsole);
|
||||
console.warn = stderrConsole.warn.bind(stderrConsole);
|
||||
@@ -23,7 +23,6 @@ import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSuppo
|
||||
import { getDefaultDataDir } from "./cli/data-dir.mjs";
|
||||
import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs";
|
||||
import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs";
|
||||
import { parseEnvValue } from "./cli/utils/parseEnvValue.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -44,19 +43,6 @@ if (isVersionFastPath(process.argv)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// MCP stdio transport uses stdout exclusively for JSON-RPC messages. Redirect
|
||||
// console.log/warn to stderr before anything else runs — including the tsx/esm and
|
||||
// polyfill imports below, since those (and their transitive module graphs, e.g. DB
|
||||
// init) can themselves log during evaluation. Redirecting after those imports let
|
||||
// early output leak straight into the JSON-RPC stream and corrupt it client-side
|
||||
// (e.g. Claude Desktop: "Unexpected token 'D', \"[DB] Changi\"... is not valid JSON").
|
||||
if (process.argv.includes("--mcp")) {
|
||||
const { Console } = await import("node:console");
|
||||
const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr });
|
||||
console.log = stderrConsole.log.bind(stderrConsole);
|
||||
console.warn = stderrConsole.warn.bind(stderrConsole);
|
||||
}
|
||||
|
||||
// Register tsx so dynamic imports of .ts source files (referenced as .js per
|
||||
// TypeScript conventions) resolve correctly. The build never emits .js for
|
||||
// src/lib/cli-helper/, so tsx handles the .ts → .js resolution at runtime.
|
||||
@@ -72,6 +58,16 @@ await import("../open-sse/utils/setupPolyfill.ts");
|
||||
const { registerAliasResolver } = await import("./aliasResolver.mjs");
|
||||
await registerAliasResolver(ROOT);
|
||||
|
||||
// MCP stdio transport uses stdout exclusively for JSON-RPC messages.
|
||||
// Redirect console.log/warn to stderr early (before loadEnvFile and DB init)
|
||||
// so no startup output corrupts the protocol.
|
||||
if (process.argv.includes("--mcp")) {
|
||||
const { Console } = await import("node:console");
|
||||
const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr });
|
||||
console.log = stderrConsole.log.bind(stderrConsole);
|
||||
console.warn = stderrConsole.warn.bind(stderrConsole);
|
||||
}
|
||||
|
||||
// Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to
|
||||
// `<DATA_DIR>/server.env` (electron/main.js), never `.env`. Migrating an existing
|
||||
// install (storage.sqlite + server.env) to the CLI left those secrets undiscoverable —
|
||||
@@ -129,8 +125,9 @@ function loadEnvFile() {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const value = trimmed.slice(eqIdx + 1).trim();
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
|
||||
process.env[key] = value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ snap="$(ops_find_snapshot "$ID")"
|
||||
|
||||
# Policy definition tables present in BOTH the snapshot and the live DB. GLOB
|
||||
# keeps `_` literal; we drop usage counters / logs so accounting isn't rewound.
|
||||
tables=()
|
||||
while IFS= read -r t; do tables+=("$t"); done < <(
|
||||
readarray -t tables < <(
|
||||
sqlite3 "$snap/storage.sqlite" \
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'api_key*' \
|
||||
AND name NOT GLOB '*counter*' AND name NOT GLOB '*_log*' ORDER BY name;"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- feat(dashboard): opt-in `DASHBOARD_ALLOW_EMBED=vscode` relaxes CSP `frame-ancestors` to `'self' vscode-webview:` and drops `X-Frame-Options` for HTML pages only, so the dashboard renders inside the VS Code Simple Browser (OmniCopilot). Default posture unchanged — API routes stay unframable (#10273)
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(providers):** publish Poolside's Laguna Preview catalog statically — `poolside/laguna-xs-2.1` and `poolside/laguna-s-2.1` (262144 context, 32768 max completion, tools + reasoning, text-only), so the models are routable and visible before a key is configured instead of only after live discovery. Pins the catalog form of the XS id against the `laguna-xs.2` variant carried by third-party listings. ([#9085](https://github.com/diegosouzapw/OmniRoute/issues/9085))
|
||||
@@ -1 +0,0 @@
|
||||
- feat(crof): advertise reasoning-effort tiers (none/low/medium/high/max) for live-discovered and seed models, so the catalog, Playground, and Combo Builder surface <model>-<tier> aliases and requests resolve max upstream
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(chatgpt-web):** Preserve native `max` thinking effort through ChatGPT Web routing ([#10077](https://github.com/diegosouzapw/OmniRoute/pull/10077)) — thanks @zannen7
|
||||
@@ -1 +0,0 @@
|
||||
- **perf(logging):** bound each scheduled call-log rotation pass to incremental database and filesystem work (#10125)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(streaming):** start early SSE heartbeats when Responses or Messages requests opt into streaming through the request body (#10127)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(translator):** resolve the Claude thinking output cap with the routed provider so a provider-scoped-only `max_output_tokens` override is no longer invisible to `fitThinkingToMaxTokens()`, which previously let the synthesized `max_tokens` (caller room + thinking budget) go out unbounded and 400 upstream ([#10139](https://github.com/diegosouzapw/OmniRoute/issues/10139))
|
||||
@@ -1,3 +0,0 @@
|
||||
- fix(providers): correct the conol-web registry fallback-models import depth, which pointed at a
|
||||
non-existent `open-sse/config/services/` and made any suite loading the provider registry fail to
|
||||
resolve (#10140)
|
||||
@@ -1 +0,0 @@
|
||||
- **docs(settings):** document Thinking Budget modes (passthrough vs auto-strip); fix dashboard i18n key collision that showed Auto Combo routing copy on the thinking tab; clarify independence from compression/cache ([#10169](https://github.com/diegosouzapw/OmniRoute/pull/10169))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(guardrails):** Vision Bridge handles OpenAI Responses `input`/`input_image` requests before combo vision filtering ([#10202](https://github.com/diegosouzapw/OmniRoute/pull/10202)) — thanks @Zartharas
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White
|
||||
@@ -1 +0,0 @@
|
||||
- **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)).
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `<model>-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(providers):** compatible/custom providers now save valid Data URL icons and show Add/Edit save failures instead of silently doing nothing ([#10247](https://github.com/diegosouzapw/OmniRoute/pull/10247)) — thanks @xz-dev
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(models):** custom model metadata and compatible-provider context overrides now take precedence over discovered metadata, while deleting a synced model no longer creates a permanent tombstone so a later provider sync can restore it ([#10248](https://github.com/diegosouzapw/OmniRoute/pull/10248)) — thanks @jackjinke
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(dashboard):** model-level allowed/blocked param edits now persist when the compatibility popover is closed by clicking outside, and a failed save no longer clears the edit or reports success ([#9013](https://github.com/diegosouzapw/OmniRoute/pull/9013))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(test):** remove live `npm pack` from MCP files unit test (it stalled concurrent `test:unit` via prepare→husky + monorepo pack walk); keep the static #3578 `files` allowlist + negation guards in unit and fold #3821 pack assertions into `check:pack-artifact` / `check:pack-policy` (already `--ignore-scripts`).
|
||||
@@ -1 +0,0 @@
|
||||
- fix(cli): drop the orphaned `resolveOpencodeConfigDir` re-export from `cliRuntime` — it lost its last consumer in #10246 and diverged from the canonical resolver by one directory level (#9985)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(ci): make `Build (advisory)` produce a signal again — pinned to a hosted runner with the swap/heap provisioning `Fast Production Build` proves sufficient, and scoped to fork PRs, which are the only ones `build.yml` cannot cover (72 of the last 100 PRs into `release/**`)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(discovery): parse upstream reasoning tiers nested under metadata.reasoning.supported_efforts (neuralwatt /v1/models shape) so synced openai-compatible models advertise effort aliases
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user