mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-30 11:52:13 +03:00
Compare commits
4 Commits
release/v3
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f96a97e33 | ||
|
|
e6d1f4b048 | ||
|
|
4919aa89a9 | ||
|
|
7c1e228353 |
72
.env.example
72
.env.example
@@ -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.
|
||||
@@ -811,15 +792,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
|
||||
@@ -1055,7 +1027,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 +1176,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)
|
||||
@@ -1830,10 +1788,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.
|
||||
@@ -1969,11 +1923,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
|
||||
@@ -2136,15 +2085,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:
|
||||
@@ -2312,8 +2252,7 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# or lifecycle tuning.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# OMNIROUTE_VNC_IMAGE=omniroute-vnc-chromium:local
|
||||
# OMNIROUTE_DOCKER_BIN=docker
|
||||
# OMNIROUTE_VNC_CONTAINER_VNC_PORT=3000
|
||||
# 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=
|
||||
@@ -2327,6 +2266,5 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# OMNIROUTE_DATA_DIR are both unset. Locates the Notion web-thread session cache.# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VIBEPROXY_DATA_DIR=
|
||||
|
||||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -9,12 +9,10 @@
|
||||
|
||||
## Validation
|
||||
|
||||
Run only the focused loop for what you changed — the full unit suite, Vitest, the
|
||||
60% coverage gate, and the production build all run in CI on this PR (#8329):
|
||||
|
||||
- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||
- [ ] `npm run lint`
|
||||
- [ ] 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
|
||||
|
||||
39
.github/workflows/build-rinseaid-image.yml
vendored
39
.github/workflows/build-rinseaid-image.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Build Rinseaid OmniRoute image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [build-k3-reasoning-image]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- open-sse/**
|
||||
- scripts/build/**
|
||||
- .github/workflows/build-rinseaid-image.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ghcr.io/rinseaid/omniroute:k3-reasoning-${{ github.sha }}
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -35,7 +35,7 @@ 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
|
||||
@@ -449,26 +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
|
||||
@@ -488,7 +477,6 @@ jobs:
|
||||
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)
|
||||
@@ -508,7 +496,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
|
||||
@@ -919,7 +907,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 }}
|
||||
@@ -1126,8 +1114,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"
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,13 +19,13 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@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"
|
||||
|
||||
22
.github/workflows/dast-smoke.yml
vendored
22
.github/workflows/dast-smoke.yml
vendored
@@ -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)
|
||||
|
||||
13
.github/workflows/nightly-compat.yml
vendored
13
.github/workflows/nightly-compat.yml
vendored
@@ -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)
|
||||
|
||||
2
.github/workflows/nightly-llm-security.yml
vendored
2
.github/workflows/nightly-llm-security.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/nightly-mutation.yml
vendored
2
.github/workflows/nightly-mutation.yml
vendored
@@ -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
|
||||
|
||||
148
.github/workflows/nightly-release-green.yml
vendored
148
.github/workflows/nightly-release-green.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/nightly-schemathesis.yml
vendored
2
.github/workflows/nightly-schemathesis.yml
vendored
@@ -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
|
||||
|
||||
44
.github/workflows/npm-publish.yml
vendored
44
.github/workflows/npm-publish.yml
vendored
@@ -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,41 +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.
|
||||
- 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
|
||||
RUN=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
|
||||
--jq '[.workflow_runs[] | select(.name == "CI" and .conclusion == "success")] | .[0].id // empty') || RUN=""
|
||||
if [ -z "$RUN" ]; then
|
||||
echo "::notice::no successful CI run for $HEAD_SHA — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
if ! gh run download "$RUN" --repo "$REPO" --name next-build --dir /tmp/next-build; then
|
||||
echo "::notice::next-build artifact unavailable for run $RUN (expired?) — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
|
||||
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:
|
||||
|
||||
48
.github/workflows/quality.yml
vendored
48
.github/workflows/quality.yml
vendored
@@ -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:
|
||||
@@ -202,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
|
||||
@@ -244,16 +210,6 @@ 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)
|
||||
@@ -383,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
|
||||
|
||||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@v2.4.4
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
||||
8
.github/workflows/semgrep.yml
vendored
8
.github/workflows/semgrep.yml
vendored
@@ -6,19 +6,13 @@ on:
|
||||
branches: ["main"]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel superseded PR runs (same rule as quality.yml). No paths filter on purpose:
|
||||
# p/secrets must keep scanning docs-only diffs too — credentials leak in .md files.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run semgrep (advisory)
|
||||
|
||||
@@ -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''',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -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.
|
||||
|
||||
983
CHANGELOG.md
983
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -198,14 +198,11 @@ Coverage notes:
|
||||
|
||||
### Pull Request Requirements
|
||||
|
||||
Before opening a PR, run the focused loop for what you changed. The full unit suite
|
||||
(4 CI shards), Vitest, the **60%+** coverage gate, and the production build are CI's
|
||||
responsibility — running them locally adds no signal the PR checks will not already
|
||||
give you, and on smaller machines it can saturate the host (#8084):
|
||||
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
|
||||
|
||||
@@ -231,31 +228,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
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -149,12 +120,13 @@ 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), {
|
||||
// #8246: on Windows, npm installs claude as a .cmd shim — spawn() without
|
||||
// shell:true cannot resolve PATHEXT shims and fails with ENOENT.
|
||||
const claudeCommand = process.platform === "win32" ? "claude.cmd" : "claude";
|
||||
const child = spawn(claudeCommand, claudeArgs, {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell,
|
||||
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
||||
...(process.platform === "win32" ? { shell: true, windowsHide: true } : {}),
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (err && err.code === "ENOENT") {
|
||||
@@ -187,10 +159,7 @@ export function registerLaunch(program) {
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -10,21 +10,11 @@ import {
|
||||
} 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 +25,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 +41,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
1
changelog.d/features/6022-decision-trace-header.md
Normal file
1
changelog.d/features/6022-decision-trace-header.md
Normal 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).
|
||||
@@ -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).
|
||||
1
changelog.d/features/6354-per-model-timeout.md
Normal file
1
changelog.d/features/6354-per-model-timeout.md
Normal 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)
|
||||
1
changelog.d/features/6540-hidepaid-ui-selects.md
Normal file
1
changelog.d/features/6540-hidepaid-ui-selects.md
Normal 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))
|
||||
1
changelog.d/features/6556-omniglyph-engine.md
Normal file
1
changelog.d/features/6556-omniglyph-engine.md
Normal 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; 59–70% 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).
|
||||
1
changelog.d/features/6593-ratelimit-admission-control.md
Normal file
1
changelog.d/features/6593-ratelimit-admission-control.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(sse):** rate-limit request queue admission control — `resilienceSettings.requestQueue.maxQueueDepth` (default `0` = disabled, opt-in 0–100000) 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).
|
||||
1
changelog.d/features/6611-native-container-runtimes.md
Normal file
1
changelog.d/features/6611-native-container-runtimes.md
Normal 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).
|
||||
1
changelog.d/features/6636-codex-session-import.md
Normal file
1
changelog.d/features/6636-codex-session-import.md
Normal 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)
|
||||
1
changelog.d/features/6650-g4f-space-gateway.md
Normal file
1
changelog.d/features/6650-g4f-space-gateway.md
Normal 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).
|
||||
1
changelog.d/features/6653-deepinfra-video-provider.md
Normal file
1
changelog.d/features/6653-deepinfra-video-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(sse): add DeepInfra as a video-generation provider via its native synchronous inference endpoint (#6653)
|
||||
@@ -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)
|
||||
1
changelog.d/features/6655-revai-stt-provider.md
Normal file
1
changelog.d/features/6655-revai-stt-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): add Rev AI speech-to-text provider with async job upload/poll/transcript flow (#6655)
|
||||
@@ -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).
|
||||
1
changelog.d/features/6657-gladia-stt-provider.md
Normal file
1
changelog.d/features/6657-gladia-stt-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): add Gladia as an async speech-to-text provider (#6657)
|
||||
1
changelog.d/features/6658-novita-video-gen-provider.md
Normal file
1
changelog.d/features/6658-novita-video-gen-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(video): add Novita AI as a video-generation provider (Wan/Kling async submit-poll) (#6658)
|
||||
1
changelog.d/features/6659-speechmatics-stt-provider.md
Normal file
1
changelog.d/features/6659-speechmatics-stt-provider.md
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
- feat(providers): add Mixedbread AI as an embeddings provider (`mxbai-embed-large-v1`, `mxbai-embed-2d-large-v1`, free tier) (#6660)
|
||||
@@ -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).
|
||||
1
changelog.d/features/6667-gtts-audio-tts-provider.md
Normal file
1
changelog.d/features/6667-gtts-audio-tts-provider.md
Normal 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)
|
||||
1
changelog.d/features/6668-edgetts-audio-tts-provider.md
Normal file
1
changelog.d/features/6668-edgetts-audio-tts-provider.md
Normal 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)
|
||||
1
changelog.d/features/6670-freetheai-gateway-provider.md
Normal file
1
changelog.d/features/6670-freetheai-gateway-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): add FreeTheAi as an OpenAI-compatible gateway provider with a free Discord-signup tier (#6670)
|
||||
@@ -0,0 +1 @@
|
||||
- feat(sse): add Microsoft Designer as an unofficial web-session image provider, reverse-engineered submit-then-poll DallE.ashx flow (#6672)
|
||||
1
changelog.d/features/6673-hailuoai-web-provider.md
Normal file
1
changelog.d/features/6673-hailuoai-web-provider.md
Normal 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)
|
||||
1
changelog.d/features/6683-cli-auth-export.md
Normal file
1
changelog.d/features/6683-cli-auth-export.md
Normal 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)
|
||||
1
changelog.d/features/6684-mitm-root-ca.md
Normal file
1
changelog.d/features/6684-mitm-root-ca.md
Normal 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).
|
||||
1
changelog.d/features/6708-gemma4-thinkingconfig-guard.md
Normal file
1
changelog.d/features/6708-gemma4-thinkingconfig-guard.md
Normal 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)
|
||||
1
changelog.d/features/6709-xai-responses-endpoint.md
Normal file
1
changelog.d/features/6709-xai-responses-endpoint.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(xai):** route xAI clients to Grok's native `/v1/responses` endpoint instead of the chat-completions bridge. (thanks @ryanngit)
|
||||
@@ -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).
|
||||
1
changelog.d/features/6737-vary-accept-encoding.md
Normal file
1
changelog.d/features/6737-vary-accept-encoding.md
Normal 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)
|
||||
1
changelog.d/features/6758-notion-web-provider.md
Normal file
1
changelog.d/features/6758-notion-web-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(sse): add Notion AI Web (Unofficial/Experimental) cookie-session provider (#6758)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
1
changelog.d/features/6774-cursor-grok-4-5-effort-fast.md
Normal file
1
changelog.d/features/6774-cursor-grok-4-5-effort-fast.md
Normal file
@@ -0,0 +1 @@
|
||||
- **chore(cursor): add Grok 4.5 effort/fast model IDs** (#6774 — thanks @andrewmunsell).
|
||||
1
changelog.d/features/6776-discover-live-codex-models.md
Normal file
1
changelog.d/features/6776-discover-live-codex-models.md
Normal 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).
|
||||
@@ -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).
|
||||
1
changelog.d/features/6783-changelog-fragments.md
Normal file
1
changelog.d/features/6783-changelog-fragments.md
Normal 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`.
|
||||
@@ -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).
|
||||
1
changelog.d/features/6801-xp-audit-log-retention.md
Normal file
1
changelog.d/features/6801-xp-audit-log-retention.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(db): include `xp_audit_log` in the automatic retention/prune cycle, with a configurable `retention.xpAuditLog` setting (#6801)
|
||||
1
changelog.d/features/6804-fusion-judge-own-knowledge.md
Normal file
1
changelog.d/features/6804-fusion-judge-own-knowledge.md
Normal 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).
|
||||
@@ -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`.
|
||||
1
changelog.d/features/6818-antigravity-weekly-quota.md
Normal file
1
changelog.d/features/6818-antigravity-weekly-quota.md
Normal 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`).
|
||||
1
changelog.d/features/6820-codex-effort-model-echo.md
Normal file
1
changelog.d/features/6820-codex-effort-model-echo.md
Normal 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`.
|
||||
1
changelog.d/features/6823-zai-web-cookie-provider.md
Normal file
1
changelog.d/features/6823-zai-web-cookie-provider.md
Normal 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)
|
||||
1
changelog.d/features/6836-import-providers-from-file.md
Normal file
1
changelog.d/features/6836-import-providers-from-file.md
Normal 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)
|
||||
@@ -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).
|
||||
1
changelog.d/features/6842-openrouter-quota-tracking.md
Normal file
1
changelog.d/features/6842-openrouter-quota-tracking.md
Normal 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).
|
||||
1
changelog.d/features/6844-grok-cli-quota.md
Normal file
1
changelog.d/features/6844-grok-cli-quota.md
Normal 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).
|
||||
1
changelog.d/features/6845-v0-vercel-quota-tracking.md
Normal file
1
changelog.d/features/6845-v0-vercel-quota-tracking.md
Normal 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).
|
||||
1
changelog.d/features/6846-nvidia-nim-quota.md
Normal file
1
changelog.d/features/6846-nvidia-nim-quota.md
Normal 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).
|
||||
1
changelog.d/features/6850-agentrouter-quota-tracking.md
Normal file
1
changelog.d/features/6850-agentrouter-quota-tracking.md
Normal 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).
|
||||
2
changelog.d/features/6862-gpt-5-6-providers.md
Normal file
2
changelog.d/features/6862-gpt-5-6-providers.md
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
- feat(api): add a structured `X-Routing-Fallback-Reason` header to relay routing responses, exposing a stable machine-readable reason code alongside the legacy `X-Routing-Fallback` detail string (#6872)
|
||||
1
changelog.d/features/6873-model-latency-stats-api.md
Normal file
1
changelog.d/features/6873-model-latency-stats-api.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(api):** new **GET /api/usage/model-latency-stats** management endpoint exposes the existing rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate) already used internally by auto-combo routing — supports `windowHours`/`minSamples`/`maxRows`/`provider`/`model` filters (#6873).
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(providers):** add a `vibeproxy-openai` provider-node preset to `POST /api/provider-nodes` — defaults name/prefix/apiType for VibeProxy's local OpenAI-compatible gateway and normalizes the caller-supplied base URL to its `/v1` root; `baseUrl` remains mandatory. (#6874, idea from #6137 by @KooshaPari)
|
||||
1
changelog.d/features/6875-latency-stats-ttft.md
Normal file
1
changelog.d/features/6875-latency-stats-ttft.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(usage): add avgTtftMs/avgE2ELatencyMs/avgTokensPerSecond to `getModelLatencyStats()` and feed them into auto-combo's speed-ranking factor (#6875)
|
||||
1
changelog.d/features/6879-default-reasoning-effort.md
Normal file
1
changelog.d/features/6879-default-reasoning-effort.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(sse): per-model default `reasoning_effort` (`ModelSpec.defaultReasoningEffort`, injected only when the request carries no reasoning field) and make `no-think/` express `reasoning_effort:"none"` instead of deleting the field on the OpenAI path, so thinks-by-default models actually stop thinking (#6879)
|
||||
1
changelog.d/features/6880-connection-cache-override.md
Normal file
1
changelog.d/features/6880-connection-cache-override.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** let a custom/openai-compatible connection opt into prompt-cache behavior via a per-connection `cache` capability override, unblocking `prompt_cache_key` injection, the compression cache-aware guard, and `cache_control` passthrough for `openai-compatible-chat-<uuid>`-style connections. (thanks @andrea-kingautomation)
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(dashboard):** add a Type filter (No Signup / OAuth Login / API Key) and an "Easiest first" sort toggle to Free Provider Rankings, so zero-setup NOAUTH providers can be surfaced without eyeballing the Type column. (#6915)
|
||||
1
changelog.d/features/6928-comfyui-base-url-field.md
Normal file
1
changelog.d/features/6928-comfyui-base-url-field.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** expose an editable base-URL field on the ComfyUI connection so Docker-network setups (e.g. `http://comfyui:8188`) work for image, video, and music generation ([#6928](https://github.com/diegosouzapw/OmniRoute/issues/6928))
|
||||
1
changelog.d/features/6976-openrouter-embeddings.md
Normal file
1
changelog.d/features/6976-openrouter-embeddings.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** refresh the curated OpenRouter embeddings catalog (`open-sse/config/embeddingRegistry.ts`) with the current lineup — `openai/text-embedding-3-small`/`-large`, `qwen/qwen3-embedding-8b`/`-4b`, `baai/bge-m3`, `mistralai/mistral-embed-2312`, `google/gemini-embedding-001` — and fold curated embedding/rerank entries into OpenRouter's live model-discovery response (`src/app/api/providers/[id]/models/route.ts`), additively and deduped by id, so they no longer only appear on the no-config `local_catalog` fallback. OpenRouter serves embeddings via a dedicated `/api/v1/embeddings` endpoint (omitted from `/v1/models`), so the live-discovery success path previously returned chat models only ([#6976](https://github.com/diegosouzapw/OmniRoute/issues/6976)). Regression guard: `tests/unit/openrouter-embeddings-catalog-6976.test.ts`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user