Compare commits

..

14 Commits

Author SHA1 Message Date
diegosouzapw
4b2bd2e37f feat(dashboard): Faro chat with push-to-talk voice on the Conductor panel 2026-07-22 21:11:47 -03:00
diegosouzapw
9813f35ff0 chore(env): CONDUCTOR_SPOKESPERSON_URL declared in schema, .env.example and ENVIRONMENT.md 2026-07-22 21:04:23 -03:00
diegosouzapw
54c69cf48d feat(dashboard): /api/conductor/ask — server-side proxy to Faro (spokesperson) with whitelisted {text,pending} 2026-07-22 21:04:18 -03:00
diegosouzapw
12b957a2df feat(dashboard): Conductor panel — fleet live view, task detail and cancel over /api/conductor proxy 2026-07-22 20:55:19 -03:00
diegosouzapw
5d3c988715 feat(dashboard): /api/conductor proxy routes (fleet, task detail, cancel) behind management auth 2026-07-22 20:44:03 -03:00
diegosouzapw
af97f7fc31 feat(dashboard): server-side hub proxy for the Conductor panel (whitelisted shapes, fail-open) 2026-07-22 20:41:46 -03:00
diegosouzapw
4d6c855258 feat(a2a): agent card announces Conductor fleet skills 2026-07-22 08:36:51 -03:00
diegosouzapw
7b951761fc feat(a2a): fleet skills derived from the Conductor hub for the agent card 2026-07-22 08:35:16 -03:00
diegosouzapw
b97318d73b docs(reference): document CONDUCTOR_HUB_URL/TOKEN in ENVIRONMENT.md (env-doc-sync gate) 2026-07-22 01:06:22 -03:00
diegosouzapw
a75295f359 fix(a2a): conductor bridge parses the hub's real SSE wire format (id/type in frame, data={ts,payload}) 2026-07-22 00:25:35 -03:00
diegosouzapw
00e15af622 feat(a2a): start conductor bridge at boot behind CONDUCTOR_HUB_URL 2026-07-22 00:23:28 -03:00
diegosouzapw
5eb10e896d feat(a2a): conductor bridge connection loop with persisted cursor and backoff 2026-07-22 00:21:10 -03:00
diegosouzapw
33baf62b58 feat(a2a): incremental SSE parser for conductor bridge 2026-07-22 00:21:10 -03:00
diegosouzapw
d42a58141b feat(a2a): conductor bridge core — event mapping with canceled->cancelled 2026-07-22 00:20:29 -03:00
2468 changed files with 78454 additions and 443583 deletions

View File

@@ -1,197 +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/
src/lib/env/
src/app/api/agent-skills/coverage/
src/app/api/cloud/
src/app/api/sync/cloud/
src/app/api/system/env/
tests/golden-set/data/
# Logs e saida de teste
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/
# ─────────────────────────────────────────────────────────────────────────────
# 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
# Cli binario local (scratch)
bin/omniroute.mjs
# Deploy / docker backups
deploy.sh
docker-compose.yml.bak
docker-compose.minimal.yml

View File

@@ -80,26 +80,8 @@ PORT=20128
# Base path (URL subpath) when serving OmniRoute behind a reverse proxy under a subpath.
# Used by: next.config.mjs — sets Next.js `basePath`; auth redirects are basePath-aware.
# Also mirrored to NEXT_PUBLIC_OMNIROUTE_BASE_PATH at build time so the dashboard
# endpoint display (useDisplayBaseUrl) shows https://host/omniroute/v1 instead of
# https://host/v1. Rebuild after changing this value (Next basePath is build-time).
# Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute
# Docker: baked at image build time via build-arg; root-path images can also apply this at
# container start (see docs/guides/DOCKER_GUIDE.md).
# OMNIROUTE_BASE_PATH=
# Client fetch/EventSource under this path are rewritten via installBasePathFetch
# (src/shared/utils/basePathFetch.ts) so absolute `/api/*` and `/v1/*` hits work
# without a reverse-proxy rewrite. Rebuild after changing (Next basePath is build-time).
#
# Browser-visible mirror of OMNIROUTE_BASE_PATH, inlined at build time so the
# dashboard endpoint display can read it client-side. Set it to the same value
# as OMNIROUTE_BASE_PATH; when unset the hook falls back to OMNIROUTE_BASE_PATH.
# Used by: src/shared/hooks/useDisplayBaseUrl.ts
# NEXT_PUBLIC_OMNIROUTE_BASE_PATH=
#
# Optional: set the public origin *with* the same path so OAuth and display URLs
# stay consistent without relying on window.location.origin alone:
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
# 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.
@@ -240,9 +222,8 @@ NODE_ENV=production
# Container runtime — controls startup script behavior (permissions, advice).
# Values: docker | podman | Default: docker
# Set to "podman" for any Podman topology. The entrypoint cannot determine
# whether the engine is local or reached through Podman Machine, so it prints
# topology-neutral guidance and links contrib/podman/README.md.
# Set to "podman" when running under rootless Podman so the entrypoint
# gives the correct fix instructions (podman unshare chown vs sudo chown).
CONTAINER_HOST=docker
# Container runtime override for skill sandboxing.
@@ -331,14 +312,6 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Message count that classifies an otherwise small body as heavyweight. Default 200.
# OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200
# Tool count that classifies an otherwise small body as heavyweight. Default 64.
# 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
# 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
@@ -379,14 +352,11 @@ ALLOW_API_KEY_REVEAL=false
# ── Request-Side: Prompt Injection Guard ──
# Scans incoming messages for prompt injection patterns before routing.
# Used by: src/middleware/promptInjectionGuard.ts
# Default ON when unset. Set to false/0/no/off to disable. Truthy: true/1/yes/on.
# INPUT_SANITIZER_ENABLED=false
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = legacy (does NOT strip injection; use PII_REDACTION_ENABLED for request PII)
# INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = strip patterns
# Legacy aliases for INPUT_SANITIZER_MODE / INPUT_SANITIZER_BLOCK_THRESHOLD (same effect).
# Legacy alias for INPUT_SANITIZER_MODE (same effect).
# INJECTION_GUARD_MODE=warn
# INJECTION_GUARD_BLOCK_THRESHOLD=high
# PII detection in incoming requests (emails, phone numbers, SSNs, etc.).
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.
@@ -811,15 +781,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Default: <repo>/src/lib/db/migrations.
#OMNIROUTE_MIGRATIONS_DIR=
# Additional migration directories, as `namespace=dir` entries separated by the
# platform path delimiter (`:` on POSIX, `;` on Windows). Files found there are
# recorded as `<namespace>-<number>` (e.g. `ee-134`), a version space that cannot
# collide with the upstream numeric slots — so a distribution shipping its own
# migrations never silently loses one to a number the upstream set also claimed.
# A malformed entry, an invalid namespace or a missing directory aborts startup
# rather than skipping the schema. Unset = no extra directories (the default).
#OMNIROUTE_EXTRA_MIGRATIONS_DIRS=ee=/opt/app/enterprise/db/migrations
# Mass-pending-migrations safety threshold (#3416). If more than this many
# migrations are pending on an existing DB, startup aborts (a wiped tracking
# table could cause data loss). Raise it to restore an older backup; set to 0
@@ -882,11 +843,10 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: open-sse/executors/antigravity.ts — escape hatch for multi-project setups.
# OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=0
# Control Antigravity Google One AI credit usage. Used by:
# open-sse/services/antigravityCredits.ts — accepts off, retry, or always.
# off (default): never use credits; retry: use credits once after eligible quota 429;
# always: use credits on the first request (higher account and spend risk).
#ANTIGRAVITY_CREDITS=off
# Adjust how Antigravity advertises remaining credits. Used by:
# open-sse/services/antigravityCredits.ts — accepts forced override strings.
# Default: empty (use upstream-reported credits).
#ANTIGRAVITY_CREDITS=
# Override the path to the Antigravity CLI (agy) token file read by the
# "auto-detect local login" import. Used by:
@@ -1055,7 +1015,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
# Update these when providers release new CLI versions to avoid blocks.
CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)"
CLAUDE_USER_AGENT="claude-cli/2.1.207 (external, cli)"
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
@@ -1204,20 +1164,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
# ── Notion web TLS sidecar (Chrome-fingerprinted client) ──
# Used by: open-sse/services/notionTlsClient.ts — wire-level timeout for the
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
# top of it when the native library is wedged. The notion-web executor raises
# the wire timeout per-request to 180000 for long generations.
# OMNIROUTE_NOTION_TLS_TIMEOUT_MS=30000
# OMNIROUTE_NOTION_TLS_GRACE_MS=10000
# ── Grok web quota fetcher (auth.json override) ──
# Used by: open-sse/services/grokQuotaFetcher.ts — path of the Grok CLI
# auth.json used to fetch the grok-web weekly quota. Defaults to
# ~/.grok/auth.json; override for tests or a non-standard CLI install.
# GROK_AUTH_PATH=
# ── Browser-backed web-cookie chat (Playwright shared pool) ──
# Used by: open-sse/services/browserPool.ts + browserBackedChat.ts. The shared
# browser pool warms a headless context for web-cookie providers (e.g. claude-web)
@@ -1414,6 +1360,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)
@@ -1826,10 +1776,6 @@ APP_LOG_TO_FILE=true
# Tokens reserved for completion output when computing prompt budgets.
# Used by: open-sse/services/contextManager.ts. Default: 1024.
# CONTEXT_RESERVE_TOKENS=1024
# How many of the newest inline images to keep when pruning older ones to fit
# the context window (#8560). Used by: open-sse/services/contextManager.ts.
# Default: 2.
# CONTEXT_KEEP_LATEST_IMAGES=2
# ── Model alias rewriting (legacy compatibility) ──
# Toggle the legacy model-alias compatibility layer used by older clients.
@@ -1965,11 +1911,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/db/backup.ts.
# DB_BACKUP_MAX_FILES=20
# DB_BACKUP_RETENTION_DAYS=0
# Tick interval (ms) of the server-side job that executes backup-schedule.json.
# Must stay well under the 1-minute cron granularity; values below 5000 (or
# unparseable) fall back to the 30000 default.
# Used by: src/lib/jobs/backupScheduleJob.ts
# OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS=30000
# ── TLS sidecar override ──
# Used by: open-sse/services/chatgptTlsClient.ts tests. Production deployments
@@ -2132,15 +2073,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# the full (unfiltered) pool with a warning. Source: open-sse/services/autoCombo/virtualFactory.ts
# OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=false
# ─── Auto-Combo chaos panel (broadcast variant) ────────────────────────────
# Tuning for the `auto/*:chaos` variant, which fans a single request out to a
# panel of provider-diverse models. Panel size is clamped to 1..10 (default 5);
# min-panel and the panel hard-timeout fall back to the engine defaults when
# unset. Source: open-sse/services/autoCombo/virtualFactory.ts
# OMNIROUTE_CHAOS_MAX_PANEL=5
# OMNIROUTE_CHAOS_MIN_PANEL=
# OMNIROUTE_CHAOS_PANEL_TIMEOUT_MS=
# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ───
# Base URL of the OmniRoute instance to query for /v1/models when regenerating
# an opencode.json with accurate limit.context values. Used by:
@@ -2291,38 +2223,14 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token
# PROMPTQL_POLL_TIMEOUT_MS=180000
# ─────────────────────────────────────────────────────────────────────────────
# HyperAgent web provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts)
# Reverse-engineered session bridge for hyperagent.com. Optional — defaults
# point at the public billing/usage endpoint; override only for a
# self-hosted/alternate HyperAgent deployment.
# Used by: open-sse/services/usage/hyperagent.ts
# ─────────────────────────────────────────────────────────────────────────────
# HYPERAGENT_USAGE_URL=https://hyperagent.com/api/settings/billing/usage
# ─────────────────────────────────────────────────────────────────────────────
# Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts)
# Containerized Chromium+VNC used for interactive browser-login credential
# capture via /api/vnc-session. All optional — defaults target the bundled
# `omniroute-vnc-chromium:local` image; override only for a custom image, ports,
# or lifecycle tuning.
# ─────────────────────────────────────────────────────────────────────────────
# OMNIROUTE_VNC_IMAGE=omniroute-vnc-chromium:local
# OMNIROUTE_DOCKER_BIN=docker
# OMNIROUTE_VNC_CONTAINER_VNC_PORT=3000
# OMNIROUTE_VNC_CONTAINER_CDP_PORT=9223
# OMNIROUTE_VNC_CONTAINER_PROFILE_DIR=/config
# OMNIROUTE_VNC_PROFILE_DIR=
# OMNIROUTE_VNC_IDLE_MS=600000
# OMNIROUTE_VNC_MAX_MS=1800000
# OMNIROUTE_VNC_MAX_SESSIONS=4
# OMNIROUTE_VNC_READY_MS=45000
# OMNIROUTE_VNC_HARVEST_MS=20000
# OMNIROUTE_VNC_CHROMIUM_ARGS=--remote-debugging-port=9222 --no-first-run --no-default-browser-check
# ─────────────────────────────────────────────────────────────────────────────
# Data-dir alias (optional — open-sse/services/notionThreadSessions.ts)
# Legacy fallback for DATA_DIR, checked only after DATA_DIR and
# OMNIROUTE_DATA_DIR are both unset. Locates the Notion web-thread session cache.
# ─────────────────────────────────────────────────────────────────────────────
# VIBEPROXY_DATA_DIR=
# ── 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=
# Faro (OmniConductor spokesperson) — chat/voice proxy for the Conductor panel.
# Used by: src/lib/conductor/faroProxy.ts
# CONDUCTOR_SPOKESPERSON_URL=http://127.0.0.1:7920

View File

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

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

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

View File

@@ -35,25 +35,13 @@ jobs:
workflow: ${{ steps.classify.outputs.workflow }}
testsOnly: ${{ steps.classify.outputs.testsOnly }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
# Refuse a PR that targets its own head branch before spending anything on it. #8912 has
# head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with
# a full check board attached on every push to that branch. One field comparison.
- 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
@@ -480,46 +449,15 @@ jobs:
# UI keys move with dashboard code OR message catalogs.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
steps:
# fetch-depth: 0 — the value-drift gate diffs en.json against the merge base to
# find rewritten English strings. On a shallow clone the base ref is missing and
# the gate self-skips (base-unresolved), so it would never actually run.
- uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
# #8463: a rewritten English value used to leave its 39 translations behind
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
# Key parity above cannot see it — a stale translation counts as covered.
- name: i18n UI value drift (stale translations)
env:
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: node scripts/i18n/check-ui-value-drift.mjs
# #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.
i18n-glossary-zhcn:
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')) }}
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: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-TW
# D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha
# a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos)
@@ -539,7 +477,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Validate all languages
@@ -751,13 +689,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
@@ -829,13 +761,7 @@ jobs:
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
@@ -962,7 +888,7 @@ jobs:
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
@@ -1169,8 +1095,7 @@ jobs:
if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then
if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi
echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES"
# FILES is our own newline-separated path list, so word-splitting is intended
# shellcheck disable=SC2086,SC2046
# shellcheck disable=SC2086 — FILES is our own newline-separated path list
npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit
else
echo "[e2e-balance] balancer unavailable — plain --shard fallback"
@@ -1247,7 +1172,6 @@ jobs:
- lint
- docs-sync-strict
- i18n-ui-coverage
- i18n-glossary-zhcn
- i18n
- pr-test-policy
- build
@@ -1272,8 +1196,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
@@ -1288,29 +1210,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"
@@ -1318,7 +1217,6 @@ 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) | $(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"

View File

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

View File

@@ -2,20 +2,8 @@ name: DAST smoke (PR)
on:
pull_request:
branches: ["main", "release/**"]
# Runner-cost guard (#8084): the CLI-bundle build alone is 6-11min; a docs-only PR
# cannot change DAST behavior, so skip the whole workflow for pure docs/markdown
# changes. Any code path in the diff still runs the full smoke.
paths-ignore:
- "docs/**"
- "**/*.md"
permissions:
contents: read
# Superseded runs on the same PR must not stack 25-minute advisory builds
# (force-push storms were holding 2-3 runners each). Same group rule as quality.yml.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
dast-smoke:
runs-on: ubuntu-latest
@@ -30,7 +18,7 @@ jobs:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -53,20 +41,14 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- run: pip install schemathesis
- name: Schemathesis smoke (high-risk endpoints, blocking)
run: |
# /api/auth/oidc/* is a BROWSER redirect flow (302 to the IdP, 302 back to
# /login?oidc_error=... on every failure), not a REST endpoint: Schemathesis reads
# those 302s as "the API accepted a schema-violating request" and the configured-off
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
# still right (operators need it); fuzzing it is not what this smoke is for.
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
--exclude-path-regex '^/api/auth/oidc/' \
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
--request-timeout 20 --suppress-health-check all --no-color
- name: promptfoo injection-guard (blocking)

View File

@@ -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]

View File

@@ -71,20 +71,7 @@ jobs:
node-version: "26"
cache: npm
- uses: ./.github/actions/npm-ci-retry
# #8090 — this is the ONLY Node 26 build in the whole CI matrix (ci.yml pins
# CI_NODE_VERSION=24). It failed every nightly with the runner-reclaimed
# signature ("The runner has received a shutdown signal" / "The operation was
# canceled", no exit code) always at the same Turbopack compile phase — a
# classic OOM kill on the memory-constrained ubuntu-latest runner. Turbopack's
# native (Rust, off-V8-heap) allocation is NOT bounded by --max-old-space-size
# and peaks far higher than webpack on this large module graph (#6409), and is
# heavier still under Node 26. Use the documented webpack fallback here: it still
# validates that the app *builds* on Node 26 (the point of this compat job) at a
# much lower memory peak. Turbopack-on-Node-24 stays covered by ci.yml's build
# job. See docs/reference/ENVIRONMENT.md (OMNIROUTE_USE_TURBOPACK) and #6409.
- run: npm run build
env:
OMNIROUTE_USE_TURBOPACK: "0"
compat-tests:
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)

View File

@@ -90,7 +90,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
if: steps.gate.outputs.run == 'true'
with: { python-version: "3.12" }
- run: pip install garak

View File

@@ -148,7 +148,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v7

View File

@@ -143,8 +143,7 @@ jobs:
MODE="--with-build --full-ci"
fi
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
# MODE is an intentional flag list, so word-splitting is wanted here
# shellcheck disable=SC2086
# shellcheck disable=SC2086 — MODE is an intentional flag list
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> release-green.json 2> release-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
@@ -249,8 +248,7 @@ jobs:
MODE="--with-build --full-ci"
fi
echo "[main-green] mode: $MODE (event: $EVENT_NAME)"
# MODE is an intentional flag list, so word-splitting is wanted here
# shellcheck disable=SC2086
# shellcheck disable=SC2086 — MODE is an intentional flag list
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> main-green.json 2> main-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
@@ -303,145 +301,3 @@ jobs:
main-green.json
main-green.log
if-no-files-found: ignore
# ── Banking lane (#8584) ──────────────────────────────────────────────────
# The ratchet is asymmetric: RAISING a cap is a ten-second manual JSON edit made
# under merge pressure, LOWERING one requires someone to run `--update` and commit
# — which no workflow does. Grep `.github/workflows/` for `--update`: only
# wiki-sync.yml (unrelated) and ci.yml's check-quality-ratchet.mjs --require-tighten
# (a different script, a different metric). So a cap outlives the code that earned
# it and every completed decomposition silently becomes a growth allowance for
# whoever touches the file next. Measured on 2026-07-25: 18 frozen files already at
# or under the 800-line new-file cap, up to 132x (schemas.ts, 19 lines / 2523 cap),
# and 31 unfulfilled "tighten via --update next cycle" notes honoured exactly once
# (-1 unit) in six weeks.
#
# This job closes that loop by making the DOWNWARD direction as automatic as the
# upward one is easy. It measures the active release branch, runs the shrink-only
# `--update` paths, and opens ONE always-current PR with the result. It never
# pushes to release/* — a human still merges, so a bad measurement cannot land
# unreviewed. verify-ratchet-bank.mjs is the hard guarantee that the automation can
# only ever write in the shrink direction; if anything was raised, added, or a
# rebaseline note was touched, the job aborts and opens nothing.
#
# Schedule/dispatch only, deliberately NOT on push: banking has no latency
# requirement (a shrink banked within 8h is fine) and a per-merge run would rebuild
# the PR branch repeatedly during merge campaigns while paying for a full ESLint
# walk each time. Detection stays on push (release-green above); banking is batched.
bank-ratchet-shrinks:
name: Bank ratchet shrinks
if: ${{ github.event_name != 'push' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve active release branch
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
else
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
| sed 's#origin/##' \
| sort -t/ -k2 -V \
| tail -1)
fi
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
# Same strict guard as the validation job — blocks ref/command injection
# through the workflow_dispatch input.
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Refusing non-canonical branch name: $TARGET"; exit 1
fi
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "bank_branch=chore/bank-ratchet-${TARGET#release/}" >> "$GITHUB_OUTPUT"
- name: Checkout the release branch
env:
TARGET: ${{ steps.branch.outputs.target }}
run: |
set -euo pipefail
git checkout "$TARGET"
git log -1 --oneline
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Ratchet the baselines down
run: |
# Both --update paths are shrink-only by construction (file-size writes only
# on `improvements`, complexity-ratchets only when `.improved`), and both exit
# non-zero while the branch is over baseline — which is exactly when there is
# nothing to bank. Their exit code is not the signal; the verifier below is.
set +e
node scripts/check/check-file-size.mjs --update
node scripts/check/check-complexity-ratchets.mjs --update
exit 0
- name: Verify the write only went downward
id: verify
run: |
set -euo pipefail
# Exits 1 if ANYTHING was raised/added or a rebaseline note was touched.
# `set -e` then aborts the job before a commit exists — no PR is opened.
node scripts/quality/verify-ratchet-bank.mjs > bank-summary.md
if [ -n "$(git status --porcelain config/quality/)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Nothing to bank — baselines already match the code."
fi
- name: Open / update the banking PR
if: steps.verify.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
BANK_BRANCH: ${{ steps.branch.outputs.bank_branch }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BANK_BRANCH"
git add config/quality/
git commit -m "chore(quality): bank ratchet shrinks measured on ${TARGET}"
git push --force origin "$BANK_BRANCH"
{
echo "Automated banking of quality-ratchet **shrinks** already present in"
echo "\`${TARGET}\` — the downward half of the ratchet, which nothing else runs (#8584)."
echo ""
echo "Produced by \`check:file-size --update\` + \`check:complexity-ratchets --update\`,"
echo "then verified by \`scripts/quality/verify-ratchet-bank.mjs\`: **nothing was raised,"
echo "nothing was added, no rebaseline note was touched** — the job aborts without"
echo "opening a PR if any of those is violated."
echo ""
echo "No product code changes. Merging retires growth allowances that the code no"
echo "longer needs; not merging leaves them available to whoever edits those files next."
echo ""
cat bank-summary.md
echo ""
echo "**Run:** ${RUN_URL}"
} > pr-body.md
EXISTING=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BANK_BRANCH" \
--state open --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh pr edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file pr-body.md
echo "Updated existing PR #$EXISTING"
else
gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET" --head "$BANK_BRANCH" \
--title "chore(quality): bank ratchet shrinks (${TARGET})" --body-file pr-body.md
fi

View File

@@ -35,7 +35,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
sleep 2
done
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
with: { python-version: "3.12" }
- name: Install schemathesis
run: pip install schemathesis

View File

@@ -56,15 +56,8 @@ env:
jobs:
publish:
# Same dynamic-runner rule as ci.yml's `build`/`test-unit`: `build:cli` falls back to a
# full `next build`, whose working set outgrew the 16 GB hosted runner during the
# v3.8.49 cycle — the publish died with "The runner has received a shutdown signal"
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
# it is kept verbatim so the expression stays greppable against ci.yml.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
permissions:
actions: read # find + download the CI run's next-build artifact for this SHA
contents: write # gh release upload (attach SBOM to the GitHub Release)
id-token: write # npm provenance
packages: write # publish to npm.pkg.github.com
@@ -152,74 +145,6 @@ jobs:
run: |
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Fast path: CI already built the standalone tree for THIS commit and uploaded it as
# `next-build`. `build:cli` (scripts/build/prepublish.ts) only shells out to a full
# `next build` when `.build/next/standalone/server.js` is missing — restoring the
# artifact turns the heaviest step of the publish into a download. Matching on
# `head_sha` is the tree-equality guarantee: same commit, same tree.
# Best-effort by design (retention is 1 day): every miss falls through to the build
# step below, which is why the dynamic runner above matters as the backstop.
#
# The `head_repository.full_name == env.REPO` clause is a supply-chain guard, not a
# filter refinement. This artifact becomes the published npm tarball. `pull_request`
# runs from forks execute in THIS repository's context and upload their own
# `next-build` built from fork-controlled source, and the runs API returns them for a
# matching `head_sha` — 57 such runs exist in this repo today. Without the clause,
# anything that made a fork's head commit coincide with the publish commit could put
# attacker-built bytes on npm. Requiring the run to originate from this repository
# excludes every fork run while keeping the fast path intact (verified: the same
# single run is selected either way for the current tip).
# CodeQL: actions/artifact-poisoning/critical.
- name: Reuse CI's next-build artifact (skips the heavy rebuild)
if: steps.resolve.outputs.skip != 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
# The question is "which run HAS the artifact", not "which run passed" (gap 16).
# Requiring `conclusion == "success"` on the whole run discarded a perfectly good tree
# whenever any unrelated shard went red — one flaky test then pushed the publish into
# the 40-minute build this step exists to avoid. The artifact is only uploaded if the
# Build job itself succeeded, so its PRESENCE is the accurate signal; the run's overall
# conclusion is noise from jobs that have nothing to do with the tree.
#
# `head_repository.full_name == env.REPO` stays, and it is not a filter refinement:
# this tree becomes the published npm tarball, and fork `pull_request` runs execute in
# THIS repository's context uploading their own next-build. That clause is the
# supply-chain guard (CodeQL actions/artifact-poisoning).
CANDIDATES=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
--jq '[.workflow_runs[]
| select(.name == "CI"
and .head_repository.full_name == env.REPO)]
| sort_by(.run_started_at) | reverse | .[0:5] | .[].id') || CANDIDATES=""
if [ -z "$CANDIDATES" ]; then
echo "::notice::no CI run from this repository for $HEAD_SHA — falling back to a full build"
exit 0
fi
RUN=""
for candidate in $CANDIDATES; do
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
RUN="$candidate"
break
fi
echo " run $candidate carries no usable next-build — trying the next"
done
if [ -z "$RUN" ]; then
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
exit 0
fi
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
rm -rf /tmp/next-build
if [ -f .build/next/standalone/server.js ]; then
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
else
echo "::notice::extract did not yield .build/next/standalone — falling back to a full build"
rm -rf .build
fi
- name: Build CLI bundle (standalone app)
if: steps.resolve.outputs.skip != 'true'
env:
@@ -256,18 +181,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

View File

@@ -57,31 +57,6 @@ jobs:
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
build:
name: Build (advisory)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — same fork-safe rule as ci.yml / fast-gates.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
# #7307: advisory for the first week of release-PR runs; remove
# continue-on-error after the production-build signal is stable.
continue-on-error: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run build
env:
OMNIROUTE_USE_TURBOPACK: "1"
# No artifact upload here: the PR-to-release quality workflow has no
# downstream package/e2e jobs that consume the Next.js build output.
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
docs-gates:
@@ -112,20 +87,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:
@@ -179,81 +141,6 @@ jobs:
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
- run: npm run check:complexity-ratchets
# ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ──────────────
# The god-file refactor happens in PRs→release/**; without these, the release
# rail never sees a new import cycle, dead code, duplication or a security
# regression until the release PR to main. Deliberately NOT brought here:
# bundle-size (self-skips without a build — this rail's build job is advisory
# and uploads nothing, so it would be dead configuration) and the coverage
# run (fast-unit already runs the full suite; the coverage ratchet stays on
# the main rail via --allow-missing in lint-guard).
- run: npm run check:cycles
- run: npm run check:lockfile
- name: Duplication ratchet
run: npm run check:duplication
- name: Dead-code ratchet (knip)
run: npm run check:dead-code
- name: Type coverage ratchet
run: npm run check:type-coverage
- name: Compression budget ratchet
run: npm run check:compression-budget
# Security scanners — same hardened install as ci.yml quality-extended
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
# 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: Secret scan (gitleaks, ratchet, blocking)
run: npm run check:secrets -- --ratchet
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
run: npm run check:vuln-ratchet -- --ratchet
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
run: npm run check:workflows -- --ratchet
# BASE_REF is read by the script from the env (never interpolated into a
# shell body) — workflow-injection-safe. actions/checkout fetches remote
# refs, not a local branch named github.base_ref, so prefix origin/ or this
# gate self-skips every PR with reason=base-unresolved.
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
env:
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: npm run check:openapi-breaking -- --ratchet
- name: Typecheck (core)
run: npm run typecheck:core
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
@@ -290,26 +177,17 @@ jobs:
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
node scripts/quality/build-test-impact-map.mjs
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
# Shadow evidence (#8084): persist every selection so TIA false negatives can
# be measured against fast-unit's full-suite verdict across releases BEFORE
# any gate authority moves off ordinary PRs. Artifact uploaded below.
printf '%s\n' "$SEL" > tia-selection.txt
if [ -z "$SEL" ]; then
echo "TIA selection: empty (no source/test changes)" >> "$GITHUB_STEP_SUMMARY"
echo "No source/test changes — skipping unit tests"; exit 0
fi
if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
# run race-free regardless of concurrency.
if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "TIA selection: __RUN_ALL__ (fail-safe) — full-suite authority stays with fast-unit" >> "$GITHUB_STEP_SUMMARY"
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
exit 0
fi
echo "TIA selection: $(grep -c . tia-selection.txt) impacted test file(s) — full suite still runs in fast-unit (shadow-evidence phase, #8084)" >> "$GITHUB_STEP_SUMMARY"
echo "Running impacted tests:"; echo "$SEL"
mapfile -t FILES <<< "$SEL"
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
@@ -332,29 +210,13 @@ jobs:
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
fi
exit $RC
# #8084 shadow evidence: keep the raw selection downloadable so TIA misses can be
# audited against fast-unit failures on the same run (gate moves need this data).
- name: Upload TIA selection (shadow evidence)
if: always()
uses: actions/upload-artifact@v7
with:
name: tia-selection
path: tia-selection.txt
if-no-files-found: ignore
retention-days: 30
fast-vitest:
name: Vitest (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
# self-hosted is strictly worse here and there is nothing to configure.
runs-on: ubuntu-latest
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
@@ -390,13 +252,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:
@@ -439,12 +295,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:
@@ -466,29 +316,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
@@ -512,7 +339,7 @@ jobs:
API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@v2.4.4
uses: ossf/scorecard-action@v2.4.3
with:
results_file: results.sarif
results_format: sarif

View File

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

7
.gitignore vendored
View File

@@ -171,6 +171,7 @@ config/quality/test-impact-map.json
# GitNexus local index
.gitnexus
.worktrees
bin/omniroute.mjs
# Consistent with .dockerignore / .npmignore
.omc/
@@ -200,10 +201,7 @@ scripts/i18n/_pending-keys.json
.codegraph/
# Fumadocs generated source
/.source/
# Temporary local worktrees used to build unpublished npm tarballs
/.deploy-build-*/
.source/
# AI agent local settings and configs
.agents/
@@ -252,4 +250,3 @@ tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak
.playwright-cli/

View File

@@ -86,10 +86,4 @@
regexes = [
'''latencyP\d{2}Ms''',
'''interleaved-thinking-2025-05-14''',
# v3.8.49 pre-flight (2026-07-28). Nenhum dos dois e credencial:
# - chave de localStorage do banner de patrocinio (#8723), 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-v1''',
'''SunbreakWebUI1''',
]

View File

@@ -3,9 +3,3 @@ docs/reference/ENVIRONMENT.md
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
open-sse/config/freeModelCatalog.data.ts
# Generated by scripts/skills/generate-agent-skills.mjs; the generator is their
# formatter of record and check:agent-skills-sync diffs its output byte-for-byte.
# Prettier reformats the frontmatter (blank line after ---), which makes the gate
# fail on any skill that happens to pass through lint-staged.
skills/*/SKILL.md

8
.source/dynamic.ts Normal file
View File

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

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

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

View File

@@ -10,8 +10,7 @@ This plugin solves that by:
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
- Re-fetching on a configurable TTL (default 5 min) **and** background auto-discovery while OpenCode is running (`autoSyncIntervalMs`, default 5 min), so new models / combo changes appear without restarting OpenCode
- Exposing a force-refresh path (`omniroute_sync_models` tool + `/omni-sync` command template) equivalent to Pi `/omni sync`
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
@@ -74,9 +73,6 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
// Background re-discovery while OpenCode is running (Pi parity).
// Default 300000 (5 min). Minimum 60000. Set 0 to disable.
"autoSyncIntervalMs": 300000,
},
],
],
@@ -92,27 +88,6 @@ opencode auth login --provider omniroute
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
### Live catalog refresh (auto + force)
While OpenCode is running, the plugin keeps the model catalog fresh in two ways:
| Mechanism | Default | What it does |
| --- | --- | --- |
| `modelCacheTtl` | `300000` (5 min) | On-demand TTL: next provider/models hook after expiry re-fetches `/v1/models` |
| `autoSyncIntervalMs` | `300000` (5 min) | Background timer: proactively invalidates + re-fetches while the harness is running. Min `60000`. Set `0` to disable background polling (TTL still applies) |
**Force sync now** (Pi `/omni sync` equivalent) — OpenCode has no Pi-style slash-command registration API, so the plugin wires both a tool and command templates:
1. **Tool:** `omniroute_sync_models` — invalidates in-memory + disk caches, re-fetches `GET /v1/models` (and combos/enrichment when enabled), returns `{ ok, count, ... }`.
2. **Command templates** (type these in OpenCode):
- `/omni-sync` — asks the agent to call `omniroute_sync_models` and report the result
- `/omni-autosync` — asks the agent to report current `autoSyncIntervalMs` / `modelCacheTtl` status
```text
/omni-sync
/omni-autosync
```
## Multi-instance (prod + preprod side-by-side)
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/opencode-plugin",
"version": "0.2.1",
"version": "0.2.0",
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
"type": "module",
"main": "./dist/index.js",
@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -46,7 +46,7 @@
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -54,7 +54,6 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { z } from "zod";
import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js";
@@ -89,10 +88,6 @@ import {
* from providerId.
* - `modelCacheTtl` `/v1/models` TTL cache duration in milliseconds.
* Default: 300_000 (5 min).
* - `autoSyncIntervalMs` Background catalog re-discovery while OpenCode is
* running. Default: 300_000 (5 min). Minimum: 60_000.
* Set `0` to disable background auto-sync (TTL on-demand
* discovery still applies via `modelCacheTtl`).
* - `baseURL` Override base URL for this OmniRoute instance. When
* absent, the loader falls back to a credential-attached
* baseURL set by `/connect`.
@@ -195,12 +190,6 @@ const optionsSchema = z
.optional(),
displayName: z.string().min(1).optional(),
modelCacheTtl: z.number().positive().optional(),
/**
* Background auto-discovery interval while the harness is running.
* `0` disables background polling. Values in (0, 60000) are clamped up
* to 60000. Default when unset: 300000.
*/
autoSyncIntervalMs: z.number().int().nonnegative().optional(),
baseURL: z.string().url().optional(),
managementReadToken: z.string().min(1).optional(),
features: featuresSchema.optional(),
@@ -216,59 +205,6 @@ const optionsSchema = z
*/
export type OmniRoutePluginOptions = z.infer<typeof optionsSchema>;
/**
* Explicit default state for every boolean `features.*` toggle.
*
* #7624: `featuresSchema` marks every flag `.optional()` with no default, and
* the effective value is applied implicitly at each read site (default-ON flags
* use the `features.X !== false` convention, default-OFF flags use
* `features.X === true`). That implicit convention is scattered across the file,
* so an operator who omits the `features` block cannot tell whether
* combos / autoCombos / enrichment are enabled — they think features are
* disabled when they are actually on. Centralising the declared defaults here
* (mirroring the read-site conventions exactly, so runtime behaviour is
* unchanged) makes the effective flags introspectable and self-documenting.
*/
export const OMNIROUTE_FEATURE_DEFAULTS = {
// default-ON (read sites use `features.X !== false`)
combos: true,
autoCombos: true,
enrichment: true,
diskCache: true,
providerTag: true,
fetchInterceptor: true,
geminiSanitization: true,
// default-OFF (read sites use `features.X === true`)
compressionMetadata: false,
usableOnly: false,
mcpAutoEmit: false,
debugLog: false,
startupDebug: false,
} as const;
/** Union of the boolean feature-flag keys declared in `OMNIROUTE_FEATURE_DEFAULTS`. */
export type OmniRouteFeatureFlag = keyof typeof OMNIROUTE_FEATURE_DEFAULTS;
/**
* Resolve the EFFECTIVE boolean state of every feature toggle, applying the
* declared default for any flag the operator omitted. A missing `features`
* block (or an empty one) yields the full default set — so callers and the
* startup diagnostics can surface exactly which features are active instead of
* relying on the implicit `!== false` / `=== true` conventions. Purely
* derived: it does not mutate options nor change any read-site behaviour.
*/
export function resolveEffectiveFeatureFlags(
features?: OmniRoutePluginOptions["features"]
): Record<OmniRouteFeatureFlag, boolean> {
const f: Partial<Record<OmniRouteFeatureFlag, boolean>> = features ?? {};
const out = {} as Record<OmniRouteFeatureFlag, boolean>;
for (const key of Object.keys(OMNIROUTE_FEATURE_DEFAULTS) as OmniRouteFeatureFlag[]) {
const val = f[key];
out[key] = typeof val === "boolean" ? val : OMNIROUTE_FEATURE_DEFAULTS[key];
}
return out;
}
export const OMNIROUTE_PROVIDER_KEY = "omniroute" as const;
/** Deployed plugin version (injected at build time by tsup define). */
@@ -281,29 +217,6 @@ export const PLUGIN_GIT_HASH: string =
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
/** Default background auto-discovery interval (matches modelCacheTtl default). */
export const DEFAULT_AUTO_SYNC_INTERVAL_MS = 300_000 as const;
/** Minimum positive background auto-discovery interval. */
export const MIN_AUTO_SYNC_INTERVAL_MS = 60_000 as const;
/**
* Sanitize background auto-sync interval.
* - unset/invalid → default 300_000
* - explicit 0 → disabled
* - (0, 60000) → clamped to 60000
* - ≥ 60000 → kept as integer ms
*/
export function sanitizeAutoSyncIntervalMs(value: unknown): number {
if (value === undefined || value === null) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
const n = Math.trunc(value);
if (n === 0) return 0;
if (n < 0) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
if (n < MIN_AUTO_SYNC_INTERVAL_MS) return MIN_AUTO_SYNC_INTERVAL_MS;
return n;
}
// Manual trim helpers avoid polynomial-regex CodeQL warnings on
// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same
// behaviour, no backtracking.
@@ -330,7 +243,7 @@ function trimLeadingDashes(value: string): string {
* sees a consistent identifier.
*/
export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Required<
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl" | "autoSyncIntervalMs">
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">
> & {
/**
* #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …).
@@ -364,22 +277,17 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re
typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0
? opts.modelCacheTtl
: DEFAULT_MODEL_CACHE_TTL_MS;
const autoSyncIntervalMs = sanitizeAutoSyncIntervalMs(opts?.autoSyncIntervalMs);
return {
providerId,
omnirouteProviderId,
displayName,
modelCacheTtl,
autoSyncIntervalMs,
baseURL: opts?.baseURL,
managementReadToken: opts?.managementReadToken,
features: opts?.features,
};
}
/** Fully resolved plugin options (defaults applied). */
export type ResolvedOmniRoutePluginOptions = ReturnType<typeof resolveOmniRoutePluginOptions>;
/**
* Strip a leading "opencode-" prefix (added only for the OC native-adapter
* gate — see `resolveOmniRoutePluginOptions`) so the returned id is safe to
@@ -615,378 +523,8 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook
* opencode.json), NOT as a closure binding. Multi-instance support follows
* from each plugin tuple invoking the factory with its own opts.
*/
/**
* Invalidate in-memory fetch cache entries for a baseURL (all credential keys).
* Returns number of entries removed.
*/
export function invalidateOmniRouteFetchCache(
cache: OmniRouteFetchCache,
baseURL?: string,
): number {
if (!baseURL) {
const n = cache.size;
cache.clear();
return n;
}
const prefix = `${baseURL}::`;
let removed = 0;
for (const key of [...cache.keys()]) {
if (key.startsWith(prefix) || key === baseURL) {
cache.delete(key);
removed += 1;
}
}
return removed;
}
/**
* Resolve API credentials for force-sync / background refresh without
* depending on the provider.models auth context.
*/
export async function resolveOmniRouteRuntimeAuth(
resolved: ResolvedOmniRoutePluginOptions,
readAuthJson?: OmniRouteReadAuthJson,
): Promise<{ apiKey: string; baseURL: string; managementReadToken: string } | null> {
const reader = readAuthJson ?? defaultReadAuthJson;
let authJson: AuthJsonShape | undefined | null;
try {
authJson = await reader();
} catch {
authJson = undefined;
}
if (authJson === null) authJson = undefined;
const bareKey = resolved.providerId.startsWith("opencode-")
? resolved.providerId.slice("opencode-".length)
: resolved.providerId;
const lookupKeys = [resolved.providerId];
if (bareKey !== resolved.providerId) lookupKeys.push(bareKey);
if (resolved.omnirouteProviderId && !lookupKeys.includes(resolved.omnirouteProviderId)) {
lookupKeys.push(resolved.omnirouteProviderId);
}
let entry: AuthJsonApiEntry | undefined;
for (const k of lookupKeys) {
const e = authJson?.[k];
if (
e &&
(e as { type?: unknown }).type === "api" &&
typeof (e as { key?: unknown }).key === "string" &&
((e as { key: string }).key).length > 0
) {
entry = e as AuthJsonApiEntry;
break;
}
}
const apiKey = entry?.key ?? "";
if (!apiKey) return null;
const authBaseURL =
entry && typeof (entry as { baseURL?: unknown }).baseURL === "string"
? (entry as { baseURL: string }).baseURL
: "";
const baseURL = resolved.baseURL ?? (authBaseURL || "");
if (!baseURL) return null;
const managementReadToken = resolved.managementReadToken ?? apiKey;
return { apiKey, baseURL, managementReadToken };
}
/**
* Force-refresh OmniRoute catalog: clear memory + disk cache, re-fetch /v1/models
* (and optional management endpoints), and repopulate the shared cache.
* OpenCode equivalent of Pi `/omni sync`.
*/
export async function forceSyncOmniRouteModels(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
readAuthJson?: OmniRouteReadAuthJson;
fetcher?: OmniRouteModelsFetcher;
combosFetcher?: OmniRouteCombosFetcher;
autoCombosFetcher?: OmniRouteAutoCombosFetcher;
enrichmentFetcher?: OmniRouteEnrichmentFetcher;
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
providersFetcher?: OmniRouteProvidersFetcher;
now?: () => number;
}): Promise<{
ok: boolean;
count: number;
combos: number;
provider: string;
baseURL?: string;
clearedMemory: number;
clearedDisk: boolean;
error?: string;
}> {
const resolved = args.resolved;
const cache = args.cache;
const now = args.now ?? Date.now;
const fetcher = args.fetcher ?? defaultOmniRouteModelsFetcher;
const combosFetcher = args.combosFetcher ?? defaultOmniRouteCombosFetcher;
const autoCombosFetcher = args.autoCombosFetcher ?? defaultOmniRouteAutoCombosFetcher;
const enrichmentFetcher = args.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher;
const compressionMetaFetcher =
args.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = args.providersFetcher ?? defaultOmniRouteProvidersFetcher;
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantAutoCombos = features.autoCombos !== false;
const wantEnrichment = features.enrichment !== false;
const wantCompressionMeta = features.compressionMetadata === true;
const wantUsableOnly = features.usableOnly === true;
const wantDiskCache = features.diskCache !== false;
const auth = await resolveOmniRouteRuntimeAuth(
resolved,
args.readAuthJson ?? defaultReadAuthJson,
);
if (!auth) {
return {
ok: false,
count: 0,
combos: 0,
provider: resolved.omnirouteProviderId,
clearedMemory: 0,
clearedDisk: false,
error:
"No OmniRoute credentials/baseURL available. Run `opencode connect omniroute` or set plugin baseURL.",
};
}
const clearedMemory = invalidateOmniRouteFetchCache(cache, auth.baseURL);
// Clear residual entries from prior baseURL history as well.
const clearedAll = invalidateOmniRouteFetchCache(cache);
let clearedDisk = false;
if (wantDiskCache) {
clearedDisk = await clearDiskSnapshot(resolved.providerId);
if (resolved.omnirouteProviderId !== resolved.providerId) {
clearedDisk = (await clearDiskSnapshot(resolved.omnirouteProviderId)) || clearedDisk;
}
}
try {
const rawModels = await fetcher(auth.baseURL, auth.apiKey, 10_000);
let rawCombos: OmniRouteRawCombo[] = [];
if (wantCombos) {
try {
rawCombos = await combosFetcher(auth.baseURL, auth.managementReadToken, 10_000);
} catch (err) {
console.warn("[omniroute-plugin] force sync: combos fetch failed", err);
}
}
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(auth.baseURL, auth.managementReadToken, 5_000);
} catch {
/* soft-fail */
}
}
let rawEnrichment: OmniRouteEnrichmentMap = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(auth.baseURL, auth.managementReadToken, 10_000);
} catch {
rawEnrichment = new Map();
}
}
let rawCompressionCombos: OmniRouteCompressionCombo[] = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(
auth.baseURL,
auth.managementReadToken,
10_000,
);
} catch {
rawCompressionCombos = [];
}
}
let rawConnections: OmniRouteProviderConnection[] = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(auth.baseURL, auth.managementReadToken, 10_000);
} catch {
rawConnections = [];
}
}
const t = now();
const entry = {
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
expiresAt: t + resolved.modelCacheTtl,
};
const cacheKey = modelsCacheKey(
auth.baseURL,
`${auth.apiKey}\0${auth.managementReadToken}`,
);
cache.set(cacheKey, entry);
if (wantDiskCache) {
try {
const fingerprint = diskSnapshotIdentityFingerprint(
auth.baseURL,
auth.apiKey,
auth.managementReadToken,
);
const { expiresAt: _expiresAt, ...diskEntry } = entry;
await defaultDiskSnapshotWriter(resolved.providerId, diskEntry, fingerprint);
} catch {
/* soft-fail disk write */
}
}
console.warn(
`[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` +
`models=${rawModels.length} combos=${rawCombos.length} ` +
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`,
);
return {
ok: true,
count: rawModels.length,
combos: rawCombos.length,
provider: resolved.omnirouteProviderId,
baseURL: auth.baseURL,
clearedMemory: clearedMemory + clearedAll,
clearedDisk,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
count: 0,
combos: 0,
provider: resolved.omnirouteProviderId,
baseURL: auth.baseURL,
clearedMemory: clearedMemory + clearedAll,
clearedDisk,
error: message,
};
}
}
export function createOmniRouteSyncModelsTool(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
}): ReturnType<typeof tool> {
const { resolved, cache } = args;
return tool({
description:
"Force-refresh the OmniRoute model catalog (OpenCode equivalent of Pi `/omni sync`). " +
"Invalidates in-memory and disk caches, then re-fetches GET /v1/models (and combos when enabled).",
args: {
reason: tool.schema
.string()
.optional()
.describe("Optional reason for the sync (logging only)"),
},
async execute(toolArgs) {
const result = await forceSyncOmniRouteModels({ resolved, cache });
const reason = toolArgs.reason ? ` reason=${toolArgs.reason}` : "";
if (!result.ok) {
return {
title: "OmniRoute sync failed",
output:
`OmniRoute model sync failed for provider=${result.provider}.${reason}\n` +
`${result.error ?? "unknown error"}`,
metadata: result,
};
}
return {
title: `OmniRoute sync: ${result.count} models`,
output:
`OmniRoute models synced.` +
`\nprovider: ${result.provider}` +
`\nbaseURL: ${result.baseURL}` +
`\nmodels: ${result.count}` +
`\ncombos: ${result.combos}` +
`\nclearedMemoryEntries: ${result.clearedMemory}` +
`\nclearedDiskSnapshot: ${result.clearedDisk}` +
`\nTTL: ${resolved.modelCacheTtl}ms` +
`\nautoSyncIntervalMs: ${resolved.autoSyncIntervalMs}` +
reason,
metadata: result,
};
},
});
}
/**
* Start background auto-discovery while the harness is running.
* Quiet: only logs when the model count changes or on errors.
* Returns a stop function.
*/
export function startOmniRouteAutoSync(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
intervalMs?: number;
}): () => void {
const resolved = args.resolved;
const cache = args.cache;
const intervalMs = args.intervalMs ?? resolved.autoSyncIntervalMs;
if (!intervalMs || intervalMs <= 0) {
return () => {};
}
let stopped = false;
let inFlight: Promise<void> | null = null;
let lastCount: number | undefined;
const tick = () => {
if (stopped) return;
if (inFlight) return;
inFlight = (async () => {
const result = await forceSyncOmniRouteModels({ resolved, cache });
if (!result.ok) {
console.warn(
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`,
);
return;
}
if (lastCount === undefined) {
lastCount = result.count;
return;
}
if (result.count !== lastCount) {
console.warn(
`[omniroute-plugin] auto-sync catalog size changed ${lastCount}${result.count} ` +
`(providerId=${resolved.providerId})`,
);
lastCount = result.count;
}
})()
.catch((err) => {
console.warn("[omniroute-plugin] auto-sync tick error", err);
})
.finally(() => {
inFlight = null;
});
};
// Delay first background tick by one interval so session start is not doubled
// with the normal provider.models cold fetch. Manual tool remains immediate.
const timer = setInterval(tick, intervalMs);
if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") {
timer.unref();
}
console.warn(
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`,
);
return () => {
stopped = true;
clearInterval(timer);
};
}
export const OmniRoutePlugin: Plugin = async (_input, options) => {
const resolved = resolveOmniRoutePluginOptions(coercePluginOptions(options));
const resolved = coercePluginOptions(options);
// T-07: a single per-plugin-instance cache shared between the provider
// hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both
// hooks fire within the same Plugin invocation, so a shared cache keeps
@@ -1015,53 +553,10 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
// Wire log level: startupDebug:true → "debug", explicit logLevel wins.
setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn"));
// Background auto-discovery while the harness is running (Pi parity).
// Interval 0 disables. TTL on-demand discovery still works via modelCacheTtl.
startOmniRouteAutoSync({ resolved, cache: sharedCache });
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache });
const bareProviderId = resolved.omnirouteProviderId;
// Config hook: keep existing catalog shim, and register slash command
// templates that ask the agent to call the force-sync tool (OpenCode has no
// Pi-style registerCommand API; tools + command templates are the native path).
const baseConfigHook = createOmniRouteConfigHook(resolved, { cache: sharedCache });
const configWithSyncCommand = async (input: Config) => {
await baseConfigHook(input);
const cfg = input as Config & {
command?: Record<
string,
{ template: string; description?: string; agent?: string; model?: string; subtask?: boolean }
>;
};
if (!cfg.command) cfg.command = {};
if (!cfg.command["omni-sync"]) {
cfg.command["omni-sync"] = {
description: "Force-refresh OmniRoute model catalog (like Pi /omni sync)",
template:
`Force-refresh the OmniRoute model catalog now using the omniroute_sync_models tool ` +
`(provider ${bareProviderId}). After the tool returns, briefly report model count and whether the sync succeeded.`,
};
}
if (!cfg.command["omni-autosync"]) {
cfg.command["omni-autosync"] = {
description: "Show OmniRoute auto-sync / cache status",
template:
`Report OmniRoute discovery status for provider ${bareProviderId}: ` +
`autoSyncIntervalMs=${resolved.autoSyncIntervalMs}, modelCacheTtl=${resolved.modelCacheTtl}. ` +
`If the user asked to refresh now, call omniroute_sync_models.`,
};
}
};
return {
auth: createOmniRouteAuthHook(resolved),
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }),
config: configWithSyncCommand,
tool: {
omniroute_sync_models: syncTool,
},
config: createOmniRouteConfigHook(resolved, { cache: sharedCache }),
};
};
@@ -2053,7 +1548,6 @@ async function writeStartupDiagnostics(params: {
autoComboCount: number;
enrichment: OmniRouteEnrichmentMap;
autoCombos: OmniRouteRawAutoCombo[];
features?: OmniRoutePluginOptions["features"];
}): Promise<void> {
const {
providerId,
@@ -2064,7 +1558,6 @@ async function writeStartupDiagnostics(params: {
autoComboCount,
enrichment,
autoCombos,
features,
} = params;
const enriched = [...enrichment.entries()];
const withName = enriched.filter(([, e]) => e.name);
@@ -2077,16 +1570,6 @@ async function writeStartupDiagnostics(params: {
lines.push(
`models=${modelCount} combos=${comboCount} enrichment=${enrichmentSize} autoCombos=${autoComboCount}`
);
// #7624: surface the EFFECTIVE feature flags so an operator who omitted the
// `features` block can see combos/autoCombos/enrichment are on (the counts
// above can read 0 for reasons unrelated to the flags — e.g. missing auth).
const effectiveFlags = resolveEffectiveFeatureFlags(features);
lines.push(
`features(effective): ` +
(Object.keys(effectiveFlags) as OmniRouteFeatureFlag[])
.map((k) => `${k}=${effectiveFlags[k] ? "on" : "off"}`)
.join(" ")
);
lines.push(
`enrichment: ${withName.length} with name, ${withPricing.length} with pricing, ${withFree.length} free`
);
@@ -3195,7 +2678,6 @@ export function createOmniRouteProviderHook(
autoComboCount: rawAutoCombos.length,
enrichment: rawEnrichment,
autoCombos: rawAutoCombos,
features: resolved.features,
});
}
}
@@ -4579,19 +4061,6 @@ export function diskSnapshotPath(providerId: string): string {
return path.join(dir, "plugins", `omniroute-${providerId}.json`);
}
/** Best-effort delete of the disk snapshot for a provider (force-sync). */
export async function clearDiskSnapshot(providerId: string): Promise<boolean> {
const file = diskSnapshotPath(providerId);
try {
await unlink(file);
return true;
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "ENOENT") return false;
return false;
}
}
export type OmniRouteDiskSnapshotWriter = (
providerId: string,
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">,
@@ -5274,7 +4743,6 @@ export function createOmniRouteConfigHook(
autoComboCount: rawAutoCombos.length,
enrichment: rawEnrichment,
autoCombos: rawAutoCombos,
features: resolved.features,
});
}

View File

@@ -1,130 +0,0 @@
/**
* Auto-discovery + force-sync (OpenCode parity with Pi `/omni sync`).
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
sanitizeAutoSyncIntervalMs,
DEFAULT_AUTO_SYNC_INTERVAL_MS,
MIN_AUTO_SYNC_INTERVAL_MS,
parseOmniRoutePluginOptions,
resolveOmniRoutePluginOptions,
invalidateOmniRouteFetchCache,
forceSyncOmniRouteModels,
type OmniRouteFetchCache,
} from "../src/index.js";
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
assert.equal(sanitizeAutoSyncIntervalMs(null), DEFAULT_AUTO_SYNC_INTERVAL_MS);
});
test("sanitizeAutoSyncIntervalMs: 0 disables", () => {
assert.equal(sanitizeAutoSyncIntervalMs(0), 0);
});
test("sanitizeAutoSyncIntervalMs: clamps below min to 60000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(1), MIN_AUTO_SYNC_INTERVAL_MS);
assert.equal(sanitizeAutoSyncIntervalMs(59_999), MIN_AUTO_SYNC_INTERVAL_MS);
});
test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
assert.equal(sanitizeAutoSyncIntervalMs(60_000), 60_000);
assert.equal(sanitizeAutoSyncIntervalMs(300_000), 300_000);
});
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
});
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
const r = resolveOmniRoutePluginOptions({});
assert.equal(r.autoSyncIntervalMs, DEFAULT_AUTO_SYNC_INTERVAL_MS);
});
test("resolveOmniRoutePluginOptions clamps low positive autoSyncIntervalMs", () => {
const r = resolveOmniRoutePluginOptions({ autoSyncIntervalMs: 5000 });
assert.equal(r.autoSyncIntervalMs, MIN_AUTO_SYNC_INTERVAL_MS);
});
test("invalidateOmniRouteFetchCache clears by baseURL prefix", () => {
const cache: OmniRouteFetchCache = new Map();
cache.set("https://a.example/v1::abc", {
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
expiresAt: Date.now() + 1000,
});
cache.set("https://b.example/v1::def", {
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
expiresAt: Date.now() + 1000,
});
const removed = invalidateOmniRouteFetchCache(cache, "https://a.example/v1");
assert.equal(removed, 1);
assert.equal(cache.size, 1);
assert.equal(cache.has("https://b.example/v1::def"), true);
});
test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
autoSyncIntervalMs: 0,
features: {
combos: false,
autoCombos: false,
enrichment: false,
compressionMetadata: false,
usableOnly: false,
diskCache: false,
},
});
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({
omniroute: { type: "api", key: "test-key" },
}),
fetcher: async () => [
{ id: "model-a", object: "model" },
{ id: "model-b", object: "model" },
],
now: () => 1_000_000,
});
assert.equal(result.ok, true);
assert.equal(result.count, 2);
assert.equal(result.provider, "omniroute");
assert.equal(cache.size, 1);
const entry = [...cache.values()][0];
assert.equal(entry.rawModels.length, 2);
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
});
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
autoSyncIntervalMs: 0,
features: { diskCache: false },
});
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({}),
});
assert.equal(result.ok, false);
assert.match(result.error ?? "", /credentials|baseURL|connect/i);
});

View File

@@ -1,95 +0,0 @@
/**
* #7624 — explicit feature-flag defaults.
*
* The `features` block in opencode.json marks every toggle `.optional()` with
* no default. The effective value was previously only knowable by tracing the
* implicit `features.X !== false` (default-ON) / `features.X === true`
* (default-OFF) convention scattered across each read site, which left
* operators unsure whether combos / autoCombos / enrichment were enabled when
* they omitted the block.
*
* `OMNIROUTE_FEATURE_DEFAULTS` declares those defaults explicitly and
* `resolveEffectiveFeatureFlags(features)` derives the effective state for any
* (possibly-undefined) features object, mirroring the read-site conventions
* exactly. These are purely derived — runtime routing behaviour is unchanged.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
OMNIROUTE_FEATURE_DEFAULTS,
resolveEffectiveFeatureFlags,
} from "../src/index.js";
const DEFAULT_ON = [
"combos",
"autoCombos",
"enrichment",
"diskCache",
"providerTag",
"fetchInterceptor",
"geminiSanitization",
] as const;
const DEFAULT_OFF = [
"compressionMetadata",
"usableOnly",
"mcpAutoEmit",
"debugLog",
"startupDebug",
] as const;
test("OMNIROUTE_FEATURE_DEFAULTS: declares each flag with its documented default", () => {
for (const key of DEFAULT_ON) {
assert.equal(OMNIROUTE_FEATURE_DEFAULTS[key], true, `${key} defaults ON`);
}
for (const key of DEFAULT_OFF) {
assert.equal(OMNIROUTE_FEATURE_DEFAULTS[key], false, `${key} defaults OFF`);
}
});
test("resolveEffectiveFeatureFlags: undefined features → full declared default set", () => {
const flags = resolveEffectiveFeatureFlags(undefined);
for (const key of DEFAULT_ON) {
assert.equal(flags[key], true, `${key} effective ON when features omitted`);
}
for (const key of DEFAULT_OFF) {
assert.equal(flags[key], false, `${key} effective OFF when features omitted`);
}
});
test("resolveEffectiveFeatureFlags: empty features object → same as omitted", () => {
assert.deepEqual(
resolveEffectiveFeatureFlags({}),
resolveEffectiveFeatureFlags(undefined)
);
});
test("resolveEffectiveFeatureFlags: explicit false disables a default-ON flag", () => {
const flags = resolveEffectiveFeatureFlags({ autoCombos: false });
assert.equal(flags.autoCombos, false, "explicit autoCombos:false honoured");
// Untouched flags keep their declared defaults.
assert.equal(flags.combos, true);
assert.equal(flags.enrichment, true);
});
test("resolveEffectiveFeatureFlags: explicit true enables a default-OFF flag", () => {
const flags = resolveEffectiveFeatureFlags({ compressionMetadata: true });
assert.equal(flags.compressionMetadata, true, "explicit compressionMetadata:true honoured");
assert.equal(flags.usableOnly, false, "other opt-in flags stay OFF");
});
test("resolveEffectiveFeatureFlags: non-boolean sibling keys do not leak into flags", () => {
// features may also carry mcpToken/logLevel/apiFormat — the resolver must
// only ever return the boolean toggle keys.
const flags = resolveEffectiveFeatureFlags({
mcpAutoEmit: true,
mcpToken: "sk-mcp-token-abc",
logLevel: "debug",
});
assert.equal(flags.mcpAutoEmit, true);
assert.equal(Object.keys(flags).length, Object.keys(OMNIROUTE_FEATURE_DEFAULTS).length);
assert.equal("mcpToken" in flags, false);
assert.equal("logLevel" in flags, false);
});

View File

@@ -3,12 +3,12 @@
## Project
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
with **271 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
with **MCP Server** (104 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
> **Live counts (v3.8.49)**: providers 271 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
@@ -570,16 +570,12 @@ This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operationa
changes (for example GHCR image publishing, personal deployment workflows, or local
automation) out of upstream contribution PRs.
When preparing a PR for upstream, always start the work branch from the upstream
**default branch** — the active `release/vX.Y.Z` line (today `release/v3.8.49`).
Never branch from `main`: `main` only receives release squash-merges, so a branch
cut there is weeks behind and produces conflict-heavy PRs
(see `CONTRIBUTING.md` and `docs/ops/BRANCHING_MODEL.md`):
When preparing a PR for upstream, always start the work branch from `upstream/main`,
not from this fork's `main`:
```bash
git fetch upstream
# the default branch is the active release line, e.g. release/v3.8.49
git switch -c <branch-name> upstream/release/vX.Y.Z
git switch -c <branch-name> upstream/main
```
Only cherry-pick or reapply the changes intended for the upstream PR.

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 290 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 271 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -45,7 +45,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
| 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 (130 migrations) |
| 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 |
@@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route)
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`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-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 13-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
**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.
---
@@ -332,7 +332,7 @@ For any non-trivial change, read the matching deep-dive first:
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
| Architecture | `docs/architecture/ARCHITECTURE.md` |
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
| Auto-Combo (13-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.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` |
@@ -461,18 +461,10 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
git fetch origin "$BASE_BRANCH"
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
cd ".claude/worktrees/${TASK##*/}"
# Reuse the main checkout's node_modules to skip a per-worktree npm install.
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
# 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
```
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
runners all keep passing — the error names "filesystem root", not the worktree, so it
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
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`.

View File

@@ -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/dev/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
@@ -108,19 +103,13 @@ Default URLs:
## Git Workflow
> ⚠️ **NEVER commit directly to `main`.** Always use feature branches.
>
> **PR base:** target the active `release/vX.Y.Z` branch (not `main`). See
> [`docs/ops/BRANCHING_MODEL.md`](docs/ops/BRANCHING_MODEL.md) for the
> release-per-branch + tag-at-ship model.
```bash
# Branch from the active release tip (example: release/v3.8.49)
git fetch origin
git checkout -b feat/your-feature-name origin/release/v3.8.49
git checkout -b feat/your-feature-name
# ... make changes ...
git commit -m "feat: describe your change"
git push -u origin feat/your-feature-name
# Open a Pull Request with base = release/v3.8.49
# Open a Pull Request on GitHub
```
### Branch Naming
@@ -203,15 +192,11 @@ Coverage notes:
### Pull Request Requirements
Before opening a PR, use the
[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and
the production build are CI's responsibility — running them locally adds no signal the PR
checks will not already give you, and on smaller machines it can saturate the host (#8084):
Before opening or merging a PR:
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
- Run `npm run lint`
- Include or update automated tests in the same PR whenever production code changes
- Run `npm run test:unit`
- Run `npm run test:coverage`
- Ensure the coverage gate stays at **60%+** statements/lines/functions/branches
- Include the changed or added test files in the PR description when production code changed
- Check the SonarQube result on the PR when the project secrets are configured in CI
@@ -237,31 +222,6 @@ Current test status: **122 unit test files** covering:
- **Zod validation** — Use Zod v4 schemas for all API input validation
- **Naming**: Files = camelCase/kebab-case, components = PascalCase, constants = UPPER_SNAKE
### Error handling / empty catch blocks
Never leave a `catch` unexplained. Classify it into one of two buckets (operationalizes
the hard rule "never silently swallow errors in SSE streams"):
- **Intentional (our own best-effort cleanup/telemetry)** — a failure here is expected and
harmless; add a one-line rationale comment, no logging (logging on every request is the
noise this convention avoids).
```ts
} catch {} // closing an already-closed controller after client disconnect is expected
```
- **Should log (external/caller-supplied code, or the swallow changes control flow)** — keep
the catch (never let it break the stream) but emit a contextual `console.debug`/`warn` so the
failure is discoverable.
```ts
} catch (e) {
console.debug("[STREAM] onFailure callback error:", e);
}
```
See `open-sse/utils/stream.ts` and `open-sse/utils/streamHandler.ts` for applied examples.
---
## Project Structure
@@ -277,7 +237,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
@@ -287,7 +247,7 @@ src/ # TypeScript (.ts / .tsx)
├── mitm/ # MITM proxy (cert, DNS, target routing)
├── shared/
│ ├── components/ # React components (.tsx)
│ ├── constants/ # Provider definitions (290), 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
@@ -295,7 +255,7 @@ src/ # TypeScript (.ts / .tsx)
open-sse/ # @omniroute/open-sse workspace
├── executors/ # 14 provider-specific request executors
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
├── mcp-server/ # MCP server (104 tools, 3 transports, 31 scopes)
├── 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

View File

@@ -1,5 +1,5 @@
# ── Common base with runtime deps ──────────────────────────────────────────
FROM node:26-trixie-slim AS base
FROM node:24-trixie-slim AS base
WORKDIR /app
# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie)
@@ -8,8 +8,8 @@ WORKDIR /app
# that already have a fix published in trixie. CVEs without an upstream fix yet
# (local-only TOCTOU, etc.) remain until the distro patches them and the image
# is rebuilt; none are reachable from the proxy's request surface at runtime.
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
@@ -29,8 +29,8 @@ FROM base AS builder
# Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
@@ -95,11 +95,6 @@ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
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.
ARG OMNIROUTE_BASE_PATH=""
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).

View File

@@ -27,7 +27,7 @@ When creating _any_ validation tests or one-off logic scripts, default to using
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.
10. **Coverage must stay**75 % statements / 75 % lines / 75 % functions / 70 % branches (real measured: ~82 %).
## 3. Codebase navigation

1121
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -77,28 +77,21 @@ Custom guardrails register via `registerGuardrail(new MyGuardrail())`. The model
### 🧠 Prompt Injection Guard
Best-effort heuristic middleware that detects prompt injection patterns in LLM requests.
**Not a complete prompt-injection firewall** — can produce false positives (benign
persona/RPG prompts) and false negatives (leetspeak, spacing, non-English patterns).
Middleware that detects and blocks prompt injection attacks in LLM requests:
| Pattern Type | Severity | Example |
| ------------------- | -------- | ---------------------------------------------- |
| System Override | High | "ignore all previous instructions" |
| Role Hijack | Medium | "you are now DAN, you can do anything" |
| Delimiter Injection | High | Encoded separators to break context boundaries |
| DAN/Jailbreak | Medium | Known jailbreak prompt patterns |
| Instruction Leak | High | "show me your system prompt" |
| Encoding Evasion | Medium | base64/rot13/hex decode + instruction keywords |
Only **High** severity detections are blocked in `block` mode. Medium-severity
families are logged but never blocked by `sanitizeRequest`.
| Role Hijack | High | "you are now DAN, you can do anything" |
| Delimiter Injection | Medium | Encoded separators to break context boundaries |
| DAN/Jailbreak | High | Known jailbreak prompt patterns |
| Instruction Leak | Medium | "show me your system prompt" |
Configure via dashboard (Settings → Security) or `.env`:
```env
INPUT_SANITIZER_ENABLED=true
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text)
INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
INPUT_SANITIZER_MODE=block # warn | block | redact
```
### 🔒 PII Redaction
@@ -115,8 +108,7 @@ Automatic detection and optional redaction of personally identifiable informatio
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
```env
PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE
PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients
PII_REDACTION_ENABLED=true
```
### 🌐 Network Security

View File

@@ -78,15 +78,18 @@ export function registerBackup(program) {
if (exitCode !== 0) process.exit(exitCode);
});
// Legacy: `omniroute backup` without a subcommand still creates a backup
// (documented as the canonical usage in USER_GUIDE.md / CLI-TOOLS.md /
// AGENT-SKILLS.md). No flags are declared here — declaring the same
// option names as `create`/`auto enable` here previously shadowed them
// (#8512), and no doc shows `omniroute backup` invoked with flags.
// Legacy: `omniroute backup` without subcommand still creates a backup
backup.action(async (opts) => {
const exitCode = await runBackupCommand(opts);
if (exitCode !== 0) process.exit(exitCode);
});
backup
.option("--name <name>", t("backup.nameOpt"))
.option("--cloud", t("backup.cloudOpt"))
.option("--encrypt", t("backup.encryptOpt"))
.option("--key-file <path>", t("backup.keyFileOpt"))
.option("--exclude <pattern>", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], [])
.option("--retention <n>", t("backup.retentionOpt"), parseInt);
}
export function registerRestore(program) {

View File

@@ -1,7 +1,6 @@
import { spawn } from "node:child_process";
import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
/** 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
@@ -30,23 +29,6 @@ export function resolveCodexSpawn(platform) {
return { command: "codex", shell: undefined };
}
/**
* `shell: true` makes Node join argv with plain spaces and no escaping (the
* DEP0190 warning). That mangles every launch-codex invocation on Windows, not
* just the ones with a multi-word user argument: the injected `-c` provider
* flags carry TOML values whose quotes cmd.exe strips
* (`model_providers.omniroute.name="OmniRoute"` arrives unquoted and no longer
* parses as TOML). Quote the args ourselves on that path; off Windows there is
* no shell, so argv is passed through untouched. Same fix as `launch` (#8837).
*
* @param {string[]} args
* @param {NodeJS.Platform|string} platform
* @returns {string[]}
*/
export function quoteCodexArgs(args, platform) {
return quoteShellArgs(args, platform);
}
function stripTrailingSlash(value) {
let s = String(value);
let end = s.length;
@@ -171,7 +153,7 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
return await new Promise((resolve) => {
const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform);
const child = spawn(codexLaunch, quoteCodexArgs(extraArgs, process.platform), {
const child = spawn(codexLaunch, extraArgs, {
env,
stdio: "inherit",
shell: shellValue,
@@ -213,10 +195,7 @@ export function registerLaunchCodex(program) {
.argument("[codexArgs...]", "arguments passed through to the codex binary")
.action(async (codexArgs, opts) => {
const merged = { ...opts, profile: opts.profile ?? opts.p };
// process.exit() here aborted the process with a libuv assertion on
// Windows (`!(handle->flags & UV_HANDLE_CLOSING)`, async.c:94): it tears
// the loop down while the inherited stdio handles of the just-exited
// child are still closing. Setting exitCode lets the loop drain first.
process.exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
if (exitCode !== 0) process.exit(exitCode);
});
}

View File

@@ -3,7 +3,6 @@ import { join } from "node:path";
import os from "node:os";
import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
function stripTrailingSlash(value) {
let s = String(value);
@@ -91,34 +90,6 @@ export function resolveLaunchTarget(opts = {}) {
return { baseUrl, authToken };
}
/**
* #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
* @returns {{ command: string, shell: true|undefined }}
*/
export function resolveClaudeSpawn(platform) {
return platform === "win32"
? { command: "claude.cmd", shell: true }
: { command: "claude", shell: undefined };
}
/**
* `shell: true` makes Node join argv with plain spaces and no escaping (the
* DEP0190 warning), so `-p "two words"` used to reach claude as `-p two` plus
* three stray positional arguments. Quote the args ourselves on that path.
* Off Windows there is no shell, so argv is passed through untouched.
*
* @param {string[]} args
* @param {NodeJS.Platform|string} platform
* @returns {string[]}
*/
export function quoteClaudeArgs(args, platform) {
return quoteShellArgs(args, platform);
}
/**
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
* @param {string[]} claudeArgs pass-through args for the claude binary
@@ -135,10 +106,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
if (!res.ok) throw new Error(`status ${res.status}`);
} catch {
console.error(
(
t("launch.notRunning") ||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
).replace("{port}", baseUrl)
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
"{port}",
baseUrl
)
);
return 1;
}
@@ -149,13 +120,7 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
return await new Promise((resolve) => {
const { command, shell } = resolveClaudeSpawn(process.platform);
const child = spawn(command, quoteClaudeArgs(claudeArgs, process.platform), {
env,
stdio: "inherit",
shell,
...(process.platform === "win32" ? { windowsHide: true } : {}),
});
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
child.on("error", (err) => {
if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
@@ -177,20 +142,14 @@ export function registerLaunch(program) {
)
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
.option(
"--profile <name>",
"Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)"
)
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
.allowUnknownOption(true)
.allowExcessArguments(true)
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
.action(async (claudeArgs, opts) => {
// process.exit() here aborted the process with a libuv assertion on
// Windows (`!(handle->flags & UV_HANDLE_CLOSING)`, async.c:94): it tears
// the loop down while the inherited stdio handles of the just-exited
// child are still closing. Setting exitCode lets the loop drain first.
process.exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
const exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
if (exitCode !== 0) process.exit(exitCode);
});
}

View File

@@ -7,11 +7,6 @@ import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
import {
ensureAndroidCacheDir,
isFatalInstrumentationHookFailure,
formatAndroidInstrumentationFailureHint,
} from "../utils/ensureAndroidCacheDir.mjs";
import {
resolveMaxOldSpaceMb,
calibrateHeapFallbackMb,
@@ -67,37 +62,9 @@ export function registerServe(program) {
});
}
/** Once-per-process guard so the Android/Termux cache hint is not spammed. */
let instrumentationFailureHintPrinted = false;
/**
* If child output looks like Next.js failed to load its instrumentation hook
* on Android/Termux, print a clear operator-facing fix hint.
* Exported for unit tests.
*
* @param {string} text
* @returns {boolean} true when a hint was printed
*/
export function maybeReportInstrumentationHookFailure(text) {
if (instrumentationFailureHintPrinted) return false;
if (!isFatalInstrumentationHookFailure(text)) return false;
instrumentationFailureHintPrinted = true;
process.stderr.write(formatAndroidInstrumentationFailureHint(process.env.XDG_CACHE_HOME));
return true;
}
/** Test-only reset for the once-per-process hint guard. */
export function resetInstrumentationFailureHintForTests() {
instrumentationFailureHintPrinted = false;
}
export async function runServe(opts = {}) {
const startedAt = performance.now();
// Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call
// (tests / programmatic) still gets a writable Next.js cache dir before spawn.
ensureAndroidCacheDir({ env: process.env });
const { isNativeBinaryCompatible } =
await import("../../../scripts/build/native-binary-compat.mjs");
const { getNodeRuntimeSupport, getNodeRuntimeWarning } =
@@ -167,11 +134,7 @@ export async function runServe(opts = {}) {
"Release",
"better_sqlite3.node"
);
if (
!process.versions.bun &&
existsSync(sqliteBinary) &&
!isNativeBinaryCompatible(sqliteBinary)
) {
if (!process.versions.bun && existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
console.error(
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
);
@@ -267,16 +230,15 @@ export async function runServe(opts = {}) {
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
// #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).
const server = spawn(
process.versions.bun ? process.execPath : "node",
[...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs],
{
cwd: APP_DIR,
env,
stdio: "ignore",
detached: true,
}
);
const server = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)),
serverJs,
], {
cwd: APP_DIR,
env,
stdio: "ignore",
detached: true,
});
writePidFile("server", server.pid);
server.unref();
console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`);
@@ -287,15 +249,14 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) {
// #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).
const server = spawn(
process.versions.bun ? process.execPath : "node",
[...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs],
{
cwd: APP_DIR,
env,
stdio: "pipe",
}
);
const server = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)),
serverJs,
], {
cwd: APP_DIR,
env,
stdio: "pipe",
});
writePidFile("server", server.pid);
@@ -304,7 +265,6 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
server.stdout.on("data", (data) => {
const text = data.toString();
process.stdout.write(text);
maybeReportInstrumentationHookFailure(text);
if (
!started &&
(text.includes("Ready") || text.includes("started") || text.includes("listening"))
@@ -314,11 +274,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
}
});
server.stderr.on("data", (data) => {
const text = data.toString();
process.stderr.write(text);
maybeReportInstrumentationHookFailure(text);
});
server.stderr.on("data", (data) => process.stderr.write(data));
server.on("error", (err) => {
console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message);
@@ -427,9 +383,6 @@ export function reportReadinessTimeout(dashboardPort, supervisor) {
console.error("--- Recent server output ---");
recentLog.forEach((l) => console.error(l));
console.error("--- End recent output ---\n");
// If the buffered log already shows the Android instrumentation failure,
// print the actionable hint even when --log was off (default).
maybeReportInstrumentationHookFailure(recentLog.join("\n"));
}
}

View File

@@ -26,7 +26,6 @@ function wantsProviderSetup(opts) {
async function resolvePassword(opts, prompt, nonInteractive) {
if (opts.password) return opts.password;
if (process.env.INITIAL_PASSWORD) return process.env.INITIAL_PASSWORD;
if (nonInteractive) return "";
const answer = await prompt.ask("Set an admin password now? [y/N]", "N");

View File

@@ -8,7 +8,6 @@ import {
sleep,
} from "../utils/pid.mjs";
import { t } from "../i18n.mjs";
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
const execFileAsync = promisify(execFile);
@@ -28,11 +27,18 @@ export async function runStopCommand(opts = {}) {
if (pid && isPidRunning(pid)) {
console.log(t("stop.stopping", { pid }));
try {
// #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.
await stopProcessGracefully({ pid, timeoutMs: 5000, isPidRunning, sleep });
process.kill(pid, "SIGTERM");
let waited = 0;
while (waited < 5000 && isPidRunning(pid)) {
await sleep(100);
waited += 100;
}
if (isPidRunning(pid)) {
process.kill(pid, "SIGKILL");
await sleep(500);
}
killAllSubprocesses();
cleanupPidFile("server");

View File

@@ -29,16 +29,16 @@
"setup": {
"title": "OmniRoute 设置",
"passwordPrompt": "管理员密码",
"providerPrompt": "默认提供(留空跳过)",
"providerPrompt": "默认提供(留空跳过)",
"done": "设置完成",
"passwordSet": "管理员密码已配置",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在测试提供连接:{name}",
"testPassed": "提供测试通过",
"testFailed": "提供测试失败:{error}",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在测试提供连接:{name}",
"testPassed": "提供测试通过",
"testFailed": "提供测试失败:{error}",
"loginEnabled": "登录:已启用(密码已更新)",
"loginDisabled": "登录:已禁用",
"providerInfo": "提供{info}"
"providerInfo": "提供{info}"
},
"doctor": {
"title": "OmniRoute 诊断",
@@ -52,29 +52,29 @@
"warnings": "{count} 个警告 — 见上文。"
},
"providers": {
"title": "提供",
"noProviders": "未配置提供。运行omniroute setup",
"title": "提供",
"noProviders": "未配置提供。运行omniroute setup",
"testing": "正在测试 {name}...",
"available": "{count} 个提供可用",
"available": "{count} 个提供可用",
"connected": "已连接",
"disconnected": "未连接",
"validationFailed": "验证失败:{error}",
"metrics": {
"description": "显示提供性能指标(延迟、成功率、成本)",
"provider": "按提供 ID 筛选",
"description": "显示提供性能指标(延迟、成功率、成本)",
"provider": "按提供 ID 筛选",
"connection_id": "按连接 ID 筛选",
"period": "时间范围1h|6h|24h|7d|30d默认24h",
"metric": "关注特定指标字段",
"sort": "按字段排序(降序)",
"limit": "最大行数默认50",
"watch": "每 5 秒刷新(实时模式)",
"compare": "逗号分隔的提供 ID并排比较"
"compare": "逗号分隔的提供 ID并排比较"
},
"metric_single": {
"description": "获取特定连接的单个指标值"
},
"rotate": {
"description": "轮换提供连接的上游 API 密钥",
"description": "轮换提供连接的上游 API 密钥",
"newKeyOpt": "新 API 密钥值(避免使用:优先使用 --from-env",
"fromEnvOpt": "从环境变量 VAR 读取新密钥",
"oauthOpt": "改为触发 OAuth 重新认证流程",
@@ -89,18 +89,18 @@
"testFailed": "轮换后测试失败:{error}"
},
"status": {
"description": "显示所有提供连接的密钥健康状态(期限、过期、冷却)",
"providerOpt": "按提供名称筛选",
"header": "ID 提供 名称 过期状态 测试状态 冷却至",
"noData": "没有可用的提供连接数据。",
"description": "显示所有提供连接的密钥健康状态(期限、过期、冷却)",
"providerOpt": "按提供名称筛选",
"header": "ID 提供 名称 过期状态 测试状态 冷却至",
"noData": "没有可用的提供连接数据。",
"requiresServer": "providers status 需要 OmniRoute 服务器正在运行。"
}
},
"keys": {
"title": "API 密钥",
"addDescription": "为提供添加或更新 API 密钥",
"addDescription": "为提供添加或更新 API 密钥",
"listDescription": "列出所有已配置的 API 密钥",
"removeDescription": "移除提供的 API 密钥",
"removeDescription": "移除提供的 API 密钥",
"regenerateDescription": "重新生成 OmniRoute API 密钥",
"revokeDescription": "撤销 OmniRoute API 密钥",
"revealDescription": "显示未掩码的 API 密钥值",
@@ -122,10 +122,10 @@
"revoked": "密钥 {id} 已撤销。",
"rotated": "密钥 {id} 已轮换。新密钥 ID{newId}",
"revealWarning": "⚠ 这将显示完整的未掩码密钥。请确保您的屏幕不被人看到。",
"providerRequired": "需要提供。",
"providerRequired": "需要提供。",
"keyRequired": "需要 API 密钥。",
"stdinEmpty": "未通过标准输入提供 API 密钥。",
"unknownProvider": "未知提供{provider}",
"unknownProvider": "未知提供{provider}",
"policy": {
"title": "密钥策略",
"showDescription": "显示密钥的速率/成本策略",
@@ -165,7 +165,7 @@
"analytics": {
"description": "显示汇总使用分析",
"period": "时间范围1d|7d|30d|90d|ytd|all默认30d",
"provider": "按提供 ID 筛选"
"provider": "按提供 ID 筛选"
},
"budget": {
"description": "管理成本预算",
@@ -175,8 +175,8 @@
}
},
"quota": {
"description": "显示提供配额使用情况",
"provider": "按提供 ID 筛选",
"description": "显示提供配额使用情况",
"provider": "按提供 ID 筛选",
"check": "显示新请求是否有可用配额"
},
"logs": {
@@ -201,7 +201,7 @@
}
},
"cost": {
"description": "按提供、模型、组合或 API 密钥显示成本报告",
"description": "按提供、模型、组合或 API 密钥显示成本报告",
"period": "时间范围1d|7d|30d|90d|ytd|all默认30d",
"since": "开始日期ISO 格式,例如 2026-01-01— 覆盖 --period",
"until": "结束日期ISO 格式)",
@@ -210,7 +210,7 @@
"limit": "显示的最大行数默认100"
},
"simulate": {
"description": "模拟路由(空运行)— 显示将选择哪些提供而不调用上游",
"description": "模拟路由(空运行)— 显示将选择哪些提供而不调用上游",
"file": "从 JSON 文件加载完整请求体",
"model": "模型 ID默认auto",
"combo": "强制指定组合名称",
@@ -301,7 +301,7 @@
"cost": "成本24h"
},
"quota": {
"description": "显示提供配额使用情况",
"description": "显示提供配额使用情况",
"noServer": "服务器未运行。启动omniroute serve",
"noData": "没有可用的配额信息。"
},
@@ -315,12 +315,12 @@
"description": "一键启动本地 Redis 容器Podman 或 Docker用于 OmniRoute 缓存和配额跟踪"
},
"test": {
"description": "测试提供连接",
"description": "测试提供连接",
"noServer": "服务器未运行。启动omniroute serve",
"testing": "正在测试 {provider} / {model}...",
"passed": "连接成功!",
"failed": "连接失败:{error}",
"allProvidersOpt": "测试所有已配置的提供",
"allProvidersOpt": "测试所有已配置的提供",
"latencyOpt": "显示延迟测量值(平均/最小/最大 毫秒)",
"repeatOpt": "重复测试 N 次并汇总结果",
"compareOpt": "逗号分隔的要比较的模型(例如 gpt-4o,claude-3-5-sonnet",
@@ -328,7 +328,7 @@
"saved": "结果已保存至 {path}",
"compareTitle": "模型比较",
"compareMinTwo": "--compare 需要至少两个模型(逗号分隔)",
"noProviders": "未配置提供。添加omniroute keys add"
"noProviders": "未配置提供。添加omniroute keys add"
},
"update": {
"checking": "正在检查更新...",
@@ -509,7 +509,7 @@
},
"models": {
"description": "列出可用模型(需要服务器)",
"search": "按 ID、名称、提供或描述筛选模型",
"search": "按 ID、名称、提供或描述筛选模型",
"noServer": "服务器未运行。启动omniroute serve",
"noModels": "未找到模型。"
},
@@ -656,25 +656,25 @@
}
},
"oauth": {
"description": "管理 OAuth 提供连接",
"description": "管理 OAuth 提供连接",
"providers": {
"description": "列出支持 OAuth 的提供及其流程类型"
"description": "列出支持 OAuth 的提供及其流程类型"
},
"start": {
"description": "为提供启动 OAuth 授权流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"description": "为提供启动 OAuth 授权流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"no_browser": "仅打印 URL — 不打开浏览器",
"import_system": "从本地系统配置自动导入凭据",
"social": "社交登录提供google|github— kiro 需要",
"social": "社交登录提供google|github— kiro 需要",
"timeout": "等待授权超时时间毫秒默认300000"
},
"status": {
"description": "列出活动的 OAuth 连接",
"provider": "按提供 ID 筛选"
"provider": "按提供 ID 筛选"
},
"revoke": {
"description": "撤销 OAuth 连接",
"provider": "要撤销的提供 ID",
"provider": "要撤销的提供 ID",
"connection_id": "按 ID 撤销特定连接",
"yes": "跳过确认提示"
}
@@ -884,11 +884,11 @@
"description": "管理模型定价数据",
"sync": {
"description": "从上游同步价格",
"provider": "按提供筛选",
"provider": "按提供筛选",
"force": "强制重新同步"
},
"list": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"model": "按模型筛选",
"limit": "最大结果数"
},
@@ -907,25 +907,25 @@
"resilience": {
"description": "检查和管理弹性机制",
"status": {
"provider": "按提供筛选"
"provider": "按提供筛选"
},
"breakers": {
"provider": "按提供筛选"
"provider": "按提供筛选"
},
"cooldowns": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"connectionId": "按连接 ID 筛选"
},
"lockouts": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"model": "按模型筛选"
},
"reset": {
"description": "重置断路器/冷却状态",
"provider": "要重置的提供",
"provider": "要重置的提供",
"connectionId": "要重置的连接 ID",
"model": "要重置锁定状态的模型",
"allCooldowns": "重置提供的所有冷却",
"allCooldowns": "重置提供的所有冷却",
"yes": "跳过确认"
},
"profile": {
@@ -940,13 +940,13 @@
}
},
"nodes": {
"description": "管理提供节点(端点)",
"description": "管理提供节点(端点)",
"list": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"enabled": "仅显示已启用的节点"
},
"add": {
"provider": "提供名称",
"provider": "提供名称",
"baseUrl": "节点的基础 URL",
"name": "节点名称",
"weight": "负载均衡权重",
@@ -965,7 +965,7 @@
},
"validate": {
"baseUrl": "要验证的 URL",
"provider": "要验证的提供"
"provider": "要验证的提供"
},
"test": {
"description": "向节点发送测试请求"
@@ -1091,7 +1091,7 @@
"oneproxy": {
"description": "管理 OneProxy 上游代理池",
"stats": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"period": "时间范围默认24h"
},
"fetch": {
@@ -1101,14 +1101,14 @@
},
"rotate": {
"description": "强制轮换代理",
"provider": "要轮换的提供",
"provider": "要轮换的提供",
"connectionId": "要轮换的特定连接 ID"
},
"config": {
"description": "显示或更新 OneProxy 配置",
"enabled": "启用代理池true|false",
"poolSize": "池大小",
"providerSource": "代理提供的 URL",
"providerSource": "代理提供的 URL",
"rotationPolicy": "轮换策略sticky|per-request|periodic"
},
"pool": {
@@ -1201,7 +1201,7 @@
"bash": "打印 bash 补全脚本",
"fish": "打印 fish 补全脚本",
"install": "为检测到的 Shell 全局安装补全脚本",
"refresh": "刷新组合/提供/模型缓存"
"refresh": "刷新组合/提供/模型缓存"
},
"logs": {
"description": "流式传输或导出请求日志",

View File

@@ -29,16 +29,16 @@
"setup": {
"title": "OmniRoute 設定",
"passwordPrompt": "管理員密碼",
"providerPrompt": "預設提供(留空跳過)",
"providerPrompt": "預設提供(留空跳過)",
"done": "設定完成",
"passwordSet": "管理員密碼已配置",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在測試提供連線:{name}",
"testPassed": "提供測試通過",
"testFailed": "提供測試失敗:{error}",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在測試提供連線:{name}",
"testPassed": "提供測試通過",
"testFailed": "提供測試失敗:{error}",
"loginEnabled": "登入:已啟用(密碼已更新)",
"loginDisabled": "登入:已停用",
"providerInfo": "提供{info}"
"providerInfo": "提供{info}"
},
"doctor": {
"title": "OmniRoute 診斷",
@@ -52,29 +52,29 @@
"warnings": "{count} 個警告 — 見上文。"
},
"providers": {
"title": "提供",
"noProviders": "未配置提供。執行omniroute setup",
"title": "提供",
"noProviders": "未配置提供。執行omniroute setup",
"testing": "正在測試 {name}...",
"available": "{count} 個提供可用",
"available": "{count} 個提供可用",
"connected": "已連線",
"disconnected": "未連線",
"validationFailed": "驗證失敗:{error}",
"metrics": {
"description": "顯示提供效能指標(延遲、成功率、成本)",
"provider": "按提供 ID 篩選",
"description": "顯示提供效能指標(延遲、成功率、成本)",
"provider": "按提供 ID 篩選",
"connection_id": "按連線 ID 篩選",
"period": "時間範圍1h|6h|24h|7d|30d預設24h",
"metric": "關注特定指標欄位",
"sort": "按欄位排序(降序)",
"limit": "最大行數預設50",
"watch": "每 5 秒重新整理(即時模式)",
"compare": "逗號分隔的提供 ID並排比較"
"compare": "逗號分隔的提供 ID並排比較"
},
"metric_single": {
"description": "獲取特定連線的單個指標值"
},
"rotate": {
"description": "輪換提供連線的上游 API 金鑰",
"description": "輪換提供連線的上游 API 金鑰",
"newKeyOpt": "新 API 金鑰值(避免使用:優先使用 --from-env",
"fromEnvOpt": "從環境變數 VAR 讀取新金鑰",
"oauthOpt": "改為觸發 OAuth 重新認證流程",
@@ -89,18 +89,18 @@
"testFailed": "輪換後測試失敗:{error}"
},
"status": {
"description": "顯示所有提供連線的金鑰健康狀態(期限、過期、冷卻)",
"providerOpt": "按提供名稱篩選",
"header": "ID 提供 名稱 過期狀態 測試狀態 冷卻至",
"noData": "沒有可用的提供連線資料。",
"description": "顯示所有提供連線的金鑰健康狀態(期限、過期、冷卻)",
"providerOpt": "按提供名稱篩選",
"header": "ID 提供 名稱 過期狀態 測試狀態 冷卻至",
"noData": "沒有可用的提供連線資料。",
"requiresServer": "providers status 需要 OmniRoute 伺服器正在執行。"
}
},
"keys": {
"title": "API 金鑰",
"addDescription": "為提供新增或更新 API 金鑰",
"addDescription": "為提供新增或更新 API 金鑰",
"listDescription": "列出所有已配置的 API 金鑰",
"removeDescription": "移除提供的 API 金鑰",
"removeDescription": "移除提供的 API 金鑰",
"regenerateDescription": "重新生成 OmniRoute API 金鑰",
"revokeDescription": "撤銷 OmniRoute API 金鑰",
"revealDescription": "顯示未掩碼的 API 金鑰值",
@@ -122,10 +122,10 @@
"revoked": "金鑰 {id} 已撤銷。",
"rotated": "金鑰 {id} 已輪換。新金鑰 ID{newId}",
"revealWarning": "⚠ 這將顯示完整的未掩碼金鑰。請確保您的螢幕不被人看到。",
"providerRequired": "需要提供。",
"providerRequired": "需要提供。",
"keyRequired": "需要 API 金鑰。",
"stdinEmpty": "未通過標準輸入提供 API 金鑰。",
"unknownProvider": "未知提供{provider}",
"unknownProvider": "未知提供{provider}",
"policy": {
"title": "金鑰策略",
"showDescription": "顯示金鑰的速率/成本策略",
@@ -151,7 +151,7 @@
"model": "模型 ID預設auto",
"system": "系統提示",
"combo": "強制指定組合名稱",
"max_tokens": "響應最大權杖數",
"max_tokens": "響應最大令牌數",
"responses_api": "使用 /v1/responses 而不是 /v1/chat/completions",
"raw": "列印接收到的原始 SSE 行",
"debug": "在 stderr 中列印每塊的時間資訊",
@@ -165,7 +165,7 @@
"analytics": {
"description": "顯示彙總使用分析",
"period": "時間範圍1d|7d|30d|90d|ytd|all預設30d",
"provider": "按提供 ID 篩選"
"provider": "按提供 ID 篩選"
},
"budget": {
"description": "管理成本預算",
@@ -175,8 +175,8 @@
}
},
"quota": {
"description": "顯示提供配額使用情況",
"provider": "按提供 ID 篩選",
"description": "顯示提供配額使用情況",
"provider": "按提供 ID 篩選",
"check": "顯示新請求是否有可用配額"
},
"logs": {
@@ -201,7 +201,7 @@
}
},
"cost": {
"description": "按提供、模型、組合或 API 金鑰顯示成本報告",
"description": "按提供、模型、組合或 API 金鑰顯示成本報告",
"period": "時間範圍1d|7d|30d|90d|ytd|all預設30d",
"since": "開始日期ISO 格式,例如 2026-01-01— 覆蓋 --period",
"until": "結束日期ISO 格式)",
@@ -210,12 +210,12 @@
"limit": "顯示的最大行數預設100"
},
"simulate": {
"description": "模擬路由(空執行)— 顯示將選擇哪些提供而不呼叫上游",
"description": "模擬路由(空執行)— 顯示將選擇哪些提供而不呼叫上游",
"file": "從 JSON 檔案載入完整請求體",
"model": "模型 ID預設auto",
"combo": "強制指定組合名稱",
"reasoning": "推理努力級別low|medium|high",
"thinking": "擴充套件思維權杖預算",
"thinking": "擴充套件思維令牌預算",
"explain": "在 stderr 中列印回退樹和成本範圍",
"noCombo": "未找到匹配的組合。使用以下命令配置omniroute combo create"
},
@@ -225,11 +225,11 @@
"stdin": "從標準輸入讀取提示",
"system": "系統提示",
"model": "模型 ID預設auto",
"max_tokens": "響應最大權杖數",
"max_tokens": "響應最大令牌數",
"temperature": "取樣溫度02",
"top_p": "Top-p 核取樣",
"reasoning_effort": "推理努力級別low|medium|high",
"thinking_budget": "擴充套件思維權杖預算",
"thinking_budget": "擴充套件思維令牌預算",
"combo": "強制指定組合名稱",
"responses_api": "使用 /v1/responses 而不是 /v1/chat/completions",
"stream": "增量流式傳輸響應",
@@ -301,7 +301,7 @@
"cost": "成本24h"
},
"quota": {
"description": "顯示提供配額使用情況",
"description": "顯示提供配額使用情況",
"noServer": "伺服器未執行。啟動omniroute serve",
"noData": "沒有可用的配額資訊。"
},
@@ -315,12 +315,12 @@
"description": "一鍵啟動本地 Redis 容器Podman 或 Docker用於 OmniRoute 快取和配額跟蹤"
},
"test": {
"description": "測試提供連線",
"description": "測試提供連線",
"noServer": "伺服器未執行。啟動omniroute serve",
"testing": "正在測試 {provider} / {model}...",
"passed": "連線成功!",
"failed": "連線失敗:{error}",
"allProvidersOpt": "測試所有已配置的提供",
"allProvidersOpt": "測試所有已配置的提供",
"latencyOpt": "顯示延遲測量值(平均/最小/最大 毫秒)",
"repeatOpt": "重複測試 N 次並彙總結果",
"compareOpt": "逗號分隔的要比較的模型(例如 gpt-4o,claude-3-5-sonnet",
@@ -328,7 +328,7 @@
"saved": "結果已儲存至 {path}",
"compareTitle": "模型比較",
"compareMinTwo": "--compare 需要至少兩個模型(逗號分隔)",
"noProviders": "未配置提供。新增omniroute keys add"
"noProviders": "未配置提供。新增omniroute keys add"
},
"update": {
"checking": "正在檢查更新...",
@@ -445,7 +445,7 @@
"description": "配置壓縮設定",
"engine": "壓縮引擎caveman|rtk|hybrid|none",
"caveman_agg": "Caveman 激程序度 0.01.0",
"rtk_budget": "RTK 權杖預算",
"rtk_budget": "RTK 令牌預算",
"language_pack": "要啟用的語言包"
},
"engine": {
@@ -509,7 +509,7 @@
},
"models": {
"description": "列出可用模型(需要伺服器)",
"search": "按 ID、名稱、提供或描述篩選模型",
"search": "按 ID、名稱、提供或描述篩選模型",
"noServer": "伺服器未執行。啟動omniroute serve",
"noModels": "未找到模型。"
},
@@ -621,7 +621,7 @@
"type": "按記憶型別篩選user|feedback|project|reference",
"limit": "最大結果數預設20",
"api_key": "按 API 金鑰篩選",
"token_budget": "限制結果中的總權杖數"
"token_budget": "限制結果中的總令牌數"
},
"add": {
"description": "新增新的記憶條目",
@@ -656,25 +656,25 @@
}
},
"oauth": {
"description": "管理 OAuth 提供連線",
"description": "管理 OAuth 提供連線",
"providers": {
"description": "列出支援 OAuth 的提供及其流程型別"
"description": "列出支援 OAuth 的提供及其流程型別"
},
"start": {
"description": "為提供啟動 OAuth 授權流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"description": "為提供啟動 OAuth 授權流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"no_browser": "僅列印 URL — 不開啟瀏覽器",
"import_system": "從本地系統配置自動匯入憑據",
"social": "社交登入提供google|github— kiro 需要",
"social": "社交登入提供google|github— kiro 需要",
"timeout": "等待授權超時時間毫秒預設300000"
},
"status": {
"description": "列出活動的 OAuth 連線",
"provider": "按提供 ID 篩選"
"provider": "按提供 ID 篩選"
},
"revoke": {
"description": "撤銷 OAuth 連線",
"provider": "要撤銷的提供 ID",
"provider": "要撤銷的提供 ID",
"connection_id": "按 ID 撤銷特定連線",
"yes": "跳過確認提示"
}
@@ -884,20 +884,20 @@
"description": "管理模型定價資料",
"sync": {
"description": "從上游同步價格",
"provider": "按提供篩選",
"provider": "按提供篩選",
"force": "強制重新同步"
},
"list": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"model": "按模型篩選",
"limit": "最大結果數"
},
"defaults": {
"description": "管理預設定價",
"input": "每 1M 權杖的輸入成本(美元)",
"output": "每 1M 權杖的輸出成本(美元)",
"cacheRead": "每 1M 權杖的快取讀取成本(美元)",
"cacheWrite": "每 1M 權杖的快取寫入成本(美元)"
"input": "每 1M 令牌的輸入成本(美元)",
"output": "每 1M 令牌的輸出成本(美元)",
"cacheRead": "每 1M 令牌的快取讀取成本(美元)",
"cacheWrite": "每 1M 令牌的快取寫入成本(美元)"
},
"diff": {
"description": "顯示與上游價格的差異",
@@ -907,25 +907,25 @@
"resilience": {
"description": "檢查和管理彈性機制",
"status": {
"provider": "按提供篩選"
"provider": "按提供篩選"
},
"breakers": {
"provider": "按提供篩選"
"provider": "按提供篩選"
},
"cooldowns": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"connectionId": "按連線 ID 篩選"
},
"lockouts": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"model": "按模型篩選"
},
"reset": {
"description": "重置斷路器/冷卻狀態",
"provider": "要重置的提供",
"provider": "要重置的提供",
"connectionId": "要重置的連線 ID",
"model": "要重置鎖定狀態的模型",
"allCooldowns": "重置提供的所有冷卻",
"allCooldowns": "重置提供的所有冷卻",
"yes": "跳過確認"
},
"profile": {
@@ -940,13 +940,13 @@
}
},
"nodes": {
"description": "管理提供節點(端點)",
"description": "管理提供節點(端點)",
"list": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"enabled": "僅顯示已啟用的節點"
},
"add": {
"provider": "提供名稱",
"provider": "提供名稱",
"baseUrl": "節點的基礎 URL",
"name": "節點名稱",
"weight": "負載均衡權重",
@@ -965,7 +965,7 @@
},
"validate": {
"baseUrl": "要驗證的 URL",
"provider": "要驗證的提供"
"provider": "要驗證的提供"
},
"test": {
"description": "向節點發送測試請求"
@@ -993,7 +993,7 @@
"description": "管理 RTK 上下文最佳化器",
"config": {
"description": "顯示或更新 RTK 配置",
"tokenBudget": "RTK 權杖預算",
"tokenBudget": "RTK 令牌預算",
"reservePct": "保留百分比"
},
"filters": {
@@ -1091,7 +1091,7 @@
"oneproxy": {
"description": "管理 OneProxy 上游代理池",
"stats": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"period": "時間範圍預設24h"
},
"fetch": {
@@ -1101,14 +1101,14 @@
},
"rotate": {
"description": "強制輪換代理",
"provider": "要輪換的提供",
"provider": "要輪換的提供",
"connectionId": "要輪換的特定連線 ID"
},
"config": {
"description": "顯示或更新 OneProxy 配置",
"enabled": "啟用代理池true|false",
"poolSize": "池大小",
"providerSource": "代理提供的 URL",
"providerSource": "代理提供的 URL",
"rotationPolicy": "輪換策略sticky|per-request|periodic"
},
"pool": {
@@ -1163,11 +1163,11 @@
"fromCloud": "從雲備份初始化"
},
"tokens": {
"description": "管理同步權杖",
"description": "管理同步令牌",
"create": {
"name": "權杖名稱",
"scope": "權杖作用域",
"ttl": "權杖有效期(例如 30d"
"name": "令牌名稱",
"scope": "令牌作用域",
"ttl": "令牌有效期(例如 30d"
},
"revoke": {
"yes": "跳過確認"
@@ -1201,7 +1201,7 @@
"bash": "列印 bash 補全指令碼",
"fish": "列印 fish 補全指令碼",
"install": "為檢測到的 Shell 全域性安裝補全指令碼",
"refresh": "重新整理組合/提供/模型快取"
"refresh": "重新整理組合/提供/模型快取"
},
"logs": {
"description": "流式傳輸或匯出請求日誌",

View File

@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { dirname } from "node:path";
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
import { writePidFile, cleanupPidFile, killAllSubprocesses } from "../utils/pid.mjs";
import {
RESTART_RESET_MS,
DEFAULT_MAX_RESTARTS,
@@ -9,22 +9,11 @@ import {
waitUntilPortFree,
} from "./supervisorPolicy.mjs";
import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs";
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
import {
isFatalInstrumentationHookFailure,
formatAndroidInstrumentationFailureHint,
} from "../utils/ensureAndroidCacheDir.mjs";
const CRASH_LOG_LINES = 50;
export class ServerSupervisor {
constructor({
serverPath,
env,
maxRestarts = DEFAULT_MAX_RESTARTS,
memoryLimit = 512,
onCrashCallback,
}) {
constructor({ serverPath, env, maxRestarts = DEFAULT_MAX_RESTARTS, memoryLimit = 512, onCrashCallback }) {
this.serverPath = serverPath;
this.env = env;
this.maxRestarts = maxRestarts;
@@ -35,13 +24,11 @@ export class ServerSupervisor {
this.crashLog = [];
this.child = null;
this.isShuttingDown = false;
this.instrumentationFailureHintPrinted = false;
}
start() {
this.startedAt = Date.now();
this.crashLog = [];
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
@@ -53,35 +40,23 @@ export class ServerSupervisor {
// 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.
this.child = spawn(
process.versions.bun ? process.execPath : "node",
[...(process.versions.bun ? [] : heapArgs), this.serverPath],
{
cwd: dirname(this.serverPath),
env: this.env,
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
}
);
this.child = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : heapArgs),
this.serverPath,
], {
cwd: dirname(this.serverPath),
env: this.env,
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
});
writePidFile("server", this.child.pid);
const bufferOutput = (data) => {
const text = data.toString();
const lines = text.split("\n").filter(Boolean);
const lines = data.toString().split("\n").filter(Boolean);
this.crashLog.push(...lines);
if (this.crashLog.length > CRASH_LOG_LINES) {
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
}
// Surface Android/Termux instrumentation-hook failures even when --log is
// off (output is only buffered otherwise).
if (!this.instrumentationFailureHintPrinted && isFatalInstrumentationHookFailure(text)) {
this.instrumentationFailureHintPrinted = true;
process.stderr.write(
formatAndroidInstrumentationFailureHint(
this.env?.XDG_CACHE_HOME || process.env.XDG_CACHE_HOME
)
);
}
};
if (this.child.stdout) {
@@ -97,32 +72,12 @@ export class ServerSupervisor {
return this.child;
}
handleExit(code, err) {
handleExit(code) {
// Node.js v24+ requires process.exit() to receive a number. Spawn-error events
// deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here.
const exitCode = typeof code === "number" ? code : null;
cleanupPidFile("server");
// #8091: the child's spawn 'error' listener passes `err` through as a second
// argument, but it used to be silently dropped — the user only ever saw the
// hardcoded "code=-1" with a permanently empty crash log, with no way to
// diagnose why the child never started (ENOENT/EACCES/bad path/etc.). Surface
// the real reason immediately, both on the console and in the crash-log buffer
// so `dumpCrashLog()` shows it too.
if (err) {
const detail = [
err.code && `code=${err.code}`,
err.syscall && `syscall=${err.syscall}`,
err.path && `path=${err.path}`,
err.message,
]
.filter(Boolean)
.join(" ");
const line = `⚠ Spawn error: ${detail || String(err)}`;
console.error(line);
this.crashLog.push(line);
}
// #4425: only exit on an intentional shutdown. A spontaneous code-0 exit (e.g. a
// systemd MemoryMax cgroup kill, which reports the process exited cleanly) is anomalous
// and must be restarted, not treated as a graceful stop that leaves the gateway dead.
@@ -180,13 +135,14 @@ export class ServerSupervisor {
stop() {
this.isShuttingDown = true;
if (this.child?.pid) {
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
// the target — it is never a real, interceptable signal there. The child already
// receives the real CTRL_C_EVENT/CTRL_CLOSE_EVENT independently (it shares the
// console) and runs its own async graceful shutdown (WAL checkpoint). Sending
// SIGTERM immediately on win32 races and beats that cleanup. Fire-and-forget:
// stop() itself stays sync so callers keep their existing control flow.
void stopProcessGracefully({ pid: this.child.pid, timeoutMs: 5000, isPidRunning });
try {
process.kill(this.child.pid, "SIGTERM");
} catch {}
setTimeout(() => {
try {
process.kill(this.child.pid, "SIGKILL");
} catch {}
}, 5000);
}
killAllSubprocesses();
}

View File

@@ -5,32 +5,13 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
async function loadSqlite() {
if (process.versions.bun) {
return { Database: (await import("bun:sqlite")).Database };
return (await import("bun:sqlite")).Database;
}
try {
return { Database: (await import("better-sqlite3")).default };
} catch (error) {
return { error };
}
}
// #7586: unlike the real server (src/lib/db/adapters/driverFactory.ts::tryOpenSync),
// this CLI helper historically had NO fallback beyond better-sqlite3 — so on any
// machine where better-sqlite3's native binary is unavailable (Windows without a
// prebuilt addon, etc.), every `omniroute doctor` DB check reported a false FAIL
// even when the actual server was healthy via its own (correct) driver cascade.
// Reuse that same cascade here instead of re-deriving it.
async function openWithSyncDriverFallback(dbPath, options, importError) {
try {
const { tryOpenSync } = await import("../../src/lib/db/adapters/driverFactory.ts");
const adapter = tryOpenSync(dbPath, options);
if (adapter) {
return adapter;
}
return (await import("better-sqlite3")).default;
} catch {
// fall through to the original better-sqlite3 error below
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
}
throw createSqliteNativeError(importError);
}
function openBunSqlite(Database, dbPath, options) {
@@ -110,25 +91,19 @@ export function createSqliteNativeError(error) {
}
async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite();
const Database = await loadSqlite();
if (process.versions.bun) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}
const bunOptions = options.readonly
options = options.readonly
? { readonly: true }
: { readwrite: true, create: options.fileMustExist !== true };
try {
return openBunSqlite(loaded.Database, dbPath, bunOptions);
} catch (error) {
throw createSqliteNativeError(error);
}
}
if (loaded.error) {
return openWithSyncDriverFallback(dbPath, options, loaded.error);
}
try {
return new loaded.Database(dbPath, options);
return process.versions.bun
? openBunSqlite(Database, dbPath, options)
: new Database(dbPath, options);
} catch (error) {
throw createSqliteNativeError(error);
}

View File

@@ -1,122 +0,0 @@
/**
* Next.js cache-dir prep for Android / Termux.
*
* Next.js `getCacheDirectory()` has no dedicated branch for
* `process.platform === "android"`. On that path it only accepts a cache root
* that *already* exists (`fs.existsSync` on `~/.cache` or a generic tmp dir).
* If neither exists it prints `Unsupported platform: android` and exits — the
* CLI can still look "running" while every request returns a bare HTTP 500
* because the instrumentation hook never loads (and so neither does logging).
*
* Termux Node sometimes reports `platform === "android"` and sometimes
* `"linux"` with Termux env signals (`TERMUX_VERSION` / `PREFIX`). Creating
* `~/.cache` (and pointing `XDG_CACHE_HOME` at it when unset) makes the probe
* succeed on both shapes.
*
* Call this *before* spawning or loading Next.js. Safe no-op on desktop
* platforms that are not Termux.
*/
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
/**
* @param {string} [platform]
* @param {NodeJS.ProcessEnv} [env]
* @returns {boolean}
*/
export function needsAndroidCacheDirPrep(platform = process.platform, env = process.env) {
return platform === "android" || isTermux(env);
}
/**
* @param {() => string} [homedirFn]
* @param {NodeJS.ProcessEnv} [env]
* @returns {string}
*/
export function resolveAndroidCacheDir(homedirFn = homedir, env = process.env) {
if (typeof env.XDG_CACHE_HOME === "string" && env.XDG_CACHE_HOME.trim()) {
return env.XDG_CACHE_HOME;
}
return join(homedirFn(), ".cache");
}
/**
* Ensure a writable cache directory exists for Next.js on Android/Termux.
*
* @param {object} [options]
* @param {string} [options.platform]
* @param {NodeJS.ProcessEnv} [options.env]
* @param {() => string} [options.homedirFn]
* @param {typeof mkdirSync} [options.mkdirSyncFn]
* @param {typeof existsSync} [options.existsSyncFn]
* @param {boolean} [options.setEnv] When true (default), set `XDG_CACHE_HOME` on `env`
* if unset so child processes inherit a known-writable cache root.
* @returns {{ prepared: boolean, cacheDir: string | null, created: boolean }}
*/
export function ensureAndroidCacheDir(options = {}) {
const {
platform = process.platform,
env = process.env,
homedirFn = homedir,
mkdirSyncFn = mkdirSync,
existsSyncFn = existsSync,
setEnv = true,
} = options;
if (!needsAndroidCacheDirPrep(platform, env)) {
return { prepared: false, cacheDir: null, created: false };
}
const cacheDir = resolveAndroidCacheDir(homedirFn, env);
let created = false;
if (!existsSyncFn(cacheDir)) {
mkdirSyncFn(cacheDir, { recursive: true });
created = true;
}
if (setEnv && !(typeof env.XDG_CACHE_HOME === "string" && env.XDG_CACHE_HOME.trim())) {
env.XDG_CACHE_HOME = cacheDir;
}
return { prepared: true, cacheDir, created };
}
/**
* Detect Next.js instrumentation-hook failures that leave the server looking
* "up" while requests get silent HTTP 500s (typical when the Android cache
* probe failed before logging started).
*
* @param {string} text
* @returns {boolean}
*/
export function isFatalInstrumentationHookFailure(text) {
if (!text) return false;
return (
/Unsupported platform:\s*android/i.test(text) ||
/error occurred while loading instrumentation hook/i.test(text)
);
}
/**
* Operator-facing hint when that instrumentation failure shows up in child
* output — defense in depth if prep was skipped or a future Next.js probe
* regresses.
*
* @param {string} [cacheDir]
* @returns {string}
*/
export function formatAndroidInstrumentationFailureHint(cacheDir) {
const dir = cacheDir || join(homedir(), ".cache");
return (
`\n\x1b[31m✖ Next.js instrumentation failed on Android/Termux (likely missing cache dir).\x1b[0m\n` +
` OmniRoute tried to create a writable cache at:\n` +
` \x1b[36m${dir}\x1b[0m\n` +
` Manual workaround (survives reinstalls — do NOT patch dist/server.js):\n` +
` \x1b[36mmkdir -p ~/.cache\x1b[0m\n` +
` then restart: \x1b[36momniroute serve\x1b[0m\n` +
` See: docs/guides/TERMUX_GUIDE.md → Troubleshooting → Unsupported platform: android\n`
);
}

View File

@@ -1,47 +0,0 @@
/**
* Argument escaping for child processes spawned with `shell: true` on Windows.
*
* The launchers (`omniroute launch`, `omniroute launch-codex`) must go through
* cmd.exe on win32 because the target binaries are npm `.cmd` shims that Node
* cannot exec directly (CVE-2024-27980). With `shell: true` Node joins argv with
* plain spaces and no escaping at all (the DEP0190 warning), so anything with a
* space, a quote or a cmd metacharacter reaches the child mangled.
*/
/** cmd.exe metacharacters that stay live inside a quoted argument. */
const WIN_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
/**
* Escape one argument for a cmd.exe command line built by `shell: true`.
*
* Two layers, in order:
* 1. the CRT argv rules the target binary parses (double the backslashes that
* precede a quote, escape embedded quotes, wrap in quotes);
* 2. cmd.exe's metacharacters, caret-escaped — applied TWICE because the
* target is an npm `.cmd` shim that forwards `%*` to node, so the line is
* parsed by cmd a second time. Single-escaping truncated any argument at
* the first `&` or `|`. (Same rule as cross-spawn's doubleEscapeMetaChars.)
*
* @param {unknown} arg
* @returns {string}
*/
export function escapeWindowsShellArg(arg) {
const s = String(arg);
if (s === "") return '""';
let out = s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
out = `"${out}"`;
return out.replace(WIN_META_CHARS, "^$1").replace(WIN_META_CHARS, "^$1");
}
/**
* Escape a whole argv for the `shell: true` path. Off Windows there is no shell,
* so argv is passed through untouched.
*
* @param {string[]} args
* @param {NodeJS.Platform|string} platform
* @returns {string[]}
*/
export function quoteShellArgs(args, platform) {
const list = [...(args ?? [])];
return platform === "win32" ? list.map(escapeWindowsShellArg) : list;
}

View File

@@ -145,15 +145,6 @@ function loadEnvFile() {
loadEnvFile();
// Next.js has no android branch in getCacheDirectory(): if ~/.cache (and tmp)
// do not already exist it aborts the instrumentation hook, and every request
// then returns a silent HTTP 500 even though the CLI still looks "running".
// Create the cache dir (and set XDG_CACHE_HOME when unset) before serve/Next.
{
const { ensureAndroidCacheDir } = await import("./cli/utils/ensureAndroidCacheDir.mjs");
ensureAndroidCacheDir();
}
// Generate STORAGE_ENCRYPTION_KEY if not set (persisted to ~/.omniroute/.env)
// This ensures the key survives across upgrades and is not regenerated on each install.
// See: https://github.com/diegosouzapw/OmniRoute/issues/1622

View File

@@ -0,0 +1 @@
- **feat(sse):** every completion response now carries an `X-OmniRoute-Decision: strategy=<name>; provider=<alias>; latency_ms=<n>` header exposing the routing decision — `<name>` is the combo strategy (`priority`, `weighted`, `fusion`, etc.) or `single` for a non-combo request — for client-side debugging/analytics without server log access (#6022 — thanks @chirag127).

View File

@@ -0,0 +1 @@
- **feat(ws):** the live-dashboard WebSocket server now auto-starts in-process (via `instrumentation-node.ts`) across every deployment mode — dev, production, Docker, Electron — with no separate sidecar script; the default WS port moved from 20129 to **20132** to avoid colliding with `API_PORT` in split-port setups, the deprecated `OMNIROUTE_DISABLE_LIVE_WS` env was consolidated into `OMNIROUTE_ENABLE_LIVE_WS` (default enabled), and the WS path is now derived from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`'s pathname (`/live-ws` fallback) (#6072 — thanks @ianriizky).

View File

@@ -0,0 +1 @@
- feat(sse): configurable per-model upstream header-response timeout override, precedence model > provider > global; applied to codex reasoning-heavy tiers (gpt-5.5-high/xhigh, gpt-5.6-\*-high/xhigh) (#6354)

View File

@@ -0,0 +1 @@
- **feat(dashboard):** Replace free-text model inputs in the Routing (web search route), Combo Defaults (handoff model), and Background Degradation tabs with a `hidePaidModels`-aware `ModelSelectField`, add a fail-open "paid-only pattern" warning to the per-model routing rule pattern field, and reject paid-only model targets at save time on `PATCH /api/settings`, `PATCH /api/settings/combo-defaults`, and `PUT /api/settings/background-degradation` when `hidePaidModels` is on ([#6540](https://github.com/diegosouzapw/OmniRoute/issues/6540))

View File

@@ -0,0 +1 @@
- **feat(compression):** new **omniglyph** engine (context-as-image) — renders system prompt, tool docs, and dense history as compact PNG pages the model reads instead of text (~10× fewer tokens on the converted block; 5970% end-to-end measured). Works stacked with RTK/Caveman (`stackPriority: 90`) or standalone (`mode: omniglyph`); restricted to Claude Fable 5 over the direct Anthropic route, fail-closed gates with `skip:<reason>` techniques, preview (`stable: false`, off by default) (#6556). Dependency bumped to `omniglyph@^1.0.2` for upstream ReDoS fixes (#6661).

View File

@@ -0,0 +1 @@
- **feat(sse):** rate-limit request queue admission control — `resilienceSettings.requestQueue.maxQueueDepth` (default `0` = disabled, opt-in 0100000) fast-rejects a request with a typed `RATE_LIMIT_QUEUE_FULL` error once the local per-provider+connection queue already holds `maxQueueDepth` requests, instead of growing the queue unboundedly; the factory default for `requestQueue.maxWaitMs` (how long a request may wait before being dropped) also fell from 120s to 15s so a saturated queue fails fast (#6593 — thanks @chirag127).

View File

@@ -0,0 +1 @@
- **feat(sandbox):** the skill sandbox gained a container-provider abstraction that auto-detects and uses the best native runtime per host — Apple Container (macOS 26+), WSL container (`wslc.exe`), OrbStack, Podman — instead of hardcoding `docker run`, removing the Docker Desktop requirement on macOS/Windows (#6611 — thanks @KooshaPari).

View File

@@ -0,0 +1 @@
- feat(oauth): accept the full ChatGPT session JSON (not just a bare access token) when pasting Codex credentials manually or via `POST /api/oauth/codex/import-token` (#6636)

View File

@@ -0,0 +1 @@
- feat(sse): add 5 no-key g4f.space gateway providers — `g4f-groq`, `g4f-gemini`, `g4f-pollinations`, `g4f-ollama`, `g4f-nvidia` — a free, no-signup reverse proxy (gpt4free project) fronting Groq, Gemini, Pollinations, Ollama, and NVIDIA NIM, rate-limited to 5 req/min per IP (#6650 — thanks @chirag127).

View File

@@ -0,0 +1 @@
- feat(sse): add DeepInfra as a video-generation provider via its native synchronous inference endpoint (#6653)

View File

@@ -0,0 +1 @@
- feat(providers): add Freepik (Magnific Mystic) API-key image generation provider — async submit/poll flow with realism/fluid/zen/flexible/super_real/editorial_portraits models (#6654)

View File

@@ -0,0 +1 @@
- feat(providers): add Rev AI speech-to-text provider with async job upload/poll/transcript flow (#6655)

View File

@@ -0,0 +1 @@
- **feat(providers):** add **Segmind** as an image + video generation provider — `x-api-key` auth against `POST https://api.segmind.com/v1/{model}`, with a curated starter model list (Flux, Stable Diffusion XL/3.5, Kandinsky for image; Wan, Hunyuan, LTX, Kling for video) (#6656).

View File

@@ -0,0 +1 @@
- feat(providers): add Gladia as an async speech-to-text provider (#6657)

View File

@@ -0,0 +1 @@
- feat(video): add Novita AI as a video-generation provider (Wan/Kling async submit-poll) (#6658)

View File

@@ -0,0 +1 @@
- **feat(providers):** add Speechmatics as an STT provider — async batch transcription (Enhanced operating point), 8 hours/month free tier, no credit card required. Streaming (real-time) mode is out of scope for v1. (#6659)

View File

@@ -0,0 +1 @@
- feat(providers): add Mixedbread AI as an embeddings provider (`mxbai-embed-large-v1`, `mxbai-embed-2d-large-v1`, free tier) (#6660)

View File

@@ -0,0 +1 @@
- **feat(providers):** add Felo (felo.ai) as a free, no-signup, no-API-key chat/search-agent aggregator provider (`felo-web`) — joins the existing `-web` family (DuckDuckGo AI Chat, Blackbox, etc). Five models (`felo-chat`, `felo-search`, `felo-scholar`, `felo-social`, `felo-document`) map to Felo's search categories; the executor opens a search thread then translates Felo's bespoke SSE stream into OpenAI-compatible chunks (#6666).

View File

@@ -0,0 +1 @@
- **feat(providers):** add gTTS (Google Translate TTS) as a free, no-signup audio-speech provider — routes through Google's current `batchexecute` RPC endpoint (the previously proposed `translate_tts` endpoint is deprecated), splitting input at the 100-char-per-request limit. (#6667)

View File

@@ -0,0 +1 @@
- **feat(sse):** add EdgeTTS (Microsoft Edge "Read Aloud") as a free, no-API-key `audio-tts` provider — the first WebSocket-transport speech provider, with per-client-IP rate limiting. (#6668)

View File

@@ -0,0 +1 @@
- feat(providers): add FreeTheAi as an OpenAI-compatible gateway provider with a free Discord-signup tier (#6670)

View File

@@ -0,0 +1 @@
- feat(sse): add Microsoft Designer as an unofficial web-session image provider, reverse-engineered submit-then-poll DallE.ashx flow (#6672)

View File

@@ -0,0 +1 @@
- **feat(providers):** add Hailuo Web (`hailuo-web`) — a free, `_token`-based web-cookie chat provider for the MiniMax consumer chat product at hailuo.ai, ported from the g4f reference implementation (MD5-chain request signing, custom `event:`/`data:` SSE parsing). Distinct from the existing paid API-key `minimax`/`minimax-cn` providers. (#6673)

View File

@@ -0,0 +1 @@
- **feat(cli):** new `omniroute auth export` command dumps DECRYPTED provider credentials (`apiKey`/`accessToken`/`refreshToken`/`idToken`) for one connection (`--id <id>`) or all connections, as `json` or `env` (`--format`), local-only and gated behind `--force` — no DB access happens without it, a stderr warning banner prints before any plaintext, `--out <file>` writes with `0600` permissions, and per-field decrypt failures surface as a `<field>DecryptFailed` boolean instead of aborting the export or leaking the caught error text (#6683)

View File

@@ -0,0 +1 @@
- **feat(mitm):** the AgentBridge static MITM server (`server.cjs`) can now issue a per-host TLS leaf from a persisted local root CA (`src/mitm/cert/rootCa.ts`, reusing the CA/leaf crypto already proven for TPROXY in `tproxy/dynamicCert.ts`) instead of a single static self-signed leaf scoped only to the 4 antigravity hosts — a fresh install covers the full `MITM_TOOL_HOSTS` set automatically; an install that already trusted the old leaf keeps using it until the operator opts in via `MITM_ROOT_CA_ENABLED=true` (`src/mitm/cert/migration.ts`), so no existing install is silently upgraded to the more powerful any-host-signing CA trust model (#6684).

View File

@@ -0,0 +1 @@
- **fix(sse):** skip `thinkingConfig` for Gemma models on the OpenAI→Gemini path so OpenAI-shape clients no longer get a 400 from Vertex. (thanks @chy1211)

View File

@@ -0,0 +1 @@
- **feat(xai):** route xAI clients to Grok's native `/v1/responses` endpoint instead of the chat-completions bridge. (thanks @ryanngit)

View File

@@ -0,0 +1 @@
- **feat(models):** add a Settings → AI "Model Overrides" UI plus `/api/model-capability-overrides` CRUD and a `model_capability_overrides` table, letting operators set a manual max-output-token override per provider/model (#6727 — thanks @xz-dev).

View File

@@ -0,0 +1 @@
- **feat(api):** add `Vary: Accept-Encoding` to token-authenticated `/v1*`/`/v1beta*` responses so downstream caches distinguish compressed vs uncompressed variants (RFC 9110 §12.5.5). (thanks @chirag127)

View File

@@ -0,0 +1 @@
- feat(sse): add Notion AI Web (Unofficial/Experimental) cookie-session provider (#6758)

View File

@@ -0,0 +1 @@
- **feat(dashboard):** add per-routing-combo compression-mode override to the Compression Combos page under Context & Cache, alongside the existing combo-card quick override. (#6760)

View File

@@ -0,0 +1 @@
- **feat(resilience):** operator-configurable account rotation policy — a new `rotationConfig` layer lets operators tune how connections rotate on failure, wired into `accountFallback` (#6763 — thanks @artickc).

View File

@@ -0,0 +1 @@
- **feat(sse):** preserve `tools`/`tool_choice` for tool-bearing requests through fusion combos — bypass panel synthesis and route straight to the judge with tools intact (#6771 — thanks @chirag127).

View File

@@ -0,0 +1 @@
- **chore(cursor): add Grok 4.5 effort/fast model IDs** (#6774 — thanks @andrewmunsell).

View File

@@ -0,0 +1 @@
- **feat(codex):** Codex provider model discovery now fetches the live catalog from `chatgpt.com/backend-api/codex/models` using Codex-shaped headers, falling back to a GitHub-hosted model manifest and then to the local static catalog when the live/GitHub sources are unavailable or return an unexpected shape — new `src/app/api/providers/[id]/models/discovery/codex.ts` (normalization, version-gating, merge/enrich against the local catalog) covered by `tests/unit/provider-models-discovery-split.test.ts` and `tests/unit/provider-models-route-codex.test.ts` (#6776 — thanks @JxnLexn).

View File

@@ -0,0 +1 @@
- **feat(cursor):** register the Opus 4.8, Fable 5, and Sonnet 5 model families for the Cursor Agent provider so the latest Claude/Fable model ids route correctly (#6779 — thanks @andrewmunsell).

View File

@@ -0,0 +1 @@
- **Changelog fragments (`changelog.d/`)**: PRs now add their changelog entry as a new fragment file (`changelog.d/{features|fixes|maintenance}/<PR>-<slug>.md`) instead of editing `CHANGELOG.md` — two PRs never touch the same file, structurally eliminating the CHANGELOG-eat merge conflicts that forced a re-sync push + full CI re-run after every sibling merge (O(N²) CI runs in a merge-storm). `scripts/release/aggregate-changelog.mjs` (npm run changelog:aggregate) folds fragments into the living section at release reconciliation, and `check:changelog-integrity` now also validates fragment well-formedness. Regression guard: `tests/unit/changelog-fragments.test.ts`.

View File

@@ -0,0 +1 @@
- **feat(proxy):** add a latency-optimized proxy rotation strategy that ranks pool entries by measured round-trip latency, extending the existing round-robin/random/sticky proxy-pool selection (#6798 — thanks @iamraydoan).

View File

@@ -0,0 +1 @@
- feat(db): include `xp_audit_log` in the automatic retention/prune cycle, with a configurable `retention.xpAuditLog` setting (#6801)

View File

@@ -0,0 +1 @@
- **feat(fusion):** the fusion judge may now draw on its own knowledge and override the panel when every panel answer is wrong or incomplete, instead of being restricted to synthesizing only from panel output (#6804 — thanks @chirag127).

View File

@@ -0,0 +1 @@
- **feat(dashboard):** search box on the Playground's raw model `<select>` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`.

View File

@@ -0,0 +1 @@
- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`).

View File

@@ -0,0 +1 @@
- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`.

View File

@@ -0,0 +1 @@
- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer <token>`, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056)

View File

@@ -0,0 +1 @@
- feat(dashboard): import multiple, possibly different providers from a CSV/JSON file — per-row validation, a checklist to pick which parsed rows to import, and a new `POST /api/providers/import` route with partial-failure results (#6836)

View File

@@ -0,0 +1 @@
- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases).

View File

@@ -0,0 +1 @@
- **feat(sse):** OpenRouter quota tracking — a dedicated fetcher polls `/api/v1/key` + `/api/v1/credits` (per-key credit cap/remaining/reset, daily/weekly/monthly USD spend, BYOK usage) with a 45s cache and graceful degradation, a local per-account counter tracks the `:free`-model 50-or-1000-per-day + 20 RPM windows (corrected from `X-RateLimit-*` headers and `Retry-After` on 429), and OpenRouter `402` responses now lock the connection with a real cooldown instead of triggering an immediate reselection of the same credit-exhausted key (#6842).

View File

@@ -0,0 +1 @@
- **feat(sse):** live gRPC-web quota fetcher for Grok Build (`grok-cli`) — polls xAI's shared weekly credit pool (`grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig`) with the connection's existing bearer token, hand-decoding the framed/raw protobuf response (proto3 omission = 0% used, malformed/expired-token fails open) instead of relying solely on the static 864 req/day estimate, which now serves as an explicit fallback when the live fetch is unavailable (#6844).

View File

@@ -0,0 +1 @@
- feat(sse): add dual-window quota tracking for the `v0-vercel` provider — polls the credits (`/v1/user/billing`) and daily Platform-API operation (`/v1/rate-limits`) endpoints with the existing routing API key, defensively degrading to an `unknown` billing type rather than misparsing a future v0 billing-model migration, feeding preflight + the dashboard's Provider Quota card (#6845).

View File

@@ -0,0 +1 @@
- feat(sse): add a static local RPM budget (default 40/min, operator-overridable), per-model 429 scoping (already covered by #6773's `passthroughModels`), and a per-connection concurrency cap (default 6, operator-overridable) for the `nvidia` (NVIDIA NIM) provider, which sends no rate-limit headers and has no usage API — Phase 1 of client-side quota tracking; adaptive AIMD learning and the dashboard quota card are deferred follow-ups (#6846).

View File

@@ -0,0 +1 @@
- feat(sse): add quota tracking for the `agentrouter` provider — polls the New-API `/api/user/self` balance endpoint with a separate System Access Token + `New-Api-User` id (configured via `providerSpecificData.consoleApiKey` / `newApiUserId`), converting raw quota units into a dollar balance and feeding preflight + the dashboard's Provider Quota card (#6850).

View File

@@ -0,0 +1,2 @@
- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun
- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun

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