mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-30 11:02:15 +03:00
Compare commits
55 Commits
dependabot
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c19dd27c | ||
|
|
e620c50f3c | ||
|
|
2e3cd599b6 | ||
|
|
5684589ce7 | ||
|
|
ccee48d34a | ||
|
|
1c37fff056 | ||
|
|
c2c97aff82 | ||
|
|
faebf6de5f | ||
|
|
55691e0416 | ||
|
|
a1d6ff5fbf | ||
|
|
49827c1db1 | ||
|
|
47ea113b99 | ||
|
|
d26fe03801 | ||
|
|
92574de164 | ||
|
|
da678bd3ff | ||
|
|
2ec24e7c0b | ||
|
|
50bc8ab8aa | ||
|
|
4e11887085 | ||
|
|
38a29661d3 | ||
|
|
2463781e00 | ||
|
|
aa861a80d2 | ||
|
|
38e2baa879 | ||
|
|
d3420d29f1 | ||
|
|
5698769aba | ||
|
|
55e33f3dc8 | ||
|
|
36b7920db1 | ||
|
|
5fcd39bd6f | ||
|
|
e8b2cd208d | ||
|
|
f30e5b2675 | ||
|
|
ef668967f6 | ||
|
|
60dc242178 | ||
|
|
3b00535d04 | ||
|
|
2b8d3a8291 | ||
|
|
34e2f84c04 | ||
|
|
065d998407 | ||
|
|
d32c76f85a | ||
|
|
c9b1c12cfd | ||
|
|
c8dc982eaa | ||
|
|
9ec4d39a74 | ||
|
|
a9aee94a00 | ||
|
|
47f7e5a306 | ||
|
|
38e2616464 | ||
|
|
c4bd8b8ec4 | ||
|
|
e6de61f0c2 | ||
|
|
02ba573730 | ||
|
|
bd04bb9cc6 | ||
|
|
322b218f06 | ||
|
|
71093eda77 | ||
|
|
fb9cbe9566 | ||
|
|
674cc5feb1 | ||
|
|
d2ad71cf56 | ||
|
|
c705147de2 | ||
|
|
d8879371ea | ||
|
|
91f9a01fda | ||
|
|
87b3bdf85e |
36
.github/workflows/api-route-typecheck.yml
vendored
Normal file
36
.github/workflows/api-route-typecheck.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: API Route Typecheck
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-typecheck:
|
||||
name: API Route Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Reject new API-route TypeScript diagnostics
|
||||
run: node scripts/check/check-api-typecheck.mjs
|
||||
- name: API typecheck gate unit tests
|
||||
run: node --import tsx/esm --test tests/unit/build/check-api-typecheck.test.ts
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -109,8 +109,11 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
# Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of
|
||||
# a second cold full-tree pass for eslintWarnings ratchet counts.
|
||||
- name: ESLint (JSON report)
|
||||
@@ -209,8 +212,11 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||
# continue-on-error: o artifact pode não existir se a job test-coverage foi
|
||||
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
|
||||
@@ -621,9 +627,9 @@ jobs:
|
||||
# 13:50Z the kernel OOM-killed main's build while a PR build ran beside it
|
||||
# (five Build jobs had been queued by a burst of PRs). Two lanes: main keeps
|
||||
# its own so a release is never queued behind PR traffic; PR builds serialize
|
||||
# among themselves. GitHub keeps one running + one pending per group and
|
||||
# CANCELS older pendings — a cancelled PR build is re-runnable; a dead main
|
||||
# build costs the publish its artefact and a 40-minute rebuild that OOMs.
|
||||
# among themselves. docker-publish.yml's amd64 leg joins `heavy-build-main`
|
||||
# so a :next image build waits beside this artefact instead of becoming the
|
||||
# third heavy (#11976). GitHub keeps one running + one pending per group.
|
||||
concurrency:
|
||||
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -26,6 +26,6 @@ jobs:
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
42
.github/workflows/docker-publish.yml
vendored
42
.github/workflows/docker-publish.yml
vendored
@@ -26,6 +26,14 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# One publish per ref. A merge storm used to fan out 8 concurrent hosted builds,
|
||||
# every one OOM-killing `npm run build` inside BuildKit (#11976). The :next
|
||||
# channel only needs the newest SHA; cancel-in-progress is the same pattern as
|
||||
# quality.yml / nightly-release-green.
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege default: read-only at the top level; the build and merge jobs that
|
||||
# push to GHCR grant packages: write themselves (Scorecard TokenPermissions).
|
||||
permissions:
|
||||
@@ -118,7 +126,23 @@ jobs:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# amd64: the .113 omni-build pool (31 GB / 32 cores, ONE listener since
|
||||
# #12048). Hosted ubuntu-24.04 is ~7 GB and dies ResourceExhausted (#11976).
|
||||
# Falls back to hosted when USE_VPS_RUNNER is off. arm64: no ARM box — stay
|
||||
# on GitHub's ubuntu-24.04-arm.
|
||||
# Webpack on BOTH arches: Turbopack on omniroute-113-6 hit
|
||||
# TurbopackInternalError "there must be a path to a root" after 26 min
|
||||
# (run 33253576569). The same tree's arm64 webpack build on hosted ARM
|
||||
# succeeded (run 33264823398). Dockerfile already documents webpack as the
|
||||
# Docker escape hatch (OMNIROUTE_USE_TURBOPACK=0).
|
||||
runs-on: ${{ matrix.arch == 'amd64' && (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-24.04') || 'ubuntu-24.04-arm' }}
|
||||
# Share the 1-slot omni-build ceiling (#12048) with ci.yml `Build` /
|
||||
# npm-publish. Same group as main's Build so a :next publish waits beside
|
||||
# the artefact instead of sitting next to it. arm64 is hosted — its own
|
||||
# group, cancelled by the workflow-level concurrency.
|
||||
concurrency:
|
||||
group: ${{ matrix.arch == 'amd64' && 'heavy-build-main' || format('docker-publish-arm-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ matrix.arch != 'amd64' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -127,10 +151,8 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
env:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
@@ -143,6 +165,9 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Assert Docker Engine
|
||||
run: docker info
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -166,6 +191,8 @@ jobs:
|
||||
context: .
|
||||
target: runner-base
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -183,6 +210,8 @@ jobs:
|
||||
context: .
|
||||
target: runner-web
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -208,6 +237,8 @@ jobs:
|
||||
file: Dockerfile.bun
|
||||
target: runner-base
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -233,6 +264,8 @@ jobs:
|
||||
file: Dockerfile.bun
|
||||
target: runner-web
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -495,6 +528,9 @@ jobs:
|
||||
severity: CRITICAL
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
# Explicit: the advisory scan above already points at it, and the blocking
|
||||
# gate must honour the same accepted-risk list (#12084).
|
||||
trivyignores: .trivyignore
|
||||
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
|
||||
17
.github/workflows/electron-release.yml
vendored
17
.github/workflows/electron-release.yml
vendored
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# A dispatch builds the ref it is dispatched ON (`gh workflow run … --ref v3.8.50` rebuilds
|
||||
# that tag; `--ref main` builds the repaired line). The ref is deliberately NOT an input:
|
||||
# CodeQL flags an input-controlled checkout next to the npm cache on the default branch as
|
||||
# cache poisoning (actions/cache-poisoning/poisonable-step), and `github.ref` is trusted.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -81,9 +85,6 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
@@ -169,9 +170,6 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
@@ -358,8 +356,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Source archives + SBOM come from the tag being released, not the dispatching branch.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
|
||||
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
|
||||
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
|
||||
@@ -417,7 +413,10 @@ jobs:
|
||||
tag_name: ${{ needs.validate.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
# Only on the tag push. A re-attach dispatch runs against a release whose curated
|
||||
# notes already exist, and `true` APPENDS GitHub's auto-generated "What's Changed"
|
||||
# block to them (v3.8.50, run 33238093090: +1,416 chars on a 121 KB body).
|
||||
generate_release_notes: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
fail_on_unmatched_files: false
|
||||
files: |
|
||||
release-assets/*.dmg
|
||||
|
||||
39
.github/workflows/nightly-release-green.yml
vendored
39
.github/workflows/nightly-release-green.yml
vendored
@@ -196,6 +196,26 @@ jobs:
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Close tracking issue when the branch is green again
|
||||
if: steps.validate.outputs.exit == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The open/update step above is the UPWARD half of the loop; without this
|
||||
# step a stale "not green" issue outlives the fix and every base-green check
|
||||
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
|
||||
TITLE="🔴 Release branch not green: ${TARGET}"
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
|
||||
--comment "✅ \`${TARGET}\` is release-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
|
||||
echo "Closed issue #$EXISTING"
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -294,6 +314,25 @@ jobs:
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Close tracking issue when the branch is green again
|
||||
if: steps.validate.outputs.exit == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The open/update step above is the UPWARD half of the loop; without this
|
||||
# step a stale "not green" issue outlives the fix and every base-green check
|
||||
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
|
||||
TITLE="🔴 main branch not green"
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
|
||||
--comment "✅ \`main\` is main-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
|
||||
echo "Closed issue #$EXISTING"
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
15
.github/workflows/npm-publish.yml
vendored
15
.github/workflows/npm-publish.yml
vendored
@@ -273,11 +273,20 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach SBOM to GitHub Release
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
|
||||
# Not only on the `release` event: the v3.8.50 package shipped through a
|
||||
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
|
||||
# GitHub Release carried no SBOM until it was attached by hand from the run's
|
||||
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
|
||||
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
|
||||
run: |
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
|
||||
exit 0
|
||||
fi
|
||||
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
|
||||
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
|
||||
|
||||
11
.github/workflows/quality.yml
vendored
11
.github/workflows/quality.yml
vendored
@@ -70,7 +70,16 @@ jobs:
|
||||
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
|
||||
# the majority of the traffic, not the exception — this job earns its place, it just should
|
||||
# not duplicate build.yml for the own-origin 28%.
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
# Disabled 2026-08-29 (#11976 follow-up). `continue-on-error: true` still
|
||||
# reports a GitHub check FAILURE, so every fork PR into release/** was born
|
||||
# with a red "Build (advisory)" even when every required gate was green
|
||||
# (sweep-reds, 41 PRs). Hosted ubuntu-latest cannot finish `npm run build`
|
||||
# on this tree — VM shutdown ~6 min in, same class as build.yml going
|
||||
# workflow_dispatch-only in #11962. Pre-merge build signal for release/**
|
||||
# is nightly-release-green (omni-build); for main it is ci.yml `Build`.
|
||||
# Restore this job when a runner that actually fits the tree is wired here.
|
||||
# Bare `false` (not `${{ false }}`) — zizmor obfuscation flags the expression form.
|
||||
if: false
|
||||
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
|
||||
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
|
||||
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
|
||||
|
||||
5
.github/workflows/scorecard.yml
vendored
5
.github/workflows/scorecard.yml
vendored
@@ -4,12 +4,15 @@ on:
|
||||
schedule:
|
||||
- cron: "27 7 * * 1"
|
||||
push:
|
||||
branches: ["main"]
|
||||
# Scorecard only accepts the DEFAULT branch — here the active release/vX.Y.Z,
|
||||
# not `main`. The job below guards on it so a push to any other branch skips.
|
||||
branches: ["main", "release/**"]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
if: ${{ github.event_name != 'push' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
10
.trivyignore
10
.trivyignore
@@ -19,4 +19,12 @@
|
||||
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
|
||||
# patched base / bump the dep) over suppressing. Stale entries are debt.
|
||||
#
|
||||
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)
|
||||
# CVE-2025-68121 — Go stdlib crypto/tls (session-resumption certificate validation)
|
||||
# inside the PREBUILT bogdanfinn/tls-client v1.15.1 .so that tls-client-node's
|
||||
# postinstall downloads (built with go 1.24.1; fixed in 1.24.13). No upstream
|
||||
# rebuild exists (v1.15.1 is still the latest release) and nothing in this repo
|
||||
# can bump it. The binary is only loaded by the browser-TLS web-provider
|
||||
# executors (claude-web / grok-web / lmarena / perplexity-web / notion-web),
|
||||
# whose handshakes go through utls. Tracking issue: #12084. Revisit at the next
|
||||
# tls-client release or base-image bump and BEFORE the v3.8.51 tag (2026-09-15).
|
||||
CVE-2025-68121
|
||||
|
||||
@@ -289,8 +289,7 @@ When creating _any_ validation tests or one-off logic scripts, default to `scrip
|
||||
### Database
|
||||
|
||||
- **Always** go through `src/lib/db/` domain modules — **never** write raw SQL in routes or handlers
|
||||
- **Never** add logic to `src/lib/localDb.ts` (re-export layer only)
|
||||
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules instead
|
||||
- **Never** barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules
|
||||
- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling)
|
||||
- Migrations: `src/lib/db/migrations/` — versioned SQL files, idempotent, run in transactions
|
||||
|
||||
@@ -355,8 +354,7 @@ Documentation must describe verified behavior, not plausible behavior.
|
||||
1. Create `src/lib/db/yourModule.ts` — import `getDbInstance` from `./core.ts`
|
||||
2. Export CRUD functions for your domain table(s)
|
||||
3. Add migration in `src/lib/db/migrations/` if new tables needed
|
||||
4. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
|
||||
5. Write tests
|
||||
4. Write tests
|
||||
|
||||
### Adding a New MCP Tool
|
||||
|
||||
@@ -668,7 +666,7 @@ the stale-enforcement added in Fase 6A.3.
|
||||
## Hard Rules
|
||||
|
||||
1. Never commit secrets or credentials
|
||||
2. Never add logic to `localDb.ts`
|
||||
2. Never barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules
|
||||
3. Never use `eval()` / `new Function()` / implied eval
|
||||
4. Never commit directly to `main`
|
||||
5. Never write raw SQL in routes — use `src/lib/db/` modules
|
||||
|
||||
@@ -557,7 +557,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md)
|
||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **351-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
|
||||
@@ -24,6 +24,80 @@ function parseHeader(kv) {
|
||||
return { name: kv.slice(0, eq), value: kv.slice(eq + 1) };
|
||||
}
|
||||
|
||||
function getRootCommand(cmd) {
|
||||
let curr = cmd;
|
||||
while (curr.parent) curr = curr.parent;
|
||||
return curr;
|
||||
}
|
||||
|
||||
function resolveNodeEndpoint(opts, cmd) {
|
||||
if (opts.endpoint) {
|
||||
return { endpoint: opts.endpoint, apiFetchOpts: cmd.optsWithGlobals() };
|
||||
}
|
||||
if (opts.nodeUrl) {
|
||||
return { endpoint: opts.nodeUrl, apiFetchOpts: cmd.optsWithGlobals() };
|
||||
}
|
||||
|
||||
// Check if --base-url, --endpoint, or --node-url was explicitly passed after the subcommand
|
||||
const root = getRootCommand(cmd);
|
||||
const rawArgs = root.rawArgs || process.argv;
|
||||
const cmdName = cmd.name();
|
||||
|
||||
let subArgsStart = -1;
|
||||
for (let i = 0; i < rawArgs.length - 1; i++) {
|
||||
if (rawArgs[i] === "nodes" || rawArgs[i] === "provider-nodes") {
|
||||
if (rawArgs[i + 1] === cmdName) {
|
||||
subArgsStart = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let explicitSubcommandBaseUrl = undefined;
|
||||
let serverBaseUrl = undefined;
|
||||
|
||||
if (subArgsStart !== -1) {
|
||||
const preArgs = rawArgs.slice(0, subArgsStart);
|
||||
for (let i = 0; i < preArgs.length; i++) {
|
||||
if (preArgs[i] === "--base-url" && i + 1 < preArgs.length) {
|
||||
serverBaseUrl = preArgs[i + 1];
|
||||
} else if (preArgs[i].startsWith("--base-url=")) {
|
||||
serverBaseUrl = preArgs[i].slice("--base-url=".length);
|
||||
}
|
||||
}
|
||||
|
||||
const subArgs = rawArgs.slice(subArgsStart);
|
||||
for (let i = 0; i < subArgs.length; i++) {
|
||||
const arg = subArgs[i];
|
||||
if (
|
||||
(arg === "--base-url" || arg === "--endpoint" || arg === "--node-url") &&
|
||||
i + 1 < subArgs.length
|
||||
) {
|
||||
explicitSubcommandBaseUrl = subArgs[i + 1];
|
||||
} else if (
|
||||
arg.startsWith("--base-url=") ||
|
||||
arg.startsWith("--endpoint=") ||
|
||||
arg.startsWith("--node-url=")
|
||||
) {
|
||||
explicitSubcommandBaseUrl = arg.slice(arg.indexOf("=") + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (explicitSubcommandBaseUrl !== undefined) {
|
||||
const globals = cmd.optsWithGlobals?.() ?? {};
|
||||
const apiFetchOpts = { ...globals };
|
||||
if (serverBaseUrl) {
|
||||
apiFetchOpts.baseUrl = serverBaseUrl;
|
||||
} else {
|
||||
delete apiFetchOpts.baseUrl;
|
||||
}
|
||||
return { endpoint: explicitSubcommandBaseUrl, apiFetchOpts };
|
||||
}
|
||||
|
||||
return { endpoint: undefined, apiFetchOpts: cmd.optsWithGlobals() };
|
||||
}
|
||||
|
||||
const nodeSchema = [
|
||||
{ key: "id", header: "Node ID", width: 22 },
|
||||
{ key: "provider", header: "Provider", width: 16 },
|
||||
@@ -70,7 +144,8 @@ export function registerNodes(program) {
|
||||
nodes
|
||||
.command("add")
|
||||
.requiredOption("--provider <p>", t("nodes.add.provider"))
|
||||
.requiredOption("--endpoint <url>", t("nodes.add.baseUrl"))
|
||||
.option("--endpoint <url>", t("nodes.add.baseUrl"))
|
||||
.option("--base-url <url>", t("nodes.add.baseUrl"))
|
||||
.option("--name <n>", t("nodes.add.name"))
|
||||
.option("--weight <w>", t("nodes.add.weight"), parseInt, 100)
|
||||
.option("--region <r>", t("nodes.add.region"))
|
||||
@@ -81,9 +156,14 @@ export function registerNodes(program) {
|
||||
[]
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
|
||||
if (!endpoint) {
|
||||
process.stderr.write(`error: required option '--endpoint <url>' or '--base-url <url>' not specified\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const body = {
|
||||
provider: opts.provider,
|
||||
baseUrl: opts.endpoint,
|
||||
baseUrl: endpoint,
|
||||
name: opts.name,
|
||||
weight: opts.weight,
|
||||
region: opts.region,
|
||||
@@ -91,7 +171,7 @@ export function registerNodes(program) {
|
||||
headers: opts.authHeader?.length ? opts.authHeader : undefined,
|
||||
};
|
||||
const res = await apiFetch("/api/provider-nodes", {
|
||||
...cmd.optsWithGlobals(),
|
||||
...apiFetchOpts,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
@@ -99,24 +179,26 @@ export function registerNodes(program) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
emit(await res.json(), apiFetchOpts);
|
||||
});
|
||||
|
||||
nodes
|
||||
.command("update <nodeId>")
|
||||
.option("--endpoint <url>", t("nodes.update.baseUrl"))
|
||||
.option("--base-url <url>", t("nodes.update.baseUrl"))
|
||||
.option("--name <n>", t("nodes.update.name"))
|
||||
.option("--weight <w>", t("nodes.update.weight"), parseInt)
|
||||
.option("--region <r>", t("nodes.update.region"))
|
||||
.option("--enabled <b>", t("nodes.update.enabled"), (v) => v === "true")
|
||||
.action(async (id, opts, cmd) => {
|
||||
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
|
||||
const body = {};
|
||||
if (opts.endpoint !== undefined) body.baseUrl = opts.endpoint;
|
||||
if (endpoint !== undefined) body.baseUrl = endpoint;
|
||||
for (const k of ["name", "weight", "region", "enabled"]) {
|
||||
if (opts[k] !== undefined) body[k] = opts[k];
|
||||
}
|
||||
const res = await apiFetch(`/api/provider-nodes/${id}`, {
|
||||
...cmd.optsWithGlobals(),
|
||||
...apiFetchOpts,
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
@@ -124,7 +206,7 @@ export function registerNodes(program) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
emit(await res.json(), apiFetchOpts);
|
||||
});
|
||||
|
||||
nodes
|
||||
@@ -145,19 +227,25 @@ export function registerNodes(program) {
|
||||
|
||||
nodes
|
||||
.command("validate")
|
||||
.requiredOption("--endpoint <url>", t("nodes.validate.baseUrl"))
|
||||
.option("--endpoint <url>", t("nodes.validate.baseUrl"))
|
||||
.option("--base-url <url>", t("nodes.validate.baseUrl"))
|
||||
.requiredOption("--provider <p>", t("nodes.validate.provider"))
|
||||
.action(async (opts, cmd) => {
|
||||
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
|
||||
if (!endpoint) {
|
||||
process.stderr.write(`error: required option '--endpoint <url>' or '--base-url <url>' not specified\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const res = await apiFetch("/api/provider-nodes/validate", {
|
||||
...cmd.optsWithGlobals(),
|
||||
...apiFetchOpts,
|
||||
method: "POST",
|
||||
body: { baseUrl: opts.endpoint, provider: opts.provider },
|
||||
body: { baseUrl: endpoint, provider: opts.provider },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
emit(await res.json(), apiFetchOpts);
|
||||
});
|
||||
|
||||
nodes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { printHeading, printInfo, printSuccess, printError, printWarning } from "../io.mjs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -6,6 +6,7 @@ import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { npmBin, npmExecOptions } from "../npm-exec.mjs";
|
||||
import { readPidFile, isPidRunning } from "../utils/pid.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -79,6 +80,39 @@ export async function createBackup() {
|
||||
}
|
||||
}
|
||||
|
||||
// #11885: `--apply` installs the new files (npm install -g) and re-reads
|
||||
// package.json from disk to confirm it, but a long-lived server process keeps
|
||||
// serving whatever it loaded at its last start — Node caches a `require()`d
|
||||
// package.json per resolved path for the life of the process. A later
|
||||
// `omniroute update` then correctly reports "already up to date" (the files
|
||||
// ARE current) while the running server is still stale, matching the reported
|
||||
// symptom. `--apply` never restarted anything and its success message ("Run
|
||||
// `omniroute --version` to verify.") implied the update was already live.
|
||||
//
|
||||
// `restart.mjs`'s `runRestartCommand()` stops then re-spawns the server in the
|
||||
// foreground (via `serve.mjs::runServe`), which can block the calling terminal
|
||||
// and is a materially bigger behavior change than this fix warrants to invoke
|
||||
// unconditionally and unattended from `--apply`. Instead, detect whether a
|
||||
// CLI-managed server is currently running (the same PID file `stop.mjs`/
|
||||
// `restart.mjs` already trust) and print an explicit, prominent instruction —
|
||||
// honest about what did and didn't happen — rather than silently assuming.
|
||||
export async function isServerProcessRunning(deps = { readPidFile, isPidRunning }) {
|
||||
const pid = deps.readPidFile("server");
|
||||
return Boolean(pid && deps.isPidRunning(pid));
|
||||
}
|
||||
|
||||
export async function printPostApplyGuidance(latest, deps = { readPidFile, isPidRunning }) {
|
||||
const running = await isServerProcessRunning(deps);
|
||||
if (running) {
|
||||
printWarning(`Files updated to ${latest}, but the running server is still on the old version.`);
|
||||
printInfo(" Run `omniroute restart` now to apply this update.");
|
||||
} else {
|
||||
printInfo(`No running OmniRoute server was detected via the CLI's PID file.`);
|
||||
printInfo(` Start it with \`omniroute serve\` (or restart your existing process) to run ${latest}.`);
|
||||
}
|
||||
printInfo("`omniroute --version` will keep reporting the old version until the process restarts.");
|
||||
}
|
||||
|
||||
export function registerUpdate(program) {
|
||||
program
|
||||
.command("update")
|
||||
@@ -210,8 +244,8 @@ export async function runUpdateCommand(opts = {}) {
|
||||
console.log(" or reorder PATH so the global bin comes first.");
|
||||
return 1;
|
||||
}
|
||||
printSuccess(`Updated to version ${latest}`);
|
||||
printInfo("Run `omniroute --version` to verify.");
|
||||
printSuccess(`Installed omniroute@${latest} to disk.`);
|
||||
await printPostApplyGuidance(latest);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
printError(`Update failed: ${err.message}`);
|
||||
|
||||
@@ -81,3 +81,7 @@ export function printInfo(message) {
|
||||
export function printError(message) {
|
||||
console.log(`\x1b[31m✖ ${message}\x1b[0m`);
|
||||
}
|
||||
|
||||
export function printWarning(message) {
|
||||
console.log(`\x1b[33m⚠ ${message}\x1b[0m`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- feat(api): add an opt-in `modelVisibilityAllowlist`/`modelVisibilityDenylist` settings pair to curate exactly which models `/v1/models` advertises, mirrored into every `auto/*` combo candidate pool so a denied model cannot be routed to via combo selection either (#11481)
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(guardrails):** enforce a bounded, deterministic contract for Video Bridge transcripts — 256 cues, 4096 input code units and 4 KiB UTF-8 per cue, 64 KiB total text, malformed-Unicode rejection, focus-window scoping, cross-source reconciliation with contributing-source metadata, and a structural provenance trust boundary so caller JSON can never self-assert `embedded`/`audio-bridge` provenance ([#11652](https://github.com/diegosouzapw/OmniRoute/issues/11652))
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(video):** orchestrate optional Video Bridge audio extraction and Audio Bridge STT behind a dual opt-in (operator setting AND per-request signal) — a new loopback-only broker `mode=audio` operation shares the frame path's exact process queue, deadline, AbortSignal, and byte budgets to extract a bounded mono 16 kHz PCM WAV from the same already-downloaded video, then reuses the existing Audio Bridge transcription boundary; provider segment timing is preserved when available and marked coarse otherwise, and every failure degrades to a visual-only-safe partial instead of throwing (#11654).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Add a tenant-bound Video Bridge drill-down lifecycle on top of the existing secure cache
|
||||
substrate: opaque hashed handles (never raw session/video identifiers), preview/standard/detail
|
||||
multiresolution variants resampled on read, response pagination capped at 8 frames and 32 MiB,
|
||||
and a new authenticated `/api/v1/video-bridge/drilldown` consumer route that stays disabled for
|
||||
remote access by default and denies cross-key access with the same response as a nonexistent
|
||||
handle (no existence oracle).
|
||||
@@ -0,0 +1 @@
|
||||
- **test(video):** Add the Video Bridge FU-07/FU-09 promotion-evidence harness (#11656) — a frozen Zod manifest schema covering the 8 required scenario kinds (static scenes, rapid cuts, late facts, fades, blur, small text, close events, visual prompt injection) with a minimum of 3 repetitions per case, deterministic declarative fixture recipes (`videoBridgePromotionFixtures.ts`), a pure medians/p95 metrics aggregator, a pure FU-07/FU-09 promotion-verdict evaluator applying the ticket's exact thresholds (missing token usage always holds), a digest-only persistence layer that never retains raw media or raw model responses, and a versioned per-model promotion allowlist shipped empty with every model defaulting to `hold`. The FU-07/FU-09 promotion verdicts themselves remain HOLD — they require a real evidence run against real models on VPS 192.168.0.15.
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(video bridge):** "embedded" transcript provenance can now be legitimately earned instead of merely asserted — a bounded, allowlisted (`mov_text`/`subrip`/`webvtt`) subtitle probe runs through the loopback-only Video Bridge broker (at most 2 streams, 10s subdeadline bounded by the request deadline, 256 KiB output, 4096-code-unit lines), normalized through a bounded, ReDoS-safe WebVTT parser and Zod-validated end to end. The adapter always resolves to an explicit `success`/`absent`/`transient_failure` outcome — a subtitle failure never breaks the visual description path, and only a fingerprint-verified broker response (never a caller-declared label) can produce embedded cues (#11659).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(kie):** reroute `flux/kontext` off the KIE Market `createTask` flow — it is catalogued with `isMarket: true` but has no Market catalog page, so KIE rejected it with "model name not supported"; it now hits the dedicated `POST /api/v1/flux/kontext/generate` / `GET /api/v1/flux/kontext/record-info` endpoints instead (#11296).
|
||||
1
changelog.d/fixes/11500-arena-elo-log-dedup.md
Normal file
1
changelog.d/fixes/11500-arena-elo-log-dedup.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): rate-limit repeated Arena ELO leaderboard fetch-failure warnings instead of logging one per sync attempt (#11500)
|
||||
@@ -0,0 +1,5 @@
|
||||
- Dropped the stale-`.eslintcache` `restore-keys` fallback from both "Restore ESLint file
|
||||
cache" steps in `ci.yml`, so the blocking `Lint` job can no longer be served per-file
|
||||
verdicts computed under a different lint config, suppressions file or lockfile. `quality.yml`
|
||||
had already dropped it in #11963; `ci.yml` — the workflow that actually gates PRs — had not
|
||||
(#11600).
|
||||
@@ -0,0 +1 @@
|
||||
- fix(ci): pass `--pass-on-unpruned-suppressions` in `run-eslint-json.mjs` so the CI Lint job no longer fails when a suppression is merely orphaned by a genuine fix, mirroring the identical fix already in `validate-release-green.mjs` (#11600)
|
||||
1
changelog.d/fixes/11704-prepublish-npx-cmd-win32.md
Normal file
1
changelog.d/fixes/11704-prepublish-npx-cmd-win32.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(build):** `prepublish.ts` bundles the ChatGPT Web (Codex) MCP bridge through `runBuildTool()` instead of spawning `npx.cmd` raw, fixing the build crash on Node ≥ 20/Windows where `.cmd` shims cannot be spawned without a shell (EINVAL) ([#11704](https://github.com/diegosouzapw/OmniRoute/issues/11704))
|
||||
@@ -0,0 +1 @@
|
||||
- fix(codex): keep `parallel_tool_calls:false` on the translated Codex Responses Lite path (#11707)
|
||||
1
changelog.d/fixes/11758-usage-refresh-exclusive-lease.md
Normal file
1
changelog.d/fixes/11758-usage-refresh-exclusive-lease.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(usage):** quota and usage refresh no longer 409 when an exclusive lease reserves the connection ([#11758](https://github.com/diegosouzapw/OmniRoute/pull/11758)) — thanks @TheDemonTuan
|
||||
@@ -0,0 +1 @@
|
||||
- fix(cli): stop prepublish from re-rebuilding the already-built ESM-only opencode-plugin dist (#11787)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): set X-OmniRoute-Selected-Connection-Id on successful combo dispatches so downstream consumers stop falling back to an empty connection id (#11810)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Antigravity's dynamic mitmAlias table no longer routes `gemini-3.7-flash-{high,medium,low}` to a literal tier-suffixed upstream id just because one connected account's own discovery listed it directly — those display ids always resolve through the safe `gemini-3.7-flash-tiered` static alias, so one account's Google-provisioned access no longer 404s every sibling account of the provider ([#11824](https://github.com/diegosouzapw/OmniRoute/issues/11824), [#11651](https://github.com/diegosouzapw/OmniRoute/issues/11651))
|
||||
1
changelog.d/fixes/11861-nous-hermes-405b-label.md
Normal file
1
changelog.d/fixes/11861-nous-hermes-405b-label.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(config):** Nous Research's `Hermes-4-405B` model now displays as "Hermes 4 405B (Nous Research)" in both the provider registry and the free-model catalog, instead of the mislabelled "Hermes 4 7B" ([#11861](https://github.com/diegosouzapw/OmniRoute/issues/11861)) — thanks @Karan825
|
||||
1
changelog.d/fixes/11885-update-restart-live-process.md
Normal file
1
changelog.d/fixes/11885-update-restart-live-process.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli):** `omniroute update --apply` now tells you explicitly whether a running server was detected and, if so, that you must run `omniroute restart` to apply the update — it never restarted anything and previously implied the update was already live once files were installed. The dashboard's npm-mode Update flow (`/api/system/version`) now tries OmniRoute's own PID-file-managed supervisor before falling back to pm2, and reports an honest "restart required" step instead of a silent pm2-only "skipped" that read like a completed update. The server-side latest-version lookup backing the dashboard's update banner also gained `--prefer-online`, closing the same stale-npm-cache class already fixed in the CLI's own copy for #4376 ([#11885](https://github.com/diegosouzapw/OmniRoute/issues/11885)).
|
||||
1
changelog.d/fixes/11911-lkgp-stale-pin-exhaustion.md
Normal file
1
changelog.d/fixes/11911-lkgp-stale-pin-exhaustion.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(resilience):** clear persisted LKGP pins when a target suffers connection/provider exhaustion or is skipped before dispatch due to cooldown/exhaustion/unavailability, preventing subsequent requests from repeatedly prioritizing known-dead providers ([#11911](https://github.com/diegosouzapw/OmniRoute/issues/11911)).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(db):** the Qdrant embedding-model dropdown now lists local/self-hosted providers (Ollama, LM Studio, vLLM, etc.) — an active connection is treated as "configured" when the provider allows an optional API key, not only when it has a real key or OAuth, so a running local embedding provider is no longer hidden from the picker ([#11949](https://github.com/diegosouzapw/OmniRoute/issues/11949))
|
||||
1
changelog.d/fixes/11999-cli-nodes-base-url-shadowing.md
Normal file
1
changelog.d/fixes/11999-cli-nodes-base-url-shadowing.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli):** support `--base-url` alongside `--endpoint` in `omniroute nodes add`, `update`, and `validate` subcommands to prevent global `--base-url` shadowing issues ([#11999](https://github.com/diegosouzapw/OmniRoute/issues/11999)).
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): stop the auto-combo candidates inspector from silently dropping model-locked/cooled-down rows (#9133)
|
||||
@@ -0,0 +1 @@
|
||||
- Electron release: `electron/package-lock.json` regained the optional `electron-builder-squirrel-windows` subtree (13 entries) that `npm ci` had been refusing as out of sync, `electron-release.yml` gained a `build_ref` dispatch input and stops regenerating release notes on a re-attach dispatch, and the npm publish workflow attaches the SBOM to the GitHub Release on dispatch publishes too — so the v3.8.51 tag ships every desktop asset and the SBOM like v3.8.49 did
|
||||
@@ -0,0 +1 @@
|
||||
- **refactor(video bridge):** extract per-video acquisition, whole-result caching, description, and metrics/abort/cleanup out of `VideoBridgeGuardrail.preCall` into a `processVideoPart` seam in a new `videoBridgePipeline.ts`, behind explicit `VideoMediaBrokerPort`, `VideoAudioTranscriptionPort`, and `VideoDrilldownPort` boundaries; `preCall` now only handles request traversal, policy, and response aggregation. The Video tab's FFmpeg/ffprobe runtime status is now an explicit `unknown` / `restricted` / `unavailable` / `available` state instead of a nullable boolean pair, fixing a case where an in-flight or failed probe was mislabeled as "install FFmpeg" ([#11657](https://github.com/diegosouzapw/OmniRoute/issues/11657)).
|
||||
@@ -0,0 +1,5 @@
|
||||
- **docs(video):** clarify that the Video Bridge transcript `source` field (`client`,
|
||||
`embedded`, `audio-bridge`) is presently caller-declared and not yet server-verified —
|
||||
OmniRoute enforces the enum shape but does not cryptographically confirm that an
|
||||
`embedded`/`audio-bridge` label came from a server-owned extraction
|
||||
([#11661](https://github.com/diegosouzapw/OmniRoute/issues/11661)).
|
||||
@@ -0,0 +1 @@
|
||||
- Stop painting every fork PR into `release/**` red: `quality.yml` `Build (advisory)` is skipped (GitHub still reports `continue-on-error` failures as check FAILURE). Hosted `ubuntu-latest` cannot finish `npm run build` on this tree — same class as #11962 taking `build.yml` off the PR rail. `docker-publish.yml` amd64 now runs on the `.113` `omni-build` pool (31 GB / 32 cores, two listeners) with Turbopack, shares the `heavy-build-main` lane with `ci.yml` `Build` so it queues instead of becoming a third heavy, and keeps per-ref concurrency (a merge storm was starting 8 concurrent OOM builds). arm64 stays on `ubuntu-24.04-arm` with webpack — there is no ARM box. Fallback when `USE_VPS_RUNNER` is off: hosted amd64 + webpack (#11976).
|
||||
@@ -0,0 +1 @@
|
||||
- `docker-publish.yml` builds with webpack (`OMNIROUTE_USE_TURBOPACK=0`) on **both** arches, including amd64 on the `.113` `omni-build` pool. Turbopack had the RAM (31 GB, `omniroute-113-6`) and still panicked (`TurbopackInternalError: there must be a path to a root`, run 33253576569, 26 min in). The same tree's arm64 webpack build on hosted `ubuntu-24.04-arm` succeeded (run 33264823398). Dockerfile already documents webpack as the Docker escape hatch (#11976).
|
||||
400
config/quality/api-typecheck-baseline.json
Normal file
400
config/quality/api-typecheck-baseline.json
Normal file
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"open-sse/transformer/responsesTransformer.ts": {
|
||||
"TS2353": 1
|
||||
},
|
||||
"open-sse/utils/progressTracker.ts": {
|
||||
"TS2353": 1
|
||||
},
|
||||
"open-sse/utils/sseHeartbeat.ts": {
|
||||
"TS2353": 1
|
||||
},
|
||||
"open-sse/utils/stream.ts": {
|
||||
"TS2353": 1
|
||||
},
|
||||
"src/app/api/assess/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/cache/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/cli-tools/all-statuses/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/cli-tools/claude-settings/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/cli-tools/cline-settings/route.ts": {
|
||||
"TS2339": 5
|
||||
},
|
||||
"src/app/api/cli-tools/codex-settings/route.ts": {
|
||||
"TS2345": 2
|
||||
},
|
||||
"src/app/api/cli-tools/grok-build-settings/route.ts": {
|
||||
"TS2304": 1
|
||||
},
|
||||
"src/app/api/cli-tools/hermes-agent-settings/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/cli-tools/letta-settings/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/cli-tools/omp-settings/route.ts": {
|
||||
"TS2339": 8
|
||||
},
|
||||
"src/app/api/cli-tools/qwen-settings/route.ts": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/combos/auto/route.ts": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/combos/test/route.ts": {
|
||||
"TS2345": 1,
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/compression/compare/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/compression/preview/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/context/combos/[id]/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/context/combos/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/copilot/chat/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/guardrails/test/route.ts": {
|
||||
"TS2554": 1
|
||||
},
|
||||
"src/app/api/internal/codex-responses-ws/route.ts": {
|
||||
"TS2740": 1,
|
||||
"TS2339": 7
|
||||
},
|
||||
"src/app/api/keys/[id]/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/local/redis/start/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/local/redis/stop/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/logs/[id]/route.ts": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/model-capability-overrides/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/model-combo-mappings/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/models/alias/route.ts": {
|
||||
"TS2339": 5
|
||||
},
|
||||
"src/app/api/models/route.ts": {
|
||||
"TS2345": 3,
|
||||
"TS2538": 1
|
||||
},
|
||||
"src/app/api/monitoring/health/route.ts": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/oauth/codex/import-token/route.ts": {
|
||||
"TS2339": 3
|
||||
},
|
||||
"src/app/api/oauth/codex/import/route.ts": {
|
||||
"TS2554": 1,
|
||||
"TS2353": 1,
|
||||
"TS2339": 3
|
||||
},
|
||||
"src/app/api/oauth/cursor/login/poll/route.ts": {
|
||||
"TS2554": 1
|
||||
},
|
||||
"src/app/api/oauth/kiro/auto-import/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/omniroute/route/preview/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/playground/presets/[id]/route.ts": {
|
||||
"TS2339": 3
|
||||
},
|
||||
"src/app/api/provider-nodes/validate/route.ts": {
|
||||
"TS2339": 2
|
||||
},
|
||||
"src/app/api/providers/[id]/login/route.ts": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/api/providers/[id]/models/route.ts": {
|
||||
"TS2367": 1,
|
||||
"TS2339": 2,
|
||||
"TS2322": 2,
|
||||
"TS2554": 2,
|
||||
"TS2345": 3
|
||||
},
|
||||
"src/app/api/providers/[id]/refresh-cursor/route.ts": {
|
||||
"TS2352": 1
|
||||
},
|
||||
"src/app/api/providers/[id]/refresh/route.ts": {
|
||||
"TS2345": 1,
|
||||
"TS2698": 1,
|
||||
"TS2339": 6
|
||||
},
|
||||
"src/app/api/providers/[id]/sync-models/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/providers/[id]/test/route.ts": {
|
||||
"TS2362": 1,
|
||||
"TS2698": 1
|
||||
},
|
||||
"src/app/api/providers/free-onboarding/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/providers/health-autopilot/actions/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/providers/route.ts": {
|
||||
"TS2352": 1,
|
||||
"TS2322": 2,
|
||||
"TS2345": 3
|
||||
},
|
||||
"src/app/api/providers/test-batch/route.ts": {
|
||||
"TS2345": 4
|
||||
},
|
||||
"src/app/api/providers/validate/route.ts": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/api/providers/volcengine-plan/connect/route.ts": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/api/radar/local-model-state/route.ts": {
|
||||
"TS2339": 4
|
||||
},
|
||||
"src/app/api/resilience/model-cooldowns/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/services/_shared/installRoute.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/settings/cache-config/route.ts": {
|
||||
"TS2339": 1,
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/settings/database/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/settings/models-dev/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/settings/obsidian/webdav/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/settings/proxies/bulk-import/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/settings/proxy/cloudflare-deploy/route.ts": {
|
||||
"TS2769": 1,
|
||||
"TS2322": 2
|
||||
},
|
||||
"src/app/api/settings/proxy/deno-deploy/route.ts": {
|
||||
"TS2322": 4
|
||||
},
|
||||
"src/app/api/settings/proxy/vercel-deploy/route.ts": {
|
||||
"TS2322": 3
|
||||
},
|
||||
"src/app/api/settings/reasoning-routing-rules/[id]/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/settings/reasoning-routing-rules/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/settings/reasoning-routing-rules/simulate/route.ts": {
|
||||
"TS2322": 1,
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/system/env/repair/route.ts": {
|
||||
"TS2578": 1,
|
||||
"TS2353": 3
|
||||
},
|
||||
"src/app/api/system/version/route.ts": {
|
||||
"TS2769": 1
|
||||
},
|
||||
"src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts": {
|
||||
"TS2769": 1
|
||||
},
|
||||
"src/app/api/tools/traffic-inspector/internal/ingest/route.ts": {
|
||||
"TS1117": 2,
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/tools/traffic-inspector/ws/route.ts": {
|
||||
"TS2578": 1
|
||||
},
|
||||
"src/app/api/translator/send/route.ts": {
|
||||
"TS2345": 1,
|
||||
"TS2322": 1,
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/translator/translate/route.ts": {
|
||||
"TS2345": 1,
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/usage/analytics/route.ts": {
|
||||
"TS2352": 15
|
||||
},
|
||||
"src/app/api/usage/combo-health-autopilot/route.ts": {
|
||||
"TS2769": 2
|
||||
},
|
||||
"src/app/api/v1/batches/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/v1/classify/route.ts": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/v1/files/[id]/content/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/v1/files/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/v1/images/edits/route.ts": {
|
||||
"TS2339": 18,
|
||||
"TS2322": 4
|
||||
},
|
||||
"src/app/api/v1/messages/count_tokens/route.ts": {
|
||||
"TS2339": 2,
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/v1/music/generations/route.ts": {
|
||||
"TS2322": 1,
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/v1/ocr/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/v1/provider-plugin-manifest/route.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/v1/providers/[provider]/embeddings/route.ts": {
|
||||
"TS2339": 3,
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/v1/providers/[provider]/images/generations/route.ts": {
|
||||
"TS2339": 5
|
||||
},
|
||||
"src/app/api/v1/rerank/route.ts": {
|
||||
"TS2339": 2
|
||||
},
|
||||
"src/app/api/v1/segment/route.ts": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/api/v1/session-leases/route.ts": {
|
||||
"TS2339": 4,
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/api/v1/speech-to-text/route.ts": {
|
||||
"TS2353": 1
|
||||
},
|
||||
"src/app/api/v1/text-to-speech/[voiceId]/route.ts": {
|
||||
"TS2353": 1
|
||||
},
|
||||
"src/app/api/v1/web/fetch/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/v1beta/models/route.ts": {
|
||||
"TS2345": 1,
|
||||
"TS2538": 1
|
||||
},
|
||||
"src/app/api/version-manager/restart/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/version-manager/start/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/version-manager/stop/route.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/api/webhooks/[id]/route.ts": {
|
||||
"TS2554": 1
|
||||
},
|
||||
"src/app/api/webhooks/[id]/test/route.ts": {
|
||||
"TS2352": 2
|
||||
},
|
||||
"src/app/api/webhooks/route.ts": {
|
||||
"TS2554": 1,
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/lib/db/tierConfig.ts": {
|
||||
"TS2345": 2
|
||||
},
|
||||
"src/lib/monitoring/comboHealthAutopilot.ts": {
|
||||
"TS2305": 1,
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/lib/monitoring/providerHealthAutopilot.ts": {
|
||||
"TS2352": 4
|
||||
},
|
||||
"src/lib/omnirouteStatus.ts": {
|
||||
"TS2322": 1,
|
||||
"TS2558": 1
|
||||
},
|
||||
"src/lib/providerModels/managedModelImport.ts": {
|
||||
"TS2352": 4
|
||||
},
|
||||
"src/lib/proxySubscription/parse.ts": {
|
||||
"TS2345": 3
|
||||
},
|
||||
"src/lib/quota/quotaAnalytics.ts": {
|
||||
"TS2769": 1
|
||||
},
|
||||
"src/lib/quota/quotaResetTimers.ts": {
|
||||
"TS2769": 2
|
||||
},
|
||||
"src/lib/usage/comboForecast.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/lib/usage/comboHealth.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/lib/usage/comboScoringInspector.ts": {
|
||||
"TS2352": 1,
|
||||
"TS2741": 1
|
||||
},
|
||||
"src/lib/usage/providerWindowCosts.ts": {
|
||||
"TS2322": 2,
|
||||
"TS2558": 5,
|
||||
"TS2339": 12,
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/lib/vscode/modelPresentation.ts": {
|
||||
"TS2554": 1
|
||||
},
|
||||
"src/lib/ws/handshake.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/mitm/detection/index.ts": {
|
||||
"TS2741": 1
|
||||
},
|
||||
"src/mitm/inspector/httpProxyServer.ts": {
|
||||
"TS2769": 1
|
||||
},
|
||||
"src/shared/schemas/cliCatalog.ts": {
|
||||
"TS2554": 2
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-is",
|
||||
"react18-json-view",
|
||||
"react-markdown",
|
||||
"react-reconciler",
|
||||
"recharts",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -477,11 +477,14 @@
|
||||
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22.",
|
||||
"_rebaseline_2026_08_24_11355_cooldown_recovery_guards": "PR #11355 own growth: test/route.ts 1215->1237, +22 (startup crash-recovery guard: clearStaleCrashCooldowns() now parses the persisted rate_limited_until deadline and skips clearing rows still genuinely in the future, instead of clearing every non-terminal cooldown unconditionally). Cohesive fix at the existing test-route dispatch chokepoint alongside the #11141 probe builder. Covered by tests/unit/startup-stale-cooldown-recovery.test.ts + tests/unit/repro-zai-cooldown-cleared-by-connection-test.test.ts.",
|
||||
"_rebaseline_2026_08_24_video_bridge_fu02_fu07_sampler": "PRs #11344 (FU-02 one-frame scene-aware determinism) + #11381 (FU-07 opt-in segment_aware structural sampling) own growth: videoBridgeRuntime.ts <1000->1009, +9 (sum of both boarded together in the same merge-batch). #11344 adds the deterministic one-frame midpoint fallback + policyEffective=uniform report at the existing scene_aware seam; #11381 adds the bounded local-only FFmpeg structural pre-analysis pass (scene/freeze/blur/exposure/SI-TI) and its budget-reallocation logic. Covered by tests/unit/guardrails/videoBridgeSampler.test.ts, tests/unit/guardrails/videoBridgeFu07StructuralSampling.test.ts, tests/integration/video-bridge-sampler-ffmpeg.test.ts. Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).",
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1138,
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1145,
|
||||
"_rebaseline_2026_08_29_9133_candidates_inspector_skip_flag": "#9133 own growth: open-sse/services/autoCombo/virtualFactory.ts 1138->1139 (+1, net of extraction). Fix: prepareVirtualAutoComboInputs gained an opt-in `skip` parameter so the read-only #7819 candidate inspector (open-sse/handlers/autoComboCandidates.ts) can build the FULL, unfiltered pool and decorate a resilience-blocked candidate as reachable:false instead of filterResilienceBlockedCandidates silently dropping the row before the inspector ever sees it (routing is unaffected — it never passes `skip`). The connectionsById map-building loop was extracted to buildConnectionResilienceMap() in resilienceCandidateFilter.ts (net 0 there since Prettier still breaks the call over multiple lines) and the now-unused ConnectionResilienceView import was dropped; the sole remaining growth is the new `skip` default parameter itself, which Prettier always places on its own line once the preceding options object parameter already breaks across lines — not further reducible without splitting prepareVirtualAutoComboInputs's signature away from its own body. Covered by tests/unit/auto-combo-candidates-locked-model-visible.test.ts (TDD repro: red before the fix, green after) plus the existing tests/unit/noauth-autocombo-lockout-7623.test.ts and tests/unit/auto-combo-credentialed-model-pool.test.ts (unaffected routing-path behavior).",
|
||||
"_rebaseline_2026_08_29_11481_model_exposure_list": "Feature #11481 (explicit model exposure allow/deny list for /v1/models, mirrored into auto/* combo pools) own growth on top of #9133's +1: open-sse/services/autoCombo/virtualFactory.ts 1139->1145 (measured real line count after both #9133 and #11481 merged together = one import line for filterModelExposureCandidates plus the filter-and-reassign block at the existing buildPreparedPool chokepoint, immediately after the filterPaidOnlyCandidates call it mirrors — the exact pattern #6512 already established for hidePaidModels). The actual predicate (isModelExposureAllowed, glob support via the shared globToRegex matcher) lives in the new src/shared/utils/modelExposureList.ts leaf, and the pool-filter wrapper lives in the new open-sse/services/autoCombo/modelExposureFilter.ts leaf (both well under cap) — this file only carries the minimal call-site wiring plus import, not extractable further without hiding the buildPreparedPool filter chain. Covered by tests/unit/autoCombo/model-exposure-filter-11481.test.ts (pure filter, all branches) and tests/unit/model-exposure-list.test.ts (predicate).",
|
||||
"_rebaseline_2026_08_28_mergebatch_v3851_qwen_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11713 (Qwen Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1132->1135 (+3, combining the Designer + Runtime retirement-guard filter into the single runtimeConnections predicate at the existing candidate-pool chokepoint, now excluding Qwen Web alongside Felo Web). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
|
||||
"_rebaseline_2026_08_28_mergebatch_v3851_chatgptweb_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11754 (common ChatGPT Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1135->1138 (+3, an early `available` connection filter for the retired chatgpt-web/cgpt-web ids applied to both the active and disabled-noauth connection lists, ahead of the existing Designer+Runtime runtimeConnections filter). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
|
||||
"src/lib/cloudflaredTunnel.ts": 1078,
|
||||
"src/shared/components/RequestLoggerDetail.tsx": 1018,
|
||||
"src/shared/components/RequestLoggerDetail.tsx": 1111,
|
||||
"_rebaseline_2026_08_30_11703_json_tree_viewer": "/merge-batch 2026-08-30 (v3.8.51): #11703 (hartmark) own growth: src/shared/components/RequestLoggerDetail.tsx 1018->1111 (+93). The 2026-07-22 annotation on this same file said 'no further growth without split rationale' — this PR does split: the collapsible-JSON-tree rendering logic itself lives in the sibling RequestLoggerDetail.sections.tsx (PayloadSection/StreamSection extraction, +82 lines there) plus two new leaves (JsonTreeExpandControls.tsx, useTimestampTitles.ts) and a new store (jsonTreeExpandStore.ts) — all well under cap. The +93 remaining here is the irreducible call-site wiring: import + mount JsonTreeExpandControls, wire the per-section expand-level state and timestamp-tooltip hook into the existing detail panel layout. Covered by the PR's own tests/unit/dashboard/payload-section-collapsible-json.test.tsx, timestamp-titles.test.tsx, tests/unit/shared/json-tree-expand-store.test.ts, short-call-id.test.ts (43/43 vitest + 11/11 native pass).",
|
||||
"src/app/api/providers/[id]/test/route.ts": 1255,
|
||||
"src/lib/guardrails/videoBridgeRuntime.ts": 1009,
|
||||
"_rebaseline_2026_08_28_mergebatch_v3851_ratchet_bank_reconcile": "/merge-batch 2026-08-28 (v3.8.51): boarding #11702 (fix/verify-ratchet-bank object-note comparator) surfaced a large stale `frozen`/`testFrozen` snapshot on PR #11702's own branch (forked before the 08-11 banking outage — see the object-valued `_rebaseline_2026_08_11_v3850_merge_storm_provider_registry` note above, the exact bug #11702 fixes in the verifier) — its conflicting block duplicated ~85 already-tracked files with sizes smaller than the current release tip, and still listed open-sse/executors/chatgpt-web.ts (deleted by the #11754 retirement). Resolved by re-measuring every file in the union of both sides directly on the boarded tree (split(\"\\n\").length, matching check-file-size.mjs) rather than trusting either stale snapshot; dropped the dead chatgpt-web.ts entry; kept the two genuinely-new entries PR #11702's branch had that this tip did not yet track (src/app/api/providers/[id]/test/route.ts, src/lib/guardrails/videoBridgeRuntime.ts, both re-measured). Same reconciliation applied to the testFrozen block above.",
|
||||
|
||||
@@ -514,7 +514,7 @@ For the full stealth playbook and operational guidance, see
|
||||
Primary state DB (SQLite):
|
||||
|
||||
- Core infra: `src/lib/db/core.ts` (better-sqlite3, migrations, WAL)
|
||||
- Re-export facade: `src/lib/localDb.ts` (thin compatibility layer for callers)
|
||||
- DB access: import specific `src/lib/db/*` modules directly (the old `localDb.ts` barrel was removed)
|
||||
- file: `${DATA_DIR}/storage.sqlite` (or `$XDG_CONFIG_HOME/omniroute/storage.sqlite` when set, else `~/.omniroute/storage.sqlite`)
|
||||
- entities (tables + KV namespaces): providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing, **customModels**, **proxyConfig**, **ipFilter**, **thinkingBudget**, **systemPrompt**
|
||||
|
||||
@@ -888,7 +888,7 @@ flowchart LR
|
||||
### Persistence
|
||||
|
||||
- `src/lib/db/*`: persistent config/state and domain persistence on SQLite
|
||||
- `src/lib/localDb.ts`: compatibility re-export for DB modules
|
||||
- `src/lib/db/*`: import specific modules directly — no barrel (the old `localDb.ts` re-export layer was removed)
|
||||
- `src/lib/usageDb.ts`: usage history/call logs facade on top of SQLite tables
|
||||
|
||||
## Provider Executor Coverage (Strategy Pattern)
|
||||
|
||||
@@ -313,7 +313,7 @@ table groups the actual directories and notable top-level files.
|
||||
|
||||
Top-level files in `src/lib/`:
|
||||
|
||||
- `localDb.ts` — re-export layer only. **Never** add logic here.
|
||||
- The old `localDb.ts` barrel was removed — consumers import specific `src/lib/db/*` modules directly.
|
||||
- `proxyHealth.ts`, `proxyLogger.ts`, `tokenHealthCheck.ts`, `localHealthCheck.ts`
|
||||
- `oneproxyRotator.ts`, `oneproxySync.ts`
|
||||
- `apiBridgeServer.ts`, `cacheLayer.ts`, `semanticCache.ts`, `settingsCache.ts`
|
||||
@@ -759,7 +759,7 @@ See [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) and the dedicated section in
|
||||
2. Export CRUD functions for your domain.
|
||||
3. If new tables: add a migration under `src/lib/db/migrations/`, numbered
|
||||
sequentially, idempotent, transactional.
|
||||
4. Re-export from `src/lib/localDb.ts` (re-export only — **no logic**).
|
||||
4. Importers use direct imports from `@/lib/db/yourModule` (no barrel — the old `localDb.ts` re-export layer was removed).
|
||||
5. Add tests under `tests/unit/`.
|
||||
|
||||
### Add a new MCP tool
|
||||
@@ -790,7 +790,7 @@ See [A2A-SERVER.md § Adding a New Skill](../frameworks/A2A-SERVER.md). Skills l
|
||||
- **TypeScript**: `strict: false` (legacy posture). Prefer explicit types over
|
||||
inference for cross-module boundaries.
|
||||
- **Database**: never write raw SQL in routes or handlers — always go through
|
||||
`src/lib/db/` modules. Never add logic to `src/lib/localDb.ts`.
|
||||
`src/lib/db/` modules. Never barrel-import — use specific `src/lib/db/*` modules directly.
|
||||
- **DB-entity typing (#3512)**: a function that writes or reads a DB table's
|
||||
row shape should take/return a named TS interface mirroring that table's
|
||||
columns 1:1, not `any` or an inline anonymous type at the call site. Land
|
||||
@@ -824,7 +824,7 @@ See [A2A-SERVER.md § Adding a New Skill](../frameworks/A2A-SERVER.md). Skills l
|
||||
## 12. Hard Rules (from CLAUDE.md)
|
||||
|
||||
1. Never commit secrets or credentials.
|
||||
2. Never add logic to `src/lib/localDb.ts`.
|
||||
2. Never barrel-import — use specific `src/lib/db/*` modules directly.
|
||||
3. Never use `eval()` / `new Function()` / implied eval.
|
||||
4. Never commit directly to `main`.
|
||||
5. Never write raw SQL in routes — always go through `src/lib/db/` modules.
|
||||
|
||||
@@ -494,7 +494,7 @@ Radar è opt-in e usa soltanto richieste GET. Il client OmniRoute non carica pro
|
||||
- **🧠 Memoria sotto il tuo controllo** — disattivata per impostazione predefinita, quantizzazione vettoriale int8 opt-in + decadimento tipizzato, `x-omniroute-no-memory` per-request. → [Memoria](../../frameworks/MEMORY.md)
|
||||
- **🛡️ Sicurezza** — guard contro la prompt injection su ogni route LLM (suite red-team), guardrail opzionale per il masking delle credenziali (oscura API key/secret trapelati in entrambe le direzioni), web search DuckDuckGo gratuita come ultima risorsa e gate di login OIDC opzionale per la dashboard (il login con password resta sempre disponibile). → [Guardrail](../../security/GUARDRAILS.md)
|
||||
- **🖼️ Nuovi endpoint** — `/v1/ocr` (Mistral OCR) e `/v1/audio/translations` (stile Whisper) completano la superficie media. → [Riferimento API](../../reference/API_REFERENCE.md)
|
||||
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Segmind e provider vocali come ElevenLabs. → [Riferimento API](../../reference/API_REFERENCE.md)
|
||||
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind e provider vocali come ElevenLabs. → [Riferimento API](../../reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment e operazioni** — `basePath` del reverse proxy, rilevamento automatico della lingua del browser, tracking dei dispositivi per chiave, trust MITM senza root, localizzazione zh-TW. → [Ambiente](../../reference/ENVIRONMENT.md)
|
||||
- **🤝 Più provider e agenti** — Cursor Cloud Agent, Grok Build (xAI) con login browser + OAuth, scheda Ollama di prima classe, Claude Opus 5 e Sonnet 5, partnership ufficiale Kimi (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… e un catalogo aggiornato di **350 provider**. → [Provider](../../reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Trasparenza del routing** — ogni risposta include un header `X-OmniRoute-Decision` con strategia/provider/latenza che l'ha servita; una nuova strategia combo `cache-optimized` + il fattore `cacheAffinity` di Auto-Combo riportano le richieste ripetute alla connessione che possiede il prefisso in cache; un endpoint read-only `/v1/auto-combo/{channel}/candidates` espone il pool di candidati live di un canale `auto/*`. → [Auto-Combo](../../routing/AUTO-COMBO.md)
|
||||
|
||||
@@ -548,7 +548,7 @@ Radar isteğe bağlıdır (opt-in) ve yalnızca GET istekleri yapar. OmniRoute i
|
||||
- **🧠 Kontrol ettiğiniz bellek** — Varsayılan olarak kapalı, isteğe bağlı int8 vektör niceleme + tipli sönümleme, istek başına `x-omniroute-no-memory`. → [Bellek](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Güvenlik** — Her LLM rotasında istem enjeksiyonu koruması (red-team paketi), isteğe bağlı kimlik bilgisi maskeleme koruması (her iki yönde de sızan API anahtarlarını/gizli bilgileri sansürler), ücretsiz DuckDuckGo son çare web araması ve pano için isteğe bağlı OIDC giriş kapısı (şifreyle giriş her zaman kullanılabilir kalır). → [Güvenlik Önlemleri (Guardrails)](docs/security/GUARDRAILS.md)
|
||||
- **🖼️ Yeni uç noktalar** — `/v1/ocr` (Mistral OCR) ve `/v1/audio/translations` (Whisper tarzı) medya yüzeyini tamamlar. → [API Referansı](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Segmind ve ElevenLabs gibi konuşma sağlayıcıları. → [API Referansı](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind ve ElevenLabs gibi konuşma sağlayıcıları. → [API Referansı](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Dağıtım ve operasyonlar** — Ters proxy `basePath`, tarayıcı dili otomatik algılama, anahtar başına cihaz takibi, root gerektirmeyen MITM güveni, zh-TW yerelleştirmesi. → [Ortam Değişkenleri](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 Daha fazla sağlayıcı ve ajan** — Cursor Cloud Agent, tarayıcı + OAuth girişiyle Grok Build (xAI), Ollama birinci sınıf kartı, Claude Opus 5 ve Sonnet 5, Kimi resmi ortaklığı (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… ve yenilenen **350 sağlayıcılı katalog**. → [Sağlayıcılar](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Yönlendirme şeffaflığı** — Her yanıt, isteğe hizmet veren stratejiyi/sağlayıcıyı/gecikmeyi belirten bir `X-OmniRoute-Decision` başlığı taşır, yeni bir `cache-optimized` kombo stratejisi + Auto-Combo `cacheAffinity` faktörü yinelenen istekleri önbelleğe alınmış öneki tutan bağlantıya geri yönlendirir ve salt okunur bir `/v1/auto-combo/{channel}/candidates` uç noktası bir `auto/*` kanalının canlı aday havuzunu gösterir. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
|
||||
@@ -158,7 +158,7 @@ status. CI performs the broader package artifact and ecosystem checks.
|
||||
|
||||
**Contracts**
|
||||
|
||||
- Domain modules under `src/lib/db/`; `src/lib/localDb.ts` remains a re-export layer only.
|
||||
- Domain modules under `src/lib/db/`; import specific modules directly (the old `localDb.ts` re-export layer was removed).
|
||||
- Numbered, idempotent SQL migrations under `src/lib/db/migrations/`, transaction safety, upgrade
|
||||
behavior, indexes, and every caller affected by the schema.
|
||||
- Routes and handlers never issue raw SQL directly.
|
||||
|
||||
@@ -7,13 +7,13 @@ title: Self-Hosted Runner Box Operations
|
||||
The self-hosted pool (`self-hosted, omni-release` on all eight runners; `omni-build` on two) runs on the **.113** box.
|
||||
Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
|
||||
|
||||
| resource | value | what it means for scheduling |
|
||||
| --------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
|
||||
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
|
||||
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
|
||||
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
|
||||
| runners | **6 listeners**: 4 OmniRoute (2 `omni-build` + 2 `omni-light`) + OmniHeuris + OmniMind | all share the memory above; `omniroute-113-3/-4/-7/-8` are disabled (`systemctl enable --now` brings one back) |
|
||||
| resource | value | what it means for scheduling |
|
||||
| --------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
|
||||
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
|
||||
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
|
||||
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
|
||||
| runners | **6 listeners**: 4 OmniRoute (1 `omni-build` + 1 `omni-release`-only + 2 `omni-light`) + OmniHeuris + OmniMind | all share the memory above; `omniroute-113-3/-4/-7/-8` are disabled (`systemctl enable --now` brings one back) |
|
||||
|
||||
## Install the janitor (one-time, on the box)
|
||||
|
||||
@@ -21,7 +21,7 @@ Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
|
||||
scp scripts/ops/runner-janitor.sh root@192.168.0.113:/opt/omniroute-ops/runner-janitor.sh
|
||||
ssh root@192.168.0.113 'chmod +x /opt/omniroute-ops/runner-janitor.sh; apt-get install -y lsof'
|
||||
# cron (root): every 30 min, log to /var/log/runner-janitor.log
|
||||
*/30 * * * * MAX_ACTIVE_RUNNERS=4 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
|
||||
*/30 * * * * MAX_ACTIVE_RUNNERS=6 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
|
||||
```
|
||||
|
||||
`lsof` is required: the janitor proves a path is idle with one snapshot of open
|
||||
@@ -49,14 +49,28 @@ a time, only when idle**, with the idle check and the restart in the same comman
|
||||
|
||||
## Operating rules
|
||||
|
||||
- **Heavy-build ceiling: 2 at a time — enforced by label.** Every job that runs a
|
||||
`next build` (`ci.yml` `build`, `npm-publish.yml` `publish`, both `nightly-release-green`
|
||||
validations) targets `[self-hosted, omni-build]`, and only **two** runners carry that
|
||||
label (`omniroute-113-5`, `omniroute-113-6`, added through the runners API — no
|
||||
re-registration). The other six keep `omni-release` and take nothing heavy; GitHub
|
||||
queues a third build instead of the kernel killing one. Pair with the `heavy-build-*`
|
||||
concurrency lanes in `ci.yml`. To add capacity, label another runner — never raise
|
||||
the count past what 31 GB holds (one next-build ≈ 14–16 GB).
|
||||
- **Heavy-build ceiling: ONE at a time — enforced by label (since 2026-08-29).** Every job
|
||||
that runs a full `next build` targets `[self-hosted, omni-build]`, and only
|
||||
**`omniroute-113-5`** carries that label (added through the runners API — no
|
||||
re-registration): `ci.yml` `Build`, `npm-publish.yml` `publish`, both
|
||||
`nightly-release-green` validations, and `docker-publish.yml` **amd64** (hosted
|
||||
7 GB ResourceExhausted this tree — #11976). The arm64 Docker leg stays on
|
||||
`ubuntu-24.04-arm` (no ARM box) with webpack. Docker amd64 also uses webpack:
|
||||
Turbopack on this tree panicked inside BuildKit (`TurbopackInternalError:
|
||||
there must be a path to a root`, run 33253576569) even with 31 GB; the same
|
||||
tree's arm64 webpack build on hosted ARM succeeded. `docker-publish` amd64
|
||||
shares the `heavy-build-main` concurrency group with `ci.yml` `Build`
|
||||
(`cancel-in-progress: false`) so it queues on the one slot. Docker Engine
|
||||
must be on `omniroute-113-5` (`docker info` is the first step of the publish
|
||||
job). Two was the previous ceiling and it was wrong for 31 GB: on
|
||||
2026-08-29 17:26 UTC two concurrent `next-build`s (15.4 GB + 17.2 GB RSS) drove the box
|
||||
to 5 GB free with 4 GB of swap in use and the kernel OOM-killed one of them — systemd
|
||||
booked the kill on the _other_ runner's unit, `runsvc.sh` SIGKILLed that listener, and
|
||||
the job on it died with "The runner has received a shutdown signal" (same text as a
|
||||
hosted-runner OOM). `omniroute-113-6` keeps `omni-release` only. Heavy builds from
|
||||
`main` merges, PRs and the nightly now serialize on one slot; the queue is the price.
|
||||
The second slot comes back the day the Proxmox VM gets more RAM (48–64 GB):
|
||||
`gh api -X POST repos/<repo>/actions/runners/<id of omniroute-113-6>/labels -f 'labels[]=omni-build'`.
|
||||
- **Light pool: `omni-light` (2026-08-29, #11965).** `omniroute-113` and `omniroute-113-2` carry
|
||||
`omni-light` for jobs that need a backend-only `next build` (~5–6 GB) but not a full one: the
|
||||
nightly Schemathesis, promptfoo, garak and axe-a11y jobs. They ran on the hosted 7 GB runner and
|
||||
@@ -65,7 +79,8 @@ a time, only when idle**, with the idle check and the restart in the same comman
|
||||
on the Proxmox VM (`tomni-proxmox-113`), which turns the label ceilings into 3 heavy + 2 light.
|
||||
- **Fewer listeners on purpose.** Four OmniRoute units were disabled on 2026-08-29 — with only
|
||||
`ci.yml` `Build` and the nightlies using the box, 8 listeners were idle and each extra one is a
|
||||
potential 14 GB tenant. The janitor ceiling is 4 (`MAX_ACTIVE_RUNNERS=4` in cron).
|
||||
potential 14 GB tenant. The janitor ceiling is 6 (`MAX_ACTIVE_RUNNERS=6` in cron): it counts
|
||||
every `Runner.Listener` on the box, and OmniHeuris + OmniMind add two to our four.
|
||||
- **Never clean `/tmp` or `_work` by hand while any runner is busy.** A
|
||||
check-then-delete with a gap between the two is how a live Build job lost its
|
||||
`_work` on 2026-08-27. The janitor does the check and the removal in one step;
|
||||
|
||||
66
docs/routing/MODEL_EXPOSURE_LIST.md
Normal file
66
docs/routing/MODEL_EXPOSURE_LIST.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: "Model Exposure Allow/Deny List"
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-08-29
|
||||
---
|
||||
|
||||
# Model Exposure Allow/Deny List
|
||||
|
||||
> Opt-in, off by default (`modelVisibilityAllowlist`/`modelVisibilityDenylist` both empty leaves
|
||||
> the `/v1/models` catalog AND every `auto/*` candidate pool byte-identical). A sibling of
|
||||
> `hidePaidModels`/`hideAutoCombos` (`src/lib/db/settings.ts`) for operators who want a curated
|
||||
> model list for reasons that have nothing to do with cost.
|
||||
|
||||
## Why this exists
|
||||
|
||||
`hidePaidModels` answers "is this model free?" and `hideAutoCombos` answers "should `auto/*`
|
||||
virtual ids be advertised at all?" — neither lets an operator curate an arbitrary subset of
|
||||
models (e.g. presenting exactly the models a given Claude Code / OpenCode client should see,
|
||||
independent of pricing). #11481 adds that as two independent, opt-in string-array settings.
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| ----------------------------- | ---------- | ------- | ----------------------------------------------------------------- |
|
||||
| `modelVisibilityDenylist` | `string[]` | `[]` | Entries matching a candidate hide it from the catalog/candidate pool. |
|
||||
| `modelVisibilityAllowlist` | `string[]` | `[]` | When non-empty, ONLY entries matching a candidate stay exposed. |
|
||||
|
||||
Both accept up to 500 entries of up to 200 characters each (Zod-validated in
|
||||
`src/shared/validation/settingsSchemas.ts`). An entry is either:
|
||||
|
||||
- an exact catalog id — `"gpt-4o"` (bare model id) or `"openai/gpt-4o"` (provider-prefixed), or
|
||||
- a glob pattern using `*`/`?` — e.g. `"openai/gpt-4*"` or `"anthropic/*"` — resolved via the
|
||||
same shared `globToRegex()` matcher (`src/shared/utils/globPattern.ts`) already used by
|
||||
`ModelRoutingSection`'s per-model combo mappings and `freeModels.ts::matchesOnlyPaidModels`.
|
||||
|
||||
Precedence: the denylist is checked first (a denied entry is always hidden, even if it also
|
||||
matches the allowlist); when the allowlist is non-empty, only entries it matches survive.
|
||||
|
||||
## Two chokepoints, not one
|
||||
|
||||
The lesson from #6512 (a `hidePaidModels`-only catalog filter still let `auto/*` route to a
|
||||
paid model, since the combo candidate pool was built independently) applies identically here.
|
||||
The matching predicate `isModelExposureAllowed()` (`src/shared/utils/modelExposureList.ts`) is
|
||||
called from BOTH:
|
||||
|
||||
- `src/app/api/v1/models/catalog.ts` — the `/v1/models` listing itself, at the same 5 per-source
|
||||
chokepoints `shouldHidePaid()` already gates (static `PROVIDER_MODELS`, synced provider rows,
|
||||
custom rows, alias-backed rows, managed-fallback rows).
|
||||
- `open-sse/services/autoCombo/modelExposureFilter.ts::filterModelExposureCandidates()` — called
|
||||
from `virtualFactory.ts::buildPreparedPool`, immediately after the equivalent
|
||||
`filterPaidOnlyCandidates()` call, so a denied model can never be selected into an `auto/*`
|
||||
candidate pool either.
|
||||
|
||||
## What is NOT filtered
|
||||
|
||||
Mirrors `hideAutoCombos`'s existing behaviour: a model id sent **explicitly** (not via `auto/*`,
|
||||
and not discovered through the catalog listing) is never blocked at dispatch — only
|
||||
advertisement/candidate-pool membership is filtered. This is independent of `hidePaidModels`;
|
||||
an operator may want a curated set for reasons that have nothing to do with cost, so both
|
||||
settings compose as independent AND-ed filters, same as the existing multi-flag composition in
|
||||
`catalog.ts`.
|
||||
|
||||
Settings export (`GET /api/settings/export-json`) includes both arrays verbatim, like any other
|
||||
settings field — unlike `hidePaidModels`'s combo-step export filter, there is no re-hydration
|
||||
risk here: a denied id embedded in an exported combo step is the operator's own explicit routing
|
||||
choice, not something the export boundary needs to strip.
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
title: "Guardrails"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-24
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-08-29
|
||||
---
|
||||
|
||||
# Guardrails
|
||||
|
||||
> **Source of truth:** `src/lib/guardrails/`
|
||||
> **Last updated:** 2026-08-24 — v3.8.50 (Video Bridge visual dedup hardening + focused captions)
|
||||
> **Last updated:** 2026-08-29 — v3.8.51 (Video Bridge transcript provenance is caller-declared,
|
||||
> not yet server-verified — clarified per #11661)
|
||||
|
||||
Guardrails enforce safety, policy, and content transformations at the boundary
|
||||
between OmniRoute and upstream providers. Each guardrail can inspect (and
|
||||
@@ -206,8 +207,12 @@ limits, runtime
|
||||
counters, and a guarded sample request. The Audio tab is also live: it exposes
|
||||
enablement, an STT-only model picker with Auto, timeout/max-clip limits, audio
|
||||
counters, and an `input_audio` sample test. The Video tab is functional: it reports
|
||||
the FFmpeg/ffprobe runtime state, persists enable/model/frame/video/timeout limits,
|
||||
filters the model picker to vision-capable models, and exposes video counters.
|
||||
the FFmpeg/ffprobe runtime state — one of four explicit UI states (`unknown` while
|
||||
the probe is in flight or could not complete, `restricted` on a non-loopback
|
||||
dashboard host where the probe is skipped client-side, `unavailable` once probed
|
||||
and confirmed missing, or `available` with the FFmpeg/ffprobe versions) — persists
|
||||
enable/model/frame/video/timeout limits, filters the model picker to vision-capable
|
||||
models, and exposes video counters.
|
||||
|
||||
The former Vision Bridge card under AI settings is a compatibility link to the
|
||||
new page; it no longer owns a second copy of the form. Media Providers also
|
||||
@@ -287,7 +292,7 @@ Runtime settings are DB-backed and Zod-validated:
|
||||
The shared cache remains controlled by `modalityBridgeCacheEnabled`,
|
||||
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`.
|
||||
|
||||
### Video Bridge (`videoBridge.ts`)
|
||||
### Video Bridge (`videoBridge.ts`, `videoBridgePipeline.ts`)
|
||||
|
||||
Intercepts top-level video parts in Chat Completions `messages` and Responses
|
||||
API `input` before a target without known native video support is called.
|
||||
@@ -295,6 +300,19 @@ Supported shapes are `input_video`, `video_url`, `video_source`, HTTPS URLs,
|
||||
and `data:video/*;base64,...` data URIs. Plain filenames in text are not treated
|
||||
as video.
|
||||
|
||||
`VideoBridgeGuardrail.preCall` (`videoBridge.ts`) owns request traversal, the
|
||||
capability/policy check, per-request aggregation, and the response payload.
|
||||
Per-video work — acquisition, the whole-result cache, describing a frame
|
||||
sequence (which fuses any caller-declared audio transcript), and per-attempt
|
||||
metrics/abort/cleanup — is hidden behind `processVideoPart` in
|
||||
`videoBridgePipeline.ts`, called once per video part inside `preCall`'s loop.
|
||||
That module also defines the explicit port boundaries `VideoMediaBrokerPort`
|
||||
(acquiring bytes and extracting sampled frames), `VideoAudioTranscriptionPort`
|
||||
(fusing a caller-declared audio transcript with the sampled captions), and
|
||||
`VideoDrilldownPort` (the frame drill-down persistence boundary; not yet wired
|
||||
into `processVideoPart` — only the separate `/api/modality-bridge/video/drilldown`
|
||||
route writes drill-down entries today).
|
||||
|
||||
The public `/v1` request path never imports or invokes a subprocess. Remote
|
||||
videos are downloaded under a 50 MiB bound; inline base64 videos have a
|
||||
conservative 36 MiB decoded per-video cap so the model/messages/framing envelope
|
||||
@@ -437,7 +455,12 @@ OmniRoute never starts transcription from this metadata: validated cues are
|
||||
copied into the described result with source, confidence, and interval, and
|
||||
are rendered as untrusted observations alongside the frame captions. Invalid,
|
||||
out-of-range, or provenance-free text is rejected rather than mixed into the
|
||||
caption stream.
|
||||
caption stream. The `source` field is presently caller-declared, not
|
||||
server-verified: OmniRoute enforces that the value is one of the three
|
||||
allowed strings, but does not yet cryptographically confirm that an
|
||||
`embedded` or `audio-bridge` label actually came from a server-owned
|
||||
extraction. Treat `source` as an untrusted hint until that verification
|
||||
lands; do not build authorization decisions on it.
|
||||
|
||||
An advanced caller may provide an already-authorized `audioTranscript` track
|
||||
for the same video. The fusion seam runs visual and audio observations under
|
||||
|
||||
195
electron/package-lock.json
generated
195
electron/package-lock.json
generated
@@ -297,6 +297,45 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
|
||||
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"minimist": "^1.2.8",
|
||||
"postject": "^1.0.0-alpha.6"
|
||||
},
|
||||
"bin": {
|
||||
"electron-windows-sign": "bin/electron-windows-sign.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
|
||||
"version": "11.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
|
||||
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -1091,6 +1130,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-dirname": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1411,6 +1459,19 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder-squirrel-windows": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
|
||||
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.15.3",
|
||||
"builder-util": "26.15.3",
|
||||
"electron-winstaller": "5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
|
||||
@@ -1445,6 +1506,66 @@
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
|
||||
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
"fs-extra": "^7.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"temp": "^0.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@electron/windows-sign": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/fs-extra": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -2359,6 +2480,20 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2622,6 +2757,36 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"postject": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postject/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
@@ -2816,6 +2981,21 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
|
||||
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
@@ -3071,6 +3251,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/temp": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-file": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",
|
||||
|
||||
@@ -329,11 +329,12 @@ const nextConfig = {
|
||||
// TODO: Re-enable after fixing all sub-component useTranslations scope issues
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
webpack(config, { webpack }) {
|
||||
webpack(config, { dev, webpack }) {
|
||||
config.ignoreWarnings = [
|
||||
...(config.ignoreWarnings || []),
|
||||
isNextIntlExtractorDynamicImportWarning,
|
||||
];
|
||||
const nextDefaultSplitChunks = config.optimization?.splitChunks;
|
||||
config.optimization = config.optimization || {};
|
||||
config.optimization.splitChunks = {
|
||||
...config.optimization.splitChunks,
|
||||
@@ -392,6 +393,9 @@ const nextConfig = {
|
||||
},
|
||||
},
|
||||
};
|
||||
// Next's development defaults are tuned for incremental route compilation.
|
||||
// Retain the custom vendor topology for production without imposing it on dev.
|
||||
if (dev) config.optimization.splitChunks = nextDefaultSplitChunks;
|
||||
|
||||
if (isMinimalBuild) {
|
||||
// Mirror the turbopack.resolveAlias entries for webpack-built artifacts.
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
|
||||
* rewrites file timestamps on every deploy, which would report a months-old
|
||||
* catalog as "updated today". Bump this whenever the entries below change.
|
||||
*/
|
||||
export const FREE_CATALOG_CURATED_AT = "2026-08-26";
|
||||
export const FREE_CATALOG_CURATED_AT = "2026-08-30";
|
||||
|
||||
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
||||
@@ -268,7 +268,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "muse-spark-web", modelId: "muse-spark-contemplating", displayName: "Muse Spark Contemplating", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "muse-spark-web", tos: "avoid" },
|
||||
{ provider: "nebius", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "Llama 3.3 70B Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "nebius", tos: "caution" },
|
||||
{ provider: "nlpcloud", modelId: "llama-3-8b-instruct", displayName: "Llama 3 8B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-monthly", poolKey: "nlpcloud", tos: "avoid" },
|
||||
{ provider: "nous-research", modelId: "Hermes-4-405B", displayName: "Hermes 4 7B (Nous Research)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-credit", poolKey: "nous-research", tos: "ambiguous" },
|
||||
{ provider: "nous-research", modelId: "Hermes-4-405B", displayName: "Hermes 4 405B (Nous Research)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-credit", poolKey: "nous-research", tos: "ambiguous" },
|
||||
{ provider: "nous-research", modelId: "Hermes-4-70B", displayName: "Hermes 4 70B (Nous Research)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-credit", poolKey: "nous-research", tos: "ambiguous" },
|
||||
{ provider: "novita", modelId: "ai-ai/llama-3.1-8b-instruct", displayName: "Llama 3.1 8B", monthlyTokens: 0, creditTokens: 500000, freeType: "one-time-initial", poolKey: "novita", tos: "caution" },
|
||||
{ provider: "nscale", modelId: "moonshotai/Kimi-K2.5", displayName: "moonshotai/Kimi-K2.5", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
|
||||
@@ -312,6 +312,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "opencode-zen", modelId: "opencode/nemotron-3-ultra-free", displayName: "Nemotron 3 Ultra (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "opencode-zen-free", tos: "caution" },
|
||||
{ provider: "openrouter", modelId: "auto", displayName: "Auto (Best Available)", monthlyTokens: 1200000, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
|
||||
{ provider: "openrouter", modelId: "stealth/ox-alpha", displayName: "Stealth Ox Alpha (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
|
||||
{ provider: "openrouter", modelId: "liquid/lfm-2.5-2.6b:free", displayName: "LiquidAI: LFM2.5-2.6B (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
|
||||
{ provider: "pollinations", modelId: "openai", displayName: "OpenAI (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
|
||||
{ provider: "pollinations", modelId: "openai-fast", displayName: "OpenAI Fast (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
|
||||
{ provider: "pollinations", modelId: "openai-large", displayName: "OpenAI Large (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
|
||||
|
||||
@@ -9,7 +9,7 @@ export const nous_researchProvider: RegistryEntry = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "Hermes-4-405B", name: "Hermes 4 7B (Nous Research)" },
|
||||
{ id: "Hermes-4-405B", name: "Hermes 4 405B (Nous Research)" },
|
||||
{ id: "Hermes-4-70B", name: "Hermes 4 70B (Nous Research)" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1478,6 +1478,11 @@ export class CodexExecutor extends BaseExecutor {
|
||||
"client_metadata",
|
||||
// GPT-5 output verbosity ({ verbosity } — normalized above by normalizeCodexVerbosity).
|
||||
"text",
|
||||
// Responses Lite (#7171/#7821/#11707): enforceCodexResponsesLiteParallelToolCalls()
|
||||
// forces this field on the translated (non-_nativeCodexPassthrough) path too — it
|
||||
// must survive this allowlist filter or upstream rejects with "X-OpenAI-Internal-
|
||||
// Codex-Responses-Lite requires `parallel_tool_calls` to be false."
|
||||
"parallel_tool_calls",
|
||||
// Internal markers used by OmniRoute pipeline
|
||||
"_omnirouteResponsesStore",
|
||||
]);
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* #7819 (Level 1) — read-only candidate pool + reachability listing for an
|
||||
* `auto/*` channel.
|
||||
*
|
||||
* Builds the SAME candidate pool `virtualFactory.createVirtualAutoCombo` uses
|
||||
* for routing (via `createBuiltinAutoCombo`, unfiltered by any per-key
|
||||
* exclusion so the operator can see — and toggle — excluded candidates), then
|
||||
* decorates each candidate with live reachability derived from the existing
|
||||
* resilience reads (CLAUDE.md "Resilience Runtime State"):
|
||||
* Builds its OWN candidate pool via `prepareVirtualAutoComboInputs({}, true)`
|
||||
* (#9133, the `skip` opt-out) — the full, unfiltered pool that routing would
|
||||
* otherwise narrow with `filterResilienceBlockedCandidates` before ever
|
||||
* handing it to this endpoint. Not filtered by any per-key exclusion either,
|
||||
* so the operator can see — and toggle — excluded candidates. Every
|
||||
* candidate is then decorated with live reachability derived from the
|
||||
* existing resilience reads (CLAUDE.md "Resilience Runtime State"):
|
||||
* - provider circuit breaker: `getCircuitBreaker(provider).getStatus()` /
|
||||
* `.canExecute()` — NEVER raw `state`, so an expired breaker (lazy
|
||||
* recovery) doesn't show as permanently open.
|
||||
@@ -14,13 +16,31 @@
|
||||
* provider_connections row (no-auth synthetic connections have no row —
|
||||
* treated as always reachable on this axis).
|
||||
* - model lockout: `isModelLocked(provider, connectionId, model)`.
|
||||
*
|
||||
* A candidate the routing path would currently skip (model-locked, cooled
|
||||
* down, breaker open) is never dropped from this listing — it is surfaced
|
||||
* with `reachable:false` and the specific reason field (`modelLocked` /
|
||||
* `connectionCooldown` / `breakerState`) set, since the panel's whole
|
||||
* purpose is read-only transparency, not a preview of the dispatch pool.
|
||||
* The actual routing path (`createVirtualAutoCombo` /
|
||||
* `createBuiltinAutoCombo` called without a `prepared` override) is
|
||||
* unchanged and keeps excluding these candidates before dispatch.
|
||||
*/
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { getCircuitBreaker } from "@/shared/utils/circuitBreaker";
|
||||
import { isModelLocked } from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import { parseModel } from "@omniroute/open-sse/services/model.ts";
|
||||
import { getProviderConnectionById } from "@/lib/db/providers";
|
||||
import { getExcludedConnectionIds } from "@/lib/db/autoCandidateOverrides";
|
||||
|
||||
/**
|
||||
* One row of the unfiltered, reason-annotated candidate pool (#9133): every
|
||||
* eligible provider/model/account combination the routing pool would
|
||||
* otherwise contain, including rows routing itself would currently skip.
|
||||
* `reachable:false` plus the relevant reason field (`modelLocked` /
|
||||
* `connectionCooldown` / `breakerState`) is how a blocked candidate is
|
||||
* represented here — it is never simply absent from `candidates`.
|
||||
*/
|
||||
export interface AutoComboCandidateView {
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
@@ -69,7 +89,17 @@ async function decorateCandidate(candidate: {
|
||||
}
|
||||
}
|
||||
|
||||
const modelLocked = isModelLocked(candidate.provider, candidate.connectionId, candidate.model);
|
||||
// #9133 (latent alignment): every lock writer (accountFallback.ts) and the
|
||||
// routing-side filter (resilienceCandidateFilter.ts, combo.ts) key
|
||||
// `isModelLocked` off the BARE model id, never the "provider/model"
|
||||
// `modelStr`. `candidate.model` here is the prefixed form (it comes from
|
||||
// the materialized combo's `models[].model`, not the raw candidate pool),
|
||||
// so it must be parsed back to bare before the lock lookup — currently
|
||||
// masked (both spellings map to the same quota-family key for every
|
||||
// provider exercised so far) but not guaranteed for a provider whose lock
|
||||
// key isn't family-scoped.
|
||||
const bareModel = parseModel(candidate.modelStr).model ?? candidate.model;
|
||||
const modelLocked = isModelLocked(candidate.provider, candidate.connectionId, bareModel);
|
||||
|
||||
return {
|
||||
provider: candidate.provider,
|
||||
@@ -99,15 +129,26 @@ export async function getAutoComboCandidates(
|
||||
// virtualFactory — createBuiltinAutoCombo() only recognizes `auto/<suffix>`
|
||||
// ids (matches classifyAutoModel()'s special-casing of the literal "auto"
|
||||
// model string in src/sse/handlers/autoRouting.ts).
|
||||
// #9133 — this endpoint's stated role is read-only transparency, so it must
|
||||
// build its OWN unfiltered pool (`prepareVirtualAutoComboInputs({}, true)`,
|
||||
// the `skip` opt-out) rather than reuse the routing path's pool. Routing
|
||||
// legitimately drops resilience-blocked candidates before dispatch; this
|
||||
// inspector must not, or a model-locked/cooled-down row silently vanishes
|
||||
// instead of showing up as `reachable:false` with a reason. The routing
|
||||
// path itself is unchanged — it keeps calling
|
||||
// `prepareVirtualAutoComboInputs()`/`createVirtualAutoCombo()` with the
|
||||
// default (filtered) behavior.
|
||||
const { prepareVirtualAutoComboInputs, createVirtualAutoComboFromPrepared } =
|
||||
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
|
||||
const prepared = await prepareVirtualAutoComboInputs({}, true);
|
||||
|
||||
let virtualCombo;
|
||||
if (channel === "auto") {
|
||||
const { createVirtualAutoCombo } =
|
||||
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
|
||||
virtualCombo = await createVirtualAutoCombo(undefined);
|
||||
virtualCombo = await createVirtualAutoComboFromPrepared(prepared, undefined);
|
||||
} else {
|
||||
const { createBuiltinAutoCombo } =
|
||||
await import("@omniroute/open-sse/services/autoCombo/builtinCatalog.ts");
|
||||
virtualCombo = await createBuiltinAutoCombo(modelStr, channel);
|
||||
virtualCombo = await createBuiltinAutoCombo(modelStr, channel, prepared);
|
||||
}
|
||||
|
||||
const excludedConnectionIds = apiKeyId
|
||||
|
||||
@@ -1427,7 +1427,7 @@ export async function handleChatCore({
|
||||
};
|
||||
if ((isCombo && comboName) || routingComboId) {
|
||||
try {
|
||||
const { getComboByName } = await import("../../src/lib/localDb");
|
||||
const { getComboByName } = await import("@/lib/db/combos");
|
||||
let comboConfig = await getComboByName(comboName);
|
||||
if (!comboConfig && comboName?.startsWith("combo/")) {
|
||||
comboConfig = await getComboByName(comboName.substring(6));
|
||||
@@ -1918,7 +1918,7 @@ export async function handleChatCore({
|
||||
if (isCombo && comboName) {
|
||||
log?.info?.("CONTEXT", `Attempting to resolve combo limits for comboName=${comboName}`);
|
||||
try {
|
||||
const { getComboByName } = await import("../../src/lib/localDb");
|
||||
const { getComboByName } = await import("@/lib/db/combos");
|
||||
const { resolveComboTargets } = await import("../services/combo.ts");
|
||||
let comboConfig = await getComboByName(comboName);
|
||||
if (!comboConfig && comboName.startsWith("combo/")) {
|
||||
@@ -4292,13 +4292,14 @@ export async function handleChatCore({
|
||||
let quotaCooldownMs = kimiRateLimitResetAt
|
||||
? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
|
||||
: retryAfterMs || COOLDOWN_MS.rateLimit;
|
||||
const deferAntigravityQuotaStateToCaller =
|
||||
shouldDeferAntigravityQuotaStateToCaller(
|
||||
provider,
|
||||
typeof onStreamFailure === "function"
|
||||
);
|
||||
const isAntigravityQuotaFamily =
|
||||
shouldDeferAntigravityQuotaStateToCaller(provider, true);
|
||||
const deferAntigravityQuotaStateToCaller = shouldDeferAntigravityQuotaStateToCaller(
|
||||
provider,
|
||||
typeof onStreamFailure === "function"
|
||||
);
|
||||
const isAntigravityQuotaFamily = shouldDeferAntigravityQuotaStateToCaller(
|
||||
provider,
|
||||
true
|
||||
);
|
||||
let coreOwnedAntigravityLockout: {
|
||||
cooldownMs: number;
|
||||
failureCount: number;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getUpstreamProxyConfig } from "@/lib/localDb";
|
||||
import type { FallbackBackend } from "@/lib/db/upstreamProxy";
|
||||
import { FallbackBackend, getUpstreamProxyConfig } from "@/lib/db/upstreamProxy";
|
||||
|
||||
/**
|
||||
* Module-level cache for upstream proxy config (shared across all requests).
|
||||
@@ -29,7 +28,8 @@ const COMBOS_CACHE_TTL = 10_000;
|
||||
|
||||
export async function getCombosCached(): Promise<unknown[]> {
|
||||
const now = Date.now();
|
||||
const { getCombos, getCombosCacheVersion } = await import("@/lib/localDb");
|
||||
const { getCombos } = await import("@/lib/db/combos");
|
||||
const { getCombosCacheVersion } = await import("@/lib/db/readCache");
|
||||
const version = getCombosCacheVersion();
|
||||
// A combo write (create/update/delete/reorder) bumps the shared version via
|
||||
// invalidateDbCache("combos"); when it no longer matches our snapshot we drop
|
||||
|
||||
@@ -119,20 +119,21 @@ interface KieImageOptions {
|
||||
// ideogram/v3-reframe has no dedicated docs.kie.ai page as of this sweep
|
||||
// (its 3 siblings above are all direct id matches, so it is assumed
|
||||
// correct by pattern, not independently confirmed).
|
||||
// Two catalog entries remain UNRESOLVED after this sweep and are
|
||||
// deliberately left untouched pending a follow-up (see #11296 discussion):
|
||||
// One catalog entry remains UNRESOLVED after this sweep and is deliberately
|
||||
// left untouched pending a follow-up (see #11296 discussion):
|
||||
// - z-image/4.0-text-to-image and z-image/4.5-text-to-image: the only
|
||||
// documented Z-Image Market page (docs.kie.ai/market/z-image/z-image)
|
||||
// shows a single fixed `model` enum value `"z-image"` with no
|
||||
// version-specific id or "version" input field found — unclear whether
|
||||
// both catalog ids should collapse to the same upstream call.
|
||||
// - flux/kontext: no `docs.kie.ai/market/flux2/kontext` (or similar)
|
||||
// Market page exists; Flux Kontext is documented under the separate
|
||||
// `/flux-kontext-api/*` docs tree with its own endpoint
|
||||
// (`POST /api/v1/flux/kontext/generate`, models `flux-kontext-pro`/
|
||||
// `flux-kontext-max`), not the Market `createTask` flow this map feeds.
|
||||
// This entry may be miscatalogued as `isMarket: true` and need a
|
||||
// dedicated reroute rather than an id rewrite.
|
||||
// flux/kontext is RESOLVED (#11296): it is catalogued with `isMarket: true`
|
||||
// but has no `docs.kie.ai/market/flux2/kontext` (or similar) Market page —
|
||||
// Flux Kontext is documented under the separate `/flux-kontext-api/*` docs
|
||||
// tree with its own endpoint (`POST /api/v1/flux/kontext/generate`, poll
|
||||
// `GET /api/v1/flux/kontext/record-info`, models `flux-kontext-pro`/
|
||||
// `flux-kontext-max`), not the Market `createTask` flow this map feeds. It is
|
||||
// NOT in KIE_MARKET_UPSTREAM_MODEL_IDS below on purpose — handleKieImageGeneration
|
||||
// reroutes it to the dedicated endpoint instead of rewriting its id.
|
||||
export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap<string, string> = new Map([
|
||||
["google-imagen/nano-banana", "google/nano-banana"],
|
||||
["google-imagen/nano-banana-2", "nano-banana-2"],
|
||||
@@ -815,13 +816,29 @@ async function handleKieImageGeneration({
|
||||
// Check if model is a Market model (unified API)
|
||||
const fullRegistry = getImageProvider(provider);
|
||||
const modelEntry = fullRegistry?.models?.find((m) => m.id === model);
|
||||
const isMarket = modelEntry?.isMarket || model.includes("/");
|
||||
// #11296 — flux/kontext is catalogued with `isMarket: true`, but KIE does not
|
||||
// expose it through the Market catalog at all: it lives under a dedicated API
|
||||
// tree (POST /api/v1/flux/kontext/generate, poll .../flux/kontext/record-info)
|
||||
// that rejects the Market createTask flow with "model name not supported". Route
|
||||
// it there instead of treating it as a Market entry (see KIE_MARKET_UPSTREAM_MODEL_IDS
|
||||
// comment above for the same finding).
|
||||
const isFluxKontext = model === "flux/kontext";
|
||||
const isMarket = !isFluxKontext && (modelEntry?.isMarket || model.includes("/"));
|
||||
|
||||
const { imageUrl } = extractImageInputs(body);
|
||||
let baseUrl = "";
|
||||
let payload: Record<string, unknown> = {};
|
||||
|
||||
if (isMarket) {
|
||||
if (isFluxKontext) {
|
||||
// Dedicated Flux Kontext API endpoint (not part of the Market catalog).
|
||||
baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/generate`;
|
||||
payload = {
|
||||
prompt,
|
||||
aspectRatio: mapImageSize(size),
|
||||
model: "flux-kontext-pro",
|
||||
...(imageUrl ? { inputImage: imageUrl } : {}),
|
||||
};
|
||||
} else if (isMarket) {
|
||||
// Unified Market API endpoint
|
||||
baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`;
|
||||
const input: Record<string, unknown> = {
|
||||
@@ -853,13 +870,18 @@ async function handleKieImageGeneration({
|
||||
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
||||
log.info(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (${isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."`
|
||||
`${provider}/${model} (${isFluxKontext ? "flux-kontext" : isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = isMarket ? "/api/v1/jobs/createTask" : new URL(baseUrl).pathname;
|
||||
const createBaseUrl = isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, "");
|
||||
const endpoint = isFluxKontext
|
||||
? "/api/v1/flux/kontext/generate"
|
||||
: isMarket
|
||||
? "/api/v1/jobs/createTask"
|
||||
: new URL(baseUrl).pathname;
|
||||
const createBaseUrl =
|
||||
isFluxKontext || isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, "");
|
||||
const createData = await kieExecutor.createTask({
|
||||
baseUrl: createBaseUrl,
|
||||
token,
|
||||
@@ -888,11 +910,13 @@ async function handleKieImageGeneration({
|
||||
}
|
||||
|
||||
// Use statusUrl from providerConfig if available, fallback to dynamic derivation
|
||||
const statusUrl = isMarket
|
||||
? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo`
|
||||
: providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo")
|
||||
? providerConfig.statusUrl
|
||||
: baseUrl.replace(/\/generate$/, "/record-info");
|
||||
const statusUrl = isFluxKontext
|
||||
? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/record-info`
|
||||
: isMarket
|
||||
? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo`
|
||||
: providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo")
|
||||
? providerConfig.statusUrl
|
||||
: baseUrl.replace(/\/generate$/, "/record-info");
|
||||
|
||||
const { data: recordData, state } = await kieExecutor.pollTask({
|
||||
statusUrl,
|
||||
|
||||
@@ -265,7 +265,15 @@ export function translateNonStreamingResponse(
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
if (message.content === undefined) {
|
||||
if (
|
||||
(!message.content ||
|
||||
(typeof message.content === "string" && message.content.trim().length === 0)) &&
|
||||
toolCalls.length === 0 &&
|
||||
replayableReasoningContent &&
|
||||
replayableReasoningContent.trim().length > 0
|
||||
) {
|
||||
message.content = replayableReasoningContent;
|
||||
} else if (message.content === undefined) {
|
||||
message.content = "";
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ import {
|
||||
clampMcpAccessibilityConfig,
|
||||
type McpAccessibilityConfig,
|
||||
} from "../services/compression/engines/mcpAccessibility/constants.ts";
|
||||
import { getDbInstance } from "../../src/lib/db/core.ts";
|
||||
import { getDbInstance, ensureDbInitialized } from "../../src/lib/db/core.ts";
|
||||
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
||||
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
@@ -1527,10 +1527,10 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
* Called when `omniroute --mcp` is used.
|
||||
*/
|
||||
export async function startMcpStdio(): Promise<void> {
|
||||
await ensureDbInitialized();
|
||||
// Stdout is reserved for JSON-RPC — bin/mcpStdioConsoleGuard.mjs is preloaded via
|
||||
// `node --import` (see bin/mcp-server.mjs) so console.log/warn already redirect to
|
||||
// stderr before this module's own imports evaluate (DB init happens as a side effect of
|
||||
// createMcpServer()'s tool registration, earlier than any code placed here could catch).
|
||||
// stderr before this module's own imports evaluate.
|
||||
const server = createMcpServer();
|
||||
const transport = new StdioServerTransport();
|
||||
const version = process.env.npm_package_version || "1.8.1";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
// Mock the DB so recommendStrategyOverride sees adaptiveVolumeRouting = true.
|
||||
// Without this the real getSettings() throws (no SQLite in test env), the
|
||||
// catch block fires, and the function returns noOverride before any rule runs.
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
vi.mock("@/lib/db/settings", () => ({
|
||||
getSettings: vi.fn().mockResolvedValue({ adaptiveVolumeRouting: true }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -249,6 +249,59 @@ export const OAUTH_INVALID_TOKEN_SIGNALS = [
|
||||
"invalid credentials",
|
||||
];
|
||||
|
||||
// A model that upstream has permanently retired — Gemini's deprecated-model 404
|
||||
// ("This model models/gemini-2.5-flash is no longer available to new users...")
|
||||
// and Fireworks/OpenAI-compatible "end of life" 410s ("has reached its end of
|
||||
// life ... and is no longer available") — will 404/410 on EVERY future request;
|
||||
// no cooldown short enough to retry soon is ever correct. Without this check
|
||||
// these fall through to the generic "all other errors" branch at the bottom of
|
||||
// checkFallbackError, which only applies a short (seconds-to-minutes) transient
|
||||
// cooldown, so combo/auto-routing keeps re-selecting the dead model roughly
|
||||
// every cooldown window, forever — wasted upstream calls that, at volume, look
|
||||
// like abusive traffic to the provider (observed: a Gemini free-tier key
|
||||
// retried `gemini-2.5-flash`/`gemini-2.5-flash-lite` every ~15-45 minutes for a
|
||||
// full day). Matched independent of MODEL_ACCESS_DENIED_PATTERNS below because
|
||||
// those only fire for status 400; this needs to catch the far more common
|
||||
// 404/410 status a retired model actually returns.
|
||||
export const MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS = [
|
||||
/\bno longer available\b/i,
|
||||
/\bno longer supported\b/i,
|
||||
/\bhas reached (?:its |the )?end.?of.?life\b/i,
|
||||
/\bmodel[\s\S]{0,40}?\b(?:deprecated|retired|discontinued|decommissioned)\b/i,
|
||||
/\b(?:deprecated|retired|discontinued|decommissioned)[\s\S]{0,40}?\bmodel\b/i,
|
||||
];
|
||||
|
||||
// A provider that has permanently retired its API base_url — the old endpoint
|
||||
// keeps returning 410/404 on EVERY future request until the connection's
|
||||
// base_url is updated by an operator; no cooldown short enough to retry soon
|
||||
// is ever correct. Without this check these fall through to the generic
|
||||
// "all other errors" branch, which only applies a short (seconds-to-minutes)
|
||||
// transient cooldown, so combo/auto-routing keeps re-selecting the dead
|
||||
// endpoint roughly every cooldown window, forever — wasted upstream calls
|
||||
// that, at volume, look like abusive traffic (observed: freeaiapikey's moved
|
||||
// endpoint retried every ~1 minute for a full day: "This API endpoint has
|
||||
// moved. Please update your base_url to https://api.freeaiapikey.com/v1 —
|
||||
// the old endpoint on freeaiapikey.com no longer works.").
|
||||
export const ENDPOINT_PERMANENTLY_MOVED_PATTERNS = [
|
||||
/\bendpoint has moved\b/i,
|
||||
/\bno longer works\b/i,
|
||||
/\bupdate your base.?url\b/i,
|
||||
];
|
||||
|
||||
// A billing/account suspension that requires manual operator action (unpaid
|
||||
// invoice, spending limit) — text varies per provider/account name, e.g.
|
||||
// Fireworks: "Account hummern is suspended, possibly due to reaching the
|
||||
// monthly spending limit or failure to pay past invoices." This does not
|
||||
// match ACCOUNT_DEACTIVATED_SIGNALS' fixed "your account has been suspended"
|
||||
// substring, and several providers surface it on a status (412) that
|
||||
// checkFallbackError does not otherwise classify — so it fell through to the
|
||||
// generic transient-error branch and got retried every few minutes, all day,
|
||||
// against an account that cannot succeed until billing is fixed.
|
||||
export const ACCOUNT_SUSPENDED_BILLING_PATTERNS = [
|
||||
/\bsuspended\b[\s\S]{0,120}?\b(?:spending limit|billing|invoice|payment)\b/i,
|
||||
/\b(?:spending limit|billing|invoice|payment)\b[\s\S]{0,120}?\bsuspended\b/i,
|
||||
];
|
||||
|
||||
// Context overflow patterns — the prompt exceeds the model's maximum context length.
|
||||
// Different providers phrase this differently. Used to decide whether a 400 error
|
||||
// should trigger combo fallback (a different model may have a larger context window).
|
||||
@@ -421,6 +474,34 @@ export function isCreditsExhausted(errorText: string): boolean {
|
||||
return CREDITS_EXHAUSTED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the response body indicates the requested model has been
|
||||
* permanently retired by the provider (see MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS).
|
||||
*/
|
||||
export function isModelPermanentlyUnavailable(errorText: string): boolean {
|
||||
const text = String(errorText || "");
|
||||
return MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS.some((p) => p.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if response body indicates the provider's API endpoint/base_url
|
||||
* has permanently moved (see ENDPOINT_PERMANENTLY_MOVED_PATTERNS).
|
||||
*/
|
||||
export function isEndpointPermanentlyMoved(errorText: string): boolean {
|
||||
const text = String(errorText || "");
|
||||
return ENDPOINT_PERMANENTLY_MOVED_PATTERNS.some((p) => p.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if response body indicates the account is suspended for a
|
||||
* billing reason (unpaid invoice, spending limit) — see
|
||||
* ACCOUNT_SUSPENDED_BILLING_PATTERNS.
|
||||
*/
|
||||
export function isAccountSuspendedForBilling(errorText: string): boolean {
|
||||
const text = String(errorText || "");
|
||||
return ACCOUNT_SUSPENDED_BILLING_PATTERNS.some((p) => p.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* T11: Returns true if response body indicates OAuth token is invalid/expired.
|
||||
* This is different from permanent account deactivation - token refresh can recover.
|
||||
@@ -1755,6 +1836,56 @@ export function checkFallbackError(
|
||||
};
|
||||
}
|
||||
|
||||
// A retired model (Gemini deprecated-model 404, Fireworks/etc. end-of-life 410)
|
||||
// will fail identically on every future request — lock it for a long, fixed
|
||||
// window instead of falling through to the generic transient-error branch's
|
||||
// short backoff, which would otherwise keep re-selecting a permanently dead
|
||||
// model roughly every cooldown window, all day, hammering the provider with
|
||||
// guaranteed-to-fail requests (see MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS).
|
||||
// `quotaResetHintMs` flows into combo.ts's per-request model-lockout as an
|
||||
// upstream-verified reset, so it is honored in full and not clamped to the
|
||||
// normal ~20min model-lockout ceiling.
|
||||
if (
|
||||
(status === HTTP_STATUS.NOT_FOUND || status === HTTP_STATUS.GONE) &&
|
||||
isModelPermanentlyUnavailable(errorStr)
|
||||
) {
|
||||
const cooldownMs = 24 * 60 * 60 * 1000; // 24h
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs,
|
||||
reason: "not_found",
|
||||
quotaResetHintMs: cooldownMs,
|
||||
};
|
||||
}
|
||||
|
||||
// The provider's API endpoint/base_url has permanently moved — every future
|
||||
// request against the stale base_url fails identically, so lock it for a
|
||||
// long, fixed window instead of the generic transient-error branch's short
|
||||
// backoff (see ENDPOINT_PERMANENTLY_MOVED_PATTERNS).
|
||||
if (isEndpointPermanentlyMoved(errorStr)) {
|
||||
const cooldownMs = 24 * 60 * 60 * 1000; // 24h
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs,
|
||||
reason: "not_found",
|
||||
quotaResetHintMs: cooldownMs,
|
||||
};
|
||||
}
|
||||
|
||||
// The account is suspended for a billing reason (unpaid invoice, spending
|
||||
// limit) that varies per provider/account name and can arrive on a status
|
||||
// checkFallbackError does not otherwise classify (e.g. Fireworks 412) —
|
||||
// treat it like a credits-exhausted account so it stops being retried
|
||||
// every few minutes until billing is fixed (see ACCOUNT_SUSPENDED_BILLING_PATTERNS).
|
||||
if (isAccountSuspendedForBilling(errorStr)) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown
|
||||
reason: RateLimitReason.QUOTA_EXHAUSTED,
|
||||
creditsExhausted: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Gemini-specific check — MUST run before isCreditsExhausted/
|
||||
// isDailyQuotaExhausted/the generic text classifier below: Gemini's free-
|
||||
// tier 429 boilerplate literally says "You exceeded your current quota,
|
||||
|
||||
51
open-sse/services/autoCombo/modelExposureFilter.ts
Normal file
51
open-sse/services/autoCombo/modelExposureFilter.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* #11481 — mandatory mirror of the `/v1/models` catalog's explicit model
|
||||
* exposure allow/deny list into every `auto/*` combo candidate pool.
|
||||
*
|
||||
* `src/app/api/v1/models/catalog.ts` hides denied models (and, when an
|
||||
* allowlist is set, restricts to it) from the advertised catalog, but
|
||||
* `auto/*` combos build their candidate pool independently — exactly the
|
||||
* trap #6512 already fixed once for `hidePaidModels` (PR #6495 hid paid
|
||||
* models from `GET /v1/models`, but `auto/*` could still route to one,
|
||||
* a 402/403 at request time). This applies the SAME predicate `catalog.ts`
|
||||
* uses to every virtual auto-combo candidate pool.
|
||||
*
|
||||
* Kept as a pure, dependency-light function so the filter is unit-testable
|
||||
* in isolation without seeding the DB-backed virtual factory (mirrors
|
||||
* `paidModelFilter.ts`).
|
||||
*/
|
||||
import {
|
||||
isModelExposureAllowed,
|
||||
type ModelExposureListSettings,
|
||||
} from "@/shared/utils/modelExposureList";
|
||||
|
||||
interface ExposureFilterCandidate {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function hasAnyExposureListEntries(settings: ModelExposureListSettings | null | undefined): boolean {
|
||||
return (
|
||||
(Array.isArray(settings?.modelVisibilityDenylist) &&
|
||||
settings.modelVisibilityDenylist.length > 0) ||
|
||||
(Array.isArray(settings?.modelVisibilityAllowlist) &&
|
||||
settings.modelVisibilityAllowlist.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the candidate pool filtered by the operator's model exposure
|
||||
* allow/deny lists; otherwise return the pool unchanged (identity — the
|
||||
* default, opt-in-off path when both lists are empty). If the allowlist
|
||||
* empties the pool, the caller's existing graceful empty-pool path handles
|
||||
* it, consistent with the opt-in intent.
|
||||
*/
|
||||
export function filterModelExposureCandidates<T extends ExposureFilterCandidate>(
|
||||
pool: T[],
|
||||
settings: ModelExposureListSettings | null | undefined
|
||||
): T[] {
|
||||
if (!hasAnyExposureListEntries(settings)) return pool;
|
||||
return pool.filter((candidate) =>
|
||||
isModelExposureAllowed(candidate.provider, candidate.model, settings)
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,15 @@ export interface ConnectionResilienceView {
|
||||
testStatus?: string | null;
|
||||
}
|
||||
|
||||
/** Index connection resilience views by id, for the O(1) lookups this filter needs. */
|
||||
export function buildConnectionResilienceMap(
|
||||
connections: Iterable<ConnectionResilienceView>
|
||||
): Map<string, ConnectionResilienceView> {
|
||||
const byId = new Map<string, ConnectionResilienceView>();
|
||||
for (const conn of connections) byId.set(conn.id, conn);
|
||||
return byId;
|
||||
}
|
||||
|
||||
function isConnectionResilienceBlocked(connection: ConnectionResilienceView): boolean {
|
||||
if (isAccountUnavailable(connection.rateLimitedUntil)) return true;
|
||||
const status = connection.testStatus;
|
||||
@@ -54,12 +63,19 @@ function isConnectionEligibleForModel(
|
||||
* Remove auto-combo candidates whose provider/model pair is model-locked, and
|
||||
* trim credentialed logical candidates whose allowed connections are all blocked.
|
||||
* Returns the input reference when nothing changed.
|
||||
*
|
||||
* `skip` (#9133) lets the read-only candidate inspector
|
||||
* (`open-sse/handlers/autoComboCandidates.ts`) opt out entirely: it needs the
|
||||
* FULL pool so it can decorate blocked candidates as `reachable:false`
|
||||
* instead of dropping them before they are ever surfaced. Routing callers
|
||||
* never pass it, so dispatch behavior is unchanged.
|
||||
*/
|
||||
export function filterResilienceBlockedCandidates<T extends ResilienceFilterCandidate>(
|
||||
pool: T[],
|
||||
connectionsById: Map<string, ConnectionResilienceView>
|
||||
connectionsById: Map<string, ConnectionResilienceView>,
|
||||
skip = false
|
||||
): T[] {
|
||||
if (!Array.isArray(pool) || pool.length === 0) return pool;
|
||||
if (skip || !Array.isArray(pool) || pool.length === 0) return pool;
|
||||
|
||||
let changed = false;
|
||||
const filtered = pool.flatMap((candidate) => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
|
||||
import { getHiddenModelsByProvider } from "@/models";
|
||||
import { getSyncedAvailableModelsByConnection, getCustomModels } from "@/lib/db/models";
|
||||
import { filterPaidOnlyCandidates } from "./paidModelFilter";
|
||||
import { filterModelExposureCandidates } from "./modelExposureFilter";
|
||||
import {
|
||||
filterSubscriptionOnlyCandidates,
|
||||
orderPoolByRung,
|
||||
@@ -44,8 +45,8 @@ import { filterExcludedCandidates } from "./candidateOverrides";
|
||||
import { getExcludedConnectionIds } from "@/lib/db/autoCandidateOverrides";
|
||||
import {
|
||||
filterResilienceBlockedCandidates,
|
||||
buildConnectionResilienceMap,
|
||||
SYNTHETIC_NOAUTH_CONNECTION_ID as RESILIENCE_NOAUTH_CONNECTION_ID,
|
||||
type ConnectionResilienceView,
|
||||
} from "./resilienceCandidateFilter";
|
||||
import type { ChaosTuning } from "./chaosEngine";
|
||||
|
||||
@@ -584,7 +585,8 @@ export async function prepareVirtualAutoComboInputs(
|
||||
options: {
|
||||
includeResolvedCapabilities?: boolean;
|
||||
resolutionSnapshot?: ModelCapabilityResolutionSnapshot;
|
||||
} = {}
|
||||
} = {},
|
||||
skip = false // #9133 — inspector opt-out, see filterResilienceBlockedCandidates
|
||||
): Promise<PreparedVirtualAutoComboInputs> {
|
||||
const [rawConnections, rawDisabledNoAuthConnections, settings] = await Promise.all([
|
||||
getCachedProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
|
||||
@@ -708,10 +710,10 @@ export async function prepareVirtualAutoComboInputs(
|
||||
|
||||
// #7623: honor existing model lockouts + connection cooldown/terminal state so
|
||||
// auto/* never advertises models the dispatch path would immediately skip.
|
||||
const connectionsById = new Map<string, ConnectionResilienceView>();
|
||||
for (const conn of [...runtimeConnections, ...disabledNoAuthConnections]) {
|
||||
connectionsById.set(conn.id, conn);
|
||||
}
|
||||
const connectionsById = buildConnectionResilienceMap([
|
||||
...runtimeConnections,
|
||||
...disabledNoAuthConnections,
|
||||
]);
|
||||
|
||||
const connectedProviders = new Set(validConnections.map((conn) => conn.provider));
|
||||
const buildPreparedPool = (bypassNoAuthAllowlist: boolean) => {
|
||||
@@ -727,7 +729,7 @@ export async function prepareVirtualAutoComboInputs(
|
||||
),
|
||||
];
|
||||
|
||||
const resilienceFilteredPool = filterResilienceBlockedCandidates(pool, connectionsById);
|
||||
const resilienceFilteredPool = filterResilienceBlockedCandidates(pool, connectionsById, skip);
|
||||
if (resilienceFilteredPool !== pool) pool = resilienceFilteredPool;
|
||||
|
||||
// #6512 (follow-up to #6328/#6495): when the operator opts into `hidePaidModels`,
|
||||
@@ -735,6 +737,11 @@ export async function prepareVirtualAutoComboInputs(
|
||||
const paidFilteredPool = filterPaidOnlyCandidates(pool, settings.hidePaidModels === true);
|
||||
if (paidFilteredPool !== pool) pool = paidFilteredPool;
|
||||
|
||||
// #11481: mandatory mirror of the /v1/models exposure allow/deny list —
|
||||
// see src/shared/utils/modelExposureList.ts for why (#6512's lesson).
|
||||
const exposureFilteredPool = filterModelExposureCandidates(pool, settings);
|
||||
if (exposureFilteredPool !== pool) pool = exposureFilteredPool;
|
||||
|
||||
// STRICT_ZERO_COST: opt-in, off by default (`settings.freeAccessPolicy !== "strict"`
|
||||
// leaves `pool` byte-identical, same contract as `hidePaidModels`). See
|
||||
// `strictZeroCostFilter.ts` for why this is stricter than `hidePaidModels` alone —
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { BatchItemCheckpoint, BatchRecord } from "@/lib/localDb";
|
||||
import {
|
||||
type BatchItemCheckpoint,
|
||||
type BatchRecord,
|
||||
countBatchItemCheckpoints,
|
||||
createFile,
|
||||
deleteFile,
|
||||
ensureBatchItemCheckpoints,
|
||||
getApiKeyById,
|
||||
getBatch,
|
||||
getFileContent,
|
||||
getPendingBatches,
|
||||
getTerminalBatches,
|
||||
listBatchItemCheckpoints,
|
||||
listFiles,
|
||||
markBatchItemError,
|
||||
markBatchItemProcessing,
|
||||
markBatchItemResult,
|
||||
updateBatch,
|
||||
} from "@/lib/localDb";
|
||||
} from "@/lib/db/batches";
|
||||
import { createFile, deleteFile, getFileContent, listFiles } from "@/lib/db/files";
|
||||
import { getApiKeyById } from "@/lib/db/apiKeys";
|
||||
import { dispatch } from "@/lib/batches/dispatch";
|
||||
import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints";
|
||||
import { DEFAULT_BATCH_EXPIRATION_SECONDS } from "@/shared/constants/batch";
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
hasPerModelQuota,
|
||||
isAccountSemaphoreFull,
|
||||
isModelLocked,
|
||||
lockModelIfPerModelQuota,
|
||||
MODEL_ACCESS_DENIED_PATTERNS,
|
||||
recordModelLockoutFailure,
|
||||
recordProviderFailure,
|
||||
@@ -356,6 +357,33 @@ export function releaseStickyPinOnFailure(
|
||||
clearStickyBinding(messageHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted LKGP pins when a target fails or is skipped due to
|
||||
* exhaustion, cooldown, or unavailability (#11911 #919).
|
||||
*/
|
||||
export function clearStaleLKGP(
|
||||
comboName: string,
|
||||
executionKey?: string | null,
|
||||
comboId?: string | null,
|
||||
log?: { warn?: (tag: string, msg: string, data?: unknown) => void } | null,
|
||||
tag: string = "COMBO"
|
||||
): void {
|
||||
void (async () => {
|
||||
try {
|
||||
const { clearLKGP } = await import("@/lib/db/settings");
|
||||
const promises: Promise<void>[] = [clearLKGP(comboName, comboId || comboName)];
|
||||
if (executionKey) {
|
||||
promises.push(clearLKGP(comboName, executionKey));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
} catch (err) {
|
||||
log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", {
|
||||
err,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL_P95_MS: Record<string, number> = {
|
||||
"grok-4-fast-non-reasoning": 1143,
|
||||
"grok-4-1-fast-non-reasoning": 1244,
|
||||
@@ -399,7 +427,7 @@ export async function buildAutoCandidates(
|
||||
// apply, so auto-routing behavior is unchanged.
|
||||
const quotaCutoffEnabled =
|
||||
(resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight?.enabled === true;
|
||||
const { getPricingForModel } = await import("../../src/lib/localDb");
|
||||
const { getPricingForModel } = await import("@/lib/db/settings");
|
||||
const quotaPromises = new Map<string, Promise<unknown>>();
|
||||
let historicalLatencyStats: Record<string, HistoricalLatencyStatsEntry> = {};
|
||||
try {
|
||||
@@ -1203,6 +1231,7 @@ async function handleComboChatInner({
|
||||
strategy === "priority" && target.fallbackOnlyOnQuotaExhaustion === true;
|
||||
const stopProtectedPriorityTarget = (message: string) => {
|
||||
observeFailure(false, target.executionKey);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
return protectedPriorityTarget
|
||||
? { ok: false, response: errorResponse(503, message) }
|
||||
: null;
|
||||
@@ -1264,6 +1293,7 @@ async function handleComboChatInner({
|
||||
);
|
||||
if (persistedSkip) {
|
||||
log.info("COMBO", persistedSkip);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
if (i > 0) fallbackCount++;
|
||||
return null;
|
||||
}
|
||||
@@ -1322,6 +1352,7 @@ async function handleComboChatInner({
|
||||
"COMBO",
|
||||
`Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})`
|
||||
);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
recordComboDecision(traceInvocationId, {
|
||||
step: target.executionKey,
|
||||
target: modelStr,
|
||||
@@ -1359,6 +1390,7 @@ async function handleComboChatInner({
|
||||
"COMBO",
|
||||
`Skipping ${modelStr} — quota budget ${quotaDecision.reason} (remaining ${quotaDecision.tokensRemaining ?? 0}, cost ${quotaDecision.estimatedCost ?? 0})`
|
||||
);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
if (i > 0) fallbackCount++;
|
||||
return null;
|
||||
}
|
||||
@@ -1376,6 +1408,7 @@ async function handleComboChatInner({
|
||||
"COMBO",
|
||||
`Skipping ${modelStr} — no credentials available or model excluded`
|
||||
);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
recordComboDecision(traceInvocationId, {
|
||||
step: target.executionKey,
|
||||
target: modelStr,
|
||||
@@ -1607,7 +1640,8 @@ async function handleComboChatInner({
|
||||
lastModel,
|
||||
modelStr,
|
||||
`Model routing: ${lastModel} → ${modelStr}`,
|
||||
existingHandoff
|
||||
existingHandoff,
|
||||
universalHandoffConfig.relayMode
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1936,7 +1970,7 @@ async function handleComboChatInner({
|
||||
const connId = effectiveConnectionId || undefined;
|
||||
void (async () => {
|
||||
try {
|
||||
const { setLKGP } = await import("../../src/lib/localDb");
|
||||
const { setLKGP } = await import("@/lib/db/settings");
|
||||
await Promise.all([
|
||||
setLKGP(combo.name, target.executionKey, provider, connId),
|
||||
setLKGP(combo.name, combo.id || combo.name, provider, connId),
|
||||
@@ -2181,6 +2215,13 @@ async function handleComboChatInner({
|
||||
// exhausted — if it's the currently sticky-bound one, release the pin now
|
||||
// rather than waiting for the next turn's lazy headroom/status recheck.
|
||||
releaseStickyPinOnFailure(_sticky.messageHash, targetWithConnection.connectionId);
|
||||
if (
|
||||
providerExhausted ||
|
||||
exhaustedConnections.has(`${provider}:${targetWithConnection.connectionId}`) ||
|
||||
(provider && exhaustedProviders.has(provider))
|
||||
) {
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
}
|
||||
|
||||
// #2101: Prevent infinite fallback loops with 400 Bad Request errors that are genuinely
|
||||
// body-specific (malformed JSON, bad format, missing required fields).
|
||||
@@ -2227,6 +2268,7 @@ async function handleComboChatInner({
|
||||
lastStatus = result.status;
|
||||
if (i > 0) fallbackCount++;
|
||||
log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
// #4279: surface the 400 via the {ok,response} contract so the OUTER
|
||||
// target loop resolves the combo and stops. A bare `break` here only
|
||||
// exits the inner retry loop; executeTarget then returns null, which
|
||||
@@ -2236,6 +2278,26 @@ async function handleComboChatInner({
|
||||
return { ok: false, response: result };
|
||||
}
|
||||
|
||||
// A model-scoped 400 ("The requested model is not supported" / "not
|
||||
// available for integrator") is permanent for THIS connection — the
|
||||
// account/integration will not gain support for the model mid-session.
|
||||
// Combo still advances to the next target immediately (unchanged,
|
||||
// preserves #5249's cross-provider fallback), but without a lockout
|
||||
// here the SAME dead model gets retried on every future, separate
|
||||
// request forever (observed: every auto-combo request wasted several
|
||||
// upstream 400s on the same GitHub models, all day). isModelLocked()
|
||||
// is checked before dispatch (see the pre-check above this loop), so
|
||||
// this lockout is honored on the next request.
|
||||
if (result.status === 400 && isModelScoped400(errorText) && provider && rawModel) {
|
||||
lockModelIfPerModelQuota(
|
||||
provider,
|
||||
targetWithConnection.connectionId || "",
|
||||
rawModel,
|
||||
"model_capacity",
|
||||
60 * 60 * 1000 // 1h
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger shared provider circuit breaker for 5xx errors and connection failures. If the
|
||||
// next target is on the same provider, don't mark it failed (a different model may still
|
||||
// succeed) — #8376: EXCEPT a proxy-unreachable failure, which poisons every model alike.
|
||||
@@ -2395,19 +2457,7 @@ async function handleComboChatInner({
|
||||
// *next* separate request. Circuit breaker / model lockout deliberately
|
||||
// don't react to request-scoped failure classes (see scopedFailure below),
|
||||
// so nothing else clears this stale pin.
|
||||
void (async () => {
|
||||
try {
|
||||
const { clearLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
clearLKGP(combo.name, target.executionKey),
|
||||
clearLKGP(combo.name, combo.id || combo.name),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to clear Last Known Good Provider. This is non-fatal.", {
|
||||
err,
|
||||
});
|
||||
}
|
||||
})();
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
|
||||
recordedAttempts++;
|
||||
lastError = errorText || String(result.status);
|
||||
comboErrors.push({
|
||||
@@ -3228,6 +3278,7 @@ async function handleRoundRobinCombo({
|
||||
"COMBO-RR",
|
||||
`Skipping ${modelStr} — no credentials available or model excluded`
|
||||
);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
|
||||
if (offset > 0) fallbackCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -3243,6 +3294,7 @@ async function handleRoundRobinCombo({
|
||||
)
|
||||
) {
|
||||
log.info("COMBO-RR", `Skipping ${modelStr} — provider ${provider} in global cooldown`);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
|
||||
if (offset > 0) fallbackCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -3255,6 +3307,7 @@ async function handleRoundRobinCombo({
|
||||
);
|
||||
if (exhaustedSkip) {
|
||||
log.info("COMBO-RR", exhaustedSkip);
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
|
||||
if (offset > 0) fallbackCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -3302,24 +3355,20 @@ async function handleRoundRobinCombo({
|
||||
"COMBO-RR",
|
||||
`Maximum combo attempts (${maxGlobalAttempts}) exceeded. Terminating loop to prevent runaway requests.`
|
||||
);
|
||||
return errorResponseWithComboDiagnostics(
|
||||
503,
|
||||
"Maximum combo retry limit reached",
|
||||
{
|
||||
poolSize: modelCount,
|
||||
attempted: globalAttempts,
|
||||
excluded: [
|
||||
...[...exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })),
|
||||
...[...exhaustedConnections].map((c) => formatExhaustedConnectionKey(String(c))),
|
||||
],
|
||||
attemptOrder: rrOutcomes.map((o) => ({
|
||||
provider: o.model.split("/")[0] || "unknown",
|
||||
model: o.model,
|
||||
})),
|
||||
terminalReason: "max_attempts_exceeded",
|
||||
recovery: buildRecoveryHint("max_attempts_exceeded"),
|
||||
}
|
||||
);
|
||||
return errorResponseWithComboDiagnostics(503, "Maximum combo retry limit reached", {
|
||||
poolSize: modelCount,
|
||||
attempted: globalAttempts,
|
||||
excluded: [
|
||||
...[...exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })),
|
||||
...[...exhaustedConnections].map((c) => formatExhaustedConnectionKey(String(c))),
|
||||
],
|
||||
attemptOrder: rrOutcomes.map((o) => ({
|
||||
provider: o.model.split("/")[0] || "unknown",
|
||||
model: o.model,
|
||||
})),
|
||||
terminalReason: "max_attempts_exceeded",
|
||||
recovery: buildRecoveryHint("max_attempts_exceeded"),
|
||||
});
|
||||
}
|
||||
if (retry > 0) {
|
||||
log.info(
|
||||
@@ -3518,7 +3567,7 @@ async function handleRoundRobinCombo({
|
||||
const connId = effectiveConnectionId || undefined;
|
||||
void (async () => {
|
||||
try {
|
||||
const { setLKGP } = await import("../../src/lib/localDb");
|
||||
const { setLKGP } = await import("@/lib/db/settings");
|
||||
await Promise.all([
|
||||
setLKGP(combo.name, target.executionKey, provider, connId),
|
||||
setLKGP(combo.name, combo.id || combo.name, provider, connId),
|
||||
@@ -3694,6 +3743,13 @@ async function handleRoundRobinCombo({
|
||||
_rrSessionSticky.messageHash,
|
||||
targetWithConnection.connectionId
|
||||
);
|
||||
if (
|
||||
providerExhausted ||
|
||||
exhaustedConnections.has(`${provider}:${targetWithConnection.connectionId}`) ||
|
||||
(provider && exhaustedProviders.has(provider))
|
||||
) {
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
|
||||
}
|
||||
|
||||
// Transient errors → mark in semaphore so round-robin stops stampeding this target.
|
||||
if (
|
||||
@@ -3749,19 +3805,7 @@ async function handleRoundRobinCombo({
|
||||
// LKGP (#919) mirror of handleComboChat's failure-path clear above — see
|
||||
// that comment for why this must happen (nothing else clears a pin left
|
||||
// by a request-scoped failure class like a stream-readiness timeout).
|
||||
void (async () => {
|
||||
try {
|
||||
const { clearLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
clearLKGP(combo.name, target.executionKey),
|
||||
clearLKGP(combo.name, combo.id || combo.name),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn("COMBO-RR", "Failed to clear Last Known Good Provider. This is non-fatal.", {
|
||||
err,
|
||||
});
|
||||
}
|
||||
})();
|
||||
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
|
||||
recordedAttempts++;
|
||||
lastError = errorText || String(result.status);
|
||||
lastStatus = result.status;
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function applyStrategyOrdering(
|
||||
|
||||
if (strategy === "lkgp") {
|
||||
try {
|
||||
const { getLKGP } = await import("../../../src/lib/localDb");
|
||||
const { getLKGP } = await import("@/lib/db/settings");
|
||||
const lkgpProvider = await getLKGP(combo.name, combo.id || combo.name);
|
||||
|
||||
if (lkgpProvider) {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* shrinking (Quality Gate / #3501).
|
||||
*/
|
||||
|
||||
import { getCachedProviderConnectionById } from "@/lib/localDb";
|
||||
import { getCachedProviderConnectionById } from "@/lib/db/readCache";
|
||||
import { effectiveMaxConcurrency } from "./comboPredicates.ts";
|
||||
import type { ResolvedComboTarget } from "./types.ts";
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type PreflightQuotaThresholds,
|
||||
type QuotaInfo,
|
||||
} from "../quotaPreflight.ts";
|
||||
import { getCachedProviderConnectionById } from "@/lib/localDb";
|
||||
import { getCachedProviderConnectionById } from "@/lib/db/readCache";
|
||||
import {
|
||||
resolveResilienceSettings,
|
||||
type ResilienceSettings,
|
||||
@@ -109,8 +109,7 @@ export async function resolveQuotaExhaustionCutoffForTarget(
|
||||
let connection: Record<string, unknown> | undefined;
|
||||
try {
|
||||
connection = (await getCachedProviderConnectionById(connectionId)) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
} catch {
|
||||
connection = undefined;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export async function resolveAutoStrategyOrder(
|
||||
|
||||
let lastKnownGoodProvider: string | undefined;
|
||||
try {
|
||||
const { getLKGP } = await import("../../../src/lib/localDb");
|
||||
const { getLKGP } = await import("@/lib/db/settings");
|
||||
const lkgp = await getLKGP(combo.name, combo.id || combo.name);
|
||||
if (lkgp) lastKnownGoodProvider = lkgp.provider;
|
||||
} catch (err) {
|
||||
|
||||
@@ -65,7 +65,7 @@ export function orderTargetsForWeightedFallback<T extends { executionKey: string
|
||||
*/
|
||||
export async function sortModelsByCost(models: string[]): Promise<string[]> {
|
||||
try {
|
||||
const { getPricingForModel } = await import("../../../src/lib/localDb");
|
||||
const { getPricingForModel } = await import("@/lib/db/settings");
|
||||
const withCost = await Promise.all(
|
||||
models.map(async (modelStr) => {
|
||||
const parsed = parseModel(modelStr);
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface ContextRelayConfig {
|
||||
handoffThreshold?: number;
|
||||
handoffProviders?: string[];
|
||||
maxMessagesForSummary?: number;
|
||||
relayMode?: "schema-locked" | "standard";
|
||||
}
|
||||
|
||||
export interface UniversalHandoffConfig {
|
||||
@@ -65,6 +66,7 @@ export interface UniversalHandoffConfig {
|
||||
ttlMinutes: number;
|
||||
/** Preserve existing system prompt when injecting handoff */
|
||||
preserveSystemPrompt: boolean;
|
||||
relayMode?: "schema-locked" | "standard";
|
||||
}
|
||||
|
||||
export const DEFAULT_UNIVERSAL_HANDOFF_CONFIG: UniversalHandoffConfig = {
|
||||
@@ -155,6 +157,8 @@ export function resolveUniversalHandoffConfig(
|
||||
"preserveSystemPrompt",
|
||||
DEFAULT_UNIVERSAL_HANDOFF_CONFIG.preserveSystemPrompt
|
||||
),
|
||||
relayMode:
|
||||
getString("relayMode", "standard") === "schema-locked" ? "schema-locked" : "standard",
|
||||
};
|
||||
}
|
||||
export interface ParsedHandoffContent {
|
||||
@@ -192,6 +196,7 @@ export function resolveContextRelayConfig(
|
||||
Number.isFinite(rawMaxMessages) && rawMaxMessages >= 5 && rawMaxMessages <= 100
|
||||
? Math.round(rawMaxMessages)
|
||||
: DEFAULT_MAX_MESSAGES_FOR_SUMMARY,
|
||||
relayMode: config?.relayMode === "schema-locked" ? "schema-locked" : "standard",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,7 +237,8 @@ function formatMessagesForPrompt(messages: MessageLike[]): string {
|
||||
|
||||
export function selectMessagesForSummary(
|
||||
messages: MessageLike[],
|
||||
maxMessages: number
|
||||
maxMessages: number,
|
||||
relayMode?: "schema-locked" | "standard"
|
||||
): MessageLike[] {
|
||||
const validMessages = messages.filter((m) => m && typeof m === "object");
|
||||
const system = validMessages.filter(
|
||||
@@ -242,15 +248,23 @@ export function selectMessagesForSummary(
|
||||
(m) => typeof m.role !== "string" || (m.role !== "system" && m.role !== "developer")
|
||||
);
|
||||
|
||||
const recentMessages = [...system, ...nonSystem.slice(-maxMessages)];
|
||||
const recentMessages =
|
||||
relayMode === "schema-locked"
|
||||
? [...nonSystem.slice(-maxMessages)]
|
||||
: [...system, ...nonSystem.slice(-maxMessages)];
|
||||
let working = [...recentMessages];
|
||||
|
||||
while (working.length > system.length + 1) {
|
||||
const minWorkingLength = relayMode === "schema-locked" ? 1 : system.length + 1;
|
||||
|
||||
while (working.length > minWorkingLength) {
|
||||
const history = formatMessagesForPrompt(working);
|
||||
if (estimateTokens(history) <= MAX_HISTORY_TOKENS_FOR_SUMMARY) {
|
||||
return working;
|
||||
}
|
||||
working = [...system, ...working.slice(system.length + 1)];
|
||||
working =
|
||||
relayMode === "schema-locked"
|
||||
? working.slice(1)
|
||||
: [...system, ...working.slice(system.length + 1)];
|
||||
}
|
||||
|
||||
const fallbackHistory = formatMessagesForPrompt(working);
|
||||
@@ -258,7 +272,7 @@ export function selectMessagesForSummary(
|
||||
// If there are system messages, return them so the caller can still produce context.
|
||||
// If there are no system messages (system=[]), fall back to the single most-recent
|
||||
// non-system message rather than returning [] which would silently drop the handoff.
|
||||
if (system.length > 0) {
|
||||
if (relayMode !== "schema-locked" && system.length > 0) {
|
||||
return system;
|
||||
}
|
||||
const lastNonSystem = nonSystem[nonSystem.length - 1];
|
||||
@@ -386,7 +400,8 @@ async function generateHandoffAsync(options: {
|
||||
const summaryModel = relayConfig.handoffModel || options.model;
|
||||
const selectedMessages = selectMessagesForSummary(
|
||||
Array.isArray(options.messages) ? options.messages : [],
|
||||
relayConfig.maxMessagesForSummary
|
||||
relayConfig.maxMessagesForSummary,
|
||||
relayConfig.relayMode
|
||||
);
|
||||
const historyText = formatMessagesForPrompt(selectedMessages);
|
||||
if (!historyText) return;
|
||||
@@ -499,7 +514,8 @@ The context above contains a concise summary of the prior work. Continue seamles
|
||||
|
||||
export function injectHandoffIntoBody(
|
||||
body: Record<string, unknown>,
|
||||
payload: HandoffPayload
|
||||
payload: HandoffPayload,
|
||||
_relayMode?: "schema-locked" | "standard"
|
||||
): Record<string, unknown> {
|
||||
const handoffContent = buildHandoffSystemMessage(payload);
|
||||
const isResponsesRequest =
|
||||
@@ -680,12 +696,14 @@ async function generateUniversalHandoffAsync(options: {
|
||||
handoffModel: string;
|
||||
ttlMs: number;
|
||||
maxMessages: number;
|
||||
relayMode?: "schema-locked" | "standard";
|
||||
providerAllowlist: string[];
|
||||
handleSingleModel: (body: Record<string, unknown>, modelStr: string) => Promise<Response>;
|
||||
}): Promise<UniversalHandoffOutcome> {
|
||||
const selectedMessages = selectMessagesForSummary(
|
||||
Array.isArray(options.messages) ? options.messages : [],
|
||||
options.maxMessages
|
||||
options.maxMessages,
|
||||
options.relayMode
|
||||
);
|
||||
const historyText = formatMessagesForPrompt(selectedMessages);
|
||||
if (!historyText) return "unavailable";
|
||||
@@ -789,6 +807,7 @@ export function maybeGenerateUniversalHandoff(options: {
|
||||
handoffModel: options.universalConfig.handoffModel || options.currModel,
|
||||
ttlMs,
|
||||
maxMessages: options.universalConfig.maxMessagesForSummary,
|
||||
relayMode: options.universalConfig.relayMode,
|
||||
providerAllowlist: options.universalConfig.providerAllowlist,
|
||||
handleSingleModel: options.handleSingleModel,
|
||||
})
|
||||
@@ -812,7 +831,8 @@ export function injectUniversalHandoffBody(
|
||||
prevModel: string,
|
||||
currModel: string,
|
||||
reason: string,
|
||||
existingPayload?: HandoffPayload | null
|
||||
existingPayload?: HandoffPayload | null,
|
||||
_relayMode?: "schema-locked" | "standard"
|
||||
): Record<string, unknown> {
|
||||
const handoffContent = buildUniversalHandoffSystemMessage(
|
||||
prevModel,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* - Provider-specific rules: "openai/gpt-4"
|
||||
*/
|
||||
|
||||
import { checkKeyModelAccess, getKeyGroupsForApiKey } from "@/lib/localDb";
|
||||
import { checkKeyModelAccess, getKeyGroupsForApiKey } from "@/lib/db/apiKeyGroups";
|
||||
|
||||
export interface KeyGroupAuthResult {
|
||||
/** Whether the request is authorized */
|
||||
|
||||
@@ -332,7 +332,7 @@ function getProviderIdFromConnection(connection: unknown) {
|
||||
|
||||
async function getActiveProviderSet() {
|
||||
try {
|
||||
const { getCachedProviderConnections } = await import("@/lib/localDb");
|
||||
const { getCachedProviderConnections } = await import("@/lib/db/readCache");
|
||||
const conns = (await getCachedProviderConnections()) as unknown[];
|
||||
const providers = conns
|
||||
.map(getProviderIdFromConnection)
|
||||
@@ -345,7 +345,7 @@ async function getActiveProviderSet() {
|
||||
|
||||
async function getActiveSyncedProvidersForModel(modelId: string) {
|
||||
try {
|
||||
const { getActiveProvidersWithSyncedModel } = await import("@/lib/localDb");
|
||||
const { getActiveProvidersWithSyncedModel } = await import("@/lib/db/models");
|
||||
const providers = await getActiveProvidersWithSyncedModel(modelId);
|
||||
return providers
|
||||
.map(resolveProviderAlias)
|
||||
@@ -403,7 +403,7 @@ function isTruthyEnv(value: string | undefined) {
|
||||
|
||||
async function getPreferClaudeCodeForUnprefixedClaudeModels() {
|
||||
try {
|
||||
const { getCachedSettings } = await import("@/lib/localDb");
|
||||
const { getCachedSettings } = await import("@/lib/db/readCache");
|
||||
const settings = (await getCachedSettings()) as Record<string, unknown>;
|
||||
if (typeof settings.preferClaudeCodeForUnprefixedClaudeModels === "boolean") {
|
||||
return settings.preferClaudeCodeForUnprefixedClaudeModels;
|
||||
|
||||
@@ -223,7 +223,7 @@ export function clearPayloadRulesConfigOverride() {
|
||||
// silently reverting to the (usually empty) file config.
|
||||
async function loadPayloadRulesFromSettings(): Promise<PayloadRulesConfig | null> {
|
||||
try {
|
||||
const { getCachedSettings } = await import("@/lib/localDb");
|
||||
const { getCachedSettings } = await import("@/lib/db/readCache");
|
||||
const settings = (await getCachedSettings()) as { payloadRules?: unknown };
|
||||
const raw = settings?.payloadRules;
|
||||
if (raw === null || raw === undefined) return null;
|
||||
|
||||
@@ -338,7 +338,8 @@ export async function initializeRateLimits() {
|
||||
applyBottleneckHeartbeatPatch();
|
||||
|
||||
try {
|
||||
const { getCachedProviderConnections, getSettings } = await import("@/lib/localDb");
|
||||
const { getCachedProviderConnections } = await import("@/lib/db/readCache");
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const [connections, settings] = await Promise.all([
|
||||
getCachedProviderConnections(),
|
||||
getSettings(),
|
||||
@@ -385,7 +386,7 @@ export async function applyRequestQueueSettings(nextSettings: RequestQueueSettin
|
||||
currentRequestQueueSettings = { ...nextSettings };
|
||||
// Global policy changes invalidate snapshots from the previous generation.
|
||||
preservedReplacementSettings.clear();
|
||||
const { getCachedProviderConnections } = await import("@/lib/localDb");
|
||||
const { getCachedProviderConnections } = await import("@/lib/db/readCache");
|
||||
const connections = await getCachedProviderConnections();
|
||||
// Also discard any snapshot created while the asynchronous DB read yielded.
|
||||
preservedReplacementSettings.clear();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getTokenLimitsForRequest,
|
||||
logTokenLimitReset,
|
||||
type TokenLimit,
|
||||
} from "@/lib/localDb";
|
||||
} from "@/lib/db/tokenLimits";
|
||||
|
||||
interface CacheEntry {
|
||||
windowStart: string;
|
||||
@@ -295,8 +295,7 @@ export function recordTokenUsage(
|
||||
ORDER BY window_start DESC LIMIT 1`
|
||||
)
|
||||
.get(limit.id, windowStart) as
|
||||
| { window_start?: string; tokens_used?: number }
|
||||
| undefined;
|
||||
{ window_start?: string; tokens_used?: number } | undefined;
|
||||
const prevTokens =
|
||||
priorRow && typeof priorRow.tokens_used === "number" ? priorRow.tokens_used : 0;
|
||||
if (prevTokens > 0) {
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function recommendStrategyOverride(
|
||||
|
||||
// Check if adaptive routing is enabled globally
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const settings = await getSettings();
|
||||
if (!settings.adaptiveVolumeRouting) {
|
||||
return noOverride;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Optional-sharp image normalization.
|
||||
*
|
||||
* Rationale (migrated from freellmapi `server/src/lib/image-normalize.ts:40-58`):
|
||||
* Rationale:
|
||||
* OpenAI resizes images to a long-edge cap of 2048px server-side, Anthropic applies
|
||||
* a similar cap. Downscaling client-side before upload reduces tokens/latency without
|
||||
* changing model behavior. `sharp` is loaded via dynamic import so that a platform
|
||||
|
||||
@@ -20,7 +20,7 @@ async function getConfig() {
|
||||
if (_cachedConfig && now < _cacheExpiry) return _cachedConfig;
|
||||
|
||||
try {
|
||||
const { getProxyConfig } = await import("../../src/lib/localDb");
|
||||
const { getProxyConfig } = await import("@/lib/db/settings");
|
||||
_cachedConfig = await getProxyConfig();
|
||||
_cacheExpiry = now + 30_000; // Cache for 30s
|
||||
return _cachedConfig;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
import { createProxyDispatcher, normalizeProxyUrl } from "./proxyDispatcher.ts";
|
||||
import { resolveProxyForScopeFromRegistry, listProxies, listOneproxyProxies } from "@/lib/localDb";
|
||||
import { resolveProxyForScopeFromRegistry, listProxies } from "@/lib/db/proxies";
|
||||
import { listOneproxyProxies } from "@/lib/db/oneproxy";
|
||||
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ResourceSignals = {
|
||||
currentBytes: ResourceMetricBytes;
|
||||
maxBytes: ResourceMetricBytes;
|
||||
highBytes: ResourceMetricBytes;
|
||||
fileBytes: ResourceMetricBytes;
|
||||
events: {
|
||||
low: ResourceMetricBytes;
|
||||
high: ResourceMetricBytes;
|
||||
@@ -176,9 +177,17 @@ export function classifyAdaptiveResourcePressure(
|
||||
best,
|
||||
ratioLevel(signals.v8.heapUsedBytes, signals.v8.heapLimitBytes, thresholds, "v8_heap_ratio")
|
||||
);
|
||||
// memory.current includes reclaimable page cache; the kernel drops those
|
||||
// pages under allocation pressure (memory.events high/max stay 0). Ratio the
|
||||
// working set (current minus file cache) so cache-heavy-but-healthy hosts do
|
||||
// not trip the guard. Without memory.stat, fall back to the raw ratio.
|
||||
// memory.high keeps the raw current: the kernel throttles on TOTAL charge
|
||||
// (file cache included) when crossing high, so a workingset ratio there
|
||||
// would miss kernel-side reclaim stalls.
|
||||
const cgroupWorkingSetBytes = workingSetBytes(signals.cgroup);
|
||||
best = maxLevel(
|
||||
best,
|
||||
ratioLevel(signals.cgroup.currentBytes, signals.cgroup.maxBytes, thresholds, "cgroup_ratio")
|
||||
ratioLevel(cgroupWorkingSetBytes, signals.cgroup.maxBytes, thresholds, "cgroup_ratio")
|
||||
);
|
||||
best = maxLevel(
|
||||
best,
|
||||
@@ -188,10 +197,22 @@ export function classifyAdaptiveResourcePressure(
|
||||
return maxLevel(best, psiLevel(signals.psi?.fullAvg10 ?? null, thresholds, "psi_full"));
|
||||
}
|
||||
|
||||
function workingSetBytes(cgroup: ResourceSignals["cgroup"]): number | null {
|
||||
if (cgroup.currentBytes == null) return null;
|
||||
if (cgroup.fileBytes == null || cgroup.fileBytes <= 0) return cgroup.currentBytes;
|
||||
// memory.current and memory.stat are separate, non-atomic reads; under churn
|
||||
// file can momentarily exceed a fresher current. Treat that as a bad sample
|
||||
// and fall back to the raw ratio rather than clamping to 0, which would
|
||||
// read as zero pressure and could force a premature recovery.
|
||||
if (cgroup.fileBytes > cgroup.currentBytes) return cgroup.currentBytes;
|
||||
return cgroup.currentBytes - cgroup.fileBytes;
|
||||
}
|
||||
|
||||
function isRecovered(signals: ResourceSignals, thresholds: ResourcePressureThresholds): boolean {
|
||||
const cgroupWorkingSetBytes = workingSetBytes(signals.cgroup);
|
||||
const ratios: Array<readonly [number | null, number | null]> = [
|
||||
[signals.v8.heapUsedBytes, signals.v8.heapLimitBytes],
|
||||
[signals.cgroup.currentBytes, signals.cgroup.maxBytes],
|
||||
[cgroupWorkingSetBytes, signals.cgroup.maxBytes],
|
||||
[signals.cgroup.currentBytes, signals.cgroup.highBytes],
|
||||
];
|
||||
if (
|
||||
|
||||
@@ -180,6 +180,22 @@ function parsePsiNumber(line: string, name: string): number | null {
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function parseMemoryStatFileBytes(text: string | null): number | null {
|
||||
if (!text) return null;
|
||||
for (const line of text.split("\n")) {
|
||||
const [key, rawValue] = line.trim().split(/\s+/, 2);
|
||||
if (key !== "file" || rawValue == null) continue;
|
||||
// sanitizeMemoryBytes rejects every falsy magnitude INCLUDING zero, but a
|
||||
// zero file cache is a valid reading (workingSetBytes falls back to the
|
||||
// raw ratio for it). The zero short-circuit below is coupled to that
|
||||
// contract on purpose; if sanitizeMemoryBytes ever accepts zero, this
|
||||
// branch becomes dead but harmless.
|
||||
if (rawValue === "0") return 0;
|
||||
return sanitizeMemoryBytes(rawValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePsi(text: string | null): ResourceSignals["psi"] {
|
||||
if (!text) return null;
|
||||
const result: NonNullable<ResourceSignals["psi"]> = {
|
||||
@@ -226,14 +242,25 @@ export async function sampleResourceSignals(
|
||||
}
|
||||
|
||||
const cgroupDirectory = await resolveCgroupDirectory(readText);
|
||||
const cgroupContents = cgroupDirectory
|
||||
const cgroupReads = cgroupDirectory
|
||||
? await Promise.all([
|
||||
readText(path.join(cgroupDirectory, "memory.current")),
|
||||
readText(path.join(cgroupDirectory, "memory.max")),
|
||||
readText(path.join(cgroupDirectory, "memory.high")),
|
||||
readText(path.join(cgroupDirectory, "memory.events")),
|
||||
readText(path.join(cgroupDirectory, "memory.stat")),
|
||||
])
|
||||
: [null, null, null, null];
|
||||
: null;
|
||||
// Named bindings instead of positional indices: the read order above is
|
||||
// easy to shuffle on edit, and a silent index shift would corrupt the
|
||||
// current/max/high/stat mapping.
|
||||
const cgroupFiles = {
|
||||
current: cgroupReads?.[0] ?? null,
|
||||
max: cgroupReads?.[1] ?? null,
|
||||
high: cgroupReads?.[2] ?? null,
|
||||
events: cgroupReads?.[3] ?? null,
|
||||
stat: cgroupReads?.[4] ?? null,
|
||||
};
|
||||
const psi = await readText("/proc/pressure/memory").catch(() => null);
|
||||
|
||||
return {
|
||||
@@ -247,10 +274,11 @@ export async function sampleResourceSignals(
|
||||
constrainedBytes: safeNumber(deps.constrainedMemory ?? (() => process.constrainedMemory?.())),
|
||||
},
|
||||
cgroup: {
|
||||
currentBytes: sanitizeMemoryBytes(cgroupContents[0]),
|
||||
maxBytes: sanitizeMemoryBytes(cgroupContents[1]),
|
||||
highBytes: sanitizeMemoryBytes(cgroupContents[2]),
|
||||
events: parseMemoryEvents(cgroupContents[3]),
|
||||
currentBytes: sanitizeMemoryBytes(cgroupFiles.current),
|
||||
maxBytes: sanitizeMemoryBytes(cgroupFiles.max),
|
||||
highBytes: sanitizeMemoryBytes(cgroupFiles.high),
|
||||
fileBytes: parseMemoryStatFileBytes(cgroupFiles.stat),
|
||||
events: parseMemoryEvents(cgroupFiles.events),
|
||||
},
|
||||
psi: parsePsi(psi),
|
||||
};
|
||||
|
||||
525
package-lock.json
generated
525
package-lock.json
generated
@@ -72,6 +72,7 @@
|
||||
"react-is": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"react18-json-view": "0.2.10",
|
||||
"recharts": "^3.8.1",
|
||||
"safe-regex": "^2.1.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
@@ -16994,6 +16995,15 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/copy-to-clipboard": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
|
||||
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"toggle-selection": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -20992,490 +21002,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fumadocs-mdx/node_modules/magic-string": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz",
|
||||
@@ -26176,17 +25702,6 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/cacache": {
|
||||
"version": "19.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
|
||||
@@ -31633,7 +31148,9 @@
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@@ -33257,6 +32774,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react18-json-view": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/react18-json-view/-/react18-json-view-0.2.10.tgz",
|
||||
"integrity": "sha512-rYEbaCG/U4THY1qp1xY14/Kbnp9yY3W6Qm3Rmu+jlCIdxzMS5EcD+wI97kCKRoN3CuJyJU8hqkax5xWfl8A4EA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-to-clipboard": "^3.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cmd-shim": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
|
||||
@@ -36292,6 +35821,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toggle-selection": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
|
||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"react-is": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"react18-json-view": "0.2.10",
|
||||
"recharts": "^3.8.1",
|
||||
"safe-regex": "^2.1.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
|
||||
@@ -328,10 +328,10 @@ const chatGptWebCodexMcpDestFile = join(
|
||||
if (existsSync(chatGptWebCodexMcpSrcFile)) {
|
||||
console.log(" 🔨 Bundling ChatGPT Web (Codex) MCP bridge...");
|
||||
mkdirSync(dirname(chatGptWebCodexMcpDestFile), { recursive: true });
|
||||
execFileSync(
|
||||
NPX_BIN,
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
"esbuild",
|
||||
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts",
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
@@ -483,9 +483,11 @@ if (existsSync(cliSrcFile)) {
|
||||
// flow for every downstream user.
|
||||
const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin");
|
||||
const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js");
|
||||
const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs");
|
||||
if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) {
|
||||
const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs);
|
||||
// The plugin's tsup config is ESM-only (format: ["esm"]), so a successful
|
||||
// build only ever produces dist/index.js (+ dist/index.d.ts) — never
|
||||
// dist/index.cjs. Gate the skip solely on dist/index.js.
|
||||
const pluginAlreadyBuilt = existsSync(opencodePluginDist);
|
||||
if (!pluginAlreadyBuilt) {
|
||||
console.log("\n 🔨 Building @omniroute/opencode-plugin (tsup)...");
|
||||
try {
|
||||
|
||||
145
scripts/check/check-api-typecheck.mjs
Normal file
145
scripts/check/check-api-typecheck.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-api-typecheck.mjs
|
||||
// API-route-scoped typecheck gate (#11601).
|
||||
//
|
||||
// `typecheck:core` uses a curated file allowlist, the dashboard typecheck gate
|
||||
// only covers src/app/(dashboard)/**, and Next builds ignore TypeScript build
|
||||
// errors. That leaves src/app/api/** without a blocking typecheck gate.
|
||||
//
|
||||
// This gate runs `tsc` scoped to src/app/api/**/*.{ts,tsx} via
|
||||
// tsconfig.typecheck-api.json and compares live diagnostics against a frozen
|
||||
// per-file/per-TS-code count baseline. New diagnostics or count increases fail;
|
||||
// reductions are reported as improvements and can be ratcheted with --update.
|
||||
//
|
||||
// Run:
|
||||
// node scripts/check/check-api-typecheck.mjs
|
||||
// node scripts/check/check-api-typecheck.mjs --update
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-api.json");
|
||||
const BASELINE_PATH = path.join(ROOT, "config/quality/api-typecheck-baseline.json");
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
|
||||
const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/;
|
||||
|
||||
export function parseTscOutput(raw) {
|
||||
const counts = {};
|
||||
for (const line of String(raw).split("\n")) {
|
||||
const match = TSC_ERROR_LINE.exec(line);
|
||||
if (!match) continue;
|
||||
const [, file, , , code] = match;
|
||||
if (!counts[file]) counts[file] = {};
|
||||
counts[file][code] = (counts[file][code] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function diffAgainstBaseline(live, baseline) {
|
||||
const regressions = [];
|
||||
const improvements = [];
|
||||
|
||||
for (const [file, codes] of Object.entries(live)) {
|
||||
for (const [code, liveCount] of Object.entries(codes)) {
|
||||
const baselineCount = (baseline[file] && baseline[file][code]) || 0;
|
||||
if (liveCount > baselineCount) {
|
||||
regressions.push({ file, code, liveCount, baselineCount });
|
||||
} else if (liveCount < baselineCount) {
|
||||
improvements.push({ file, code, liveCount, baselineCount });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, codes] of Object.entries(baseline)) {
|
||||
for (const [code, baselineCount] of Object.entries(codes)) {
|
||||
const liveCount = (live[file] && live[file][code]) || 0;
|
||||
if (liveCount === 0 && baselineCount > 0) {
|
||||
improvements.push({ file, code, liveCount: 0, baselineCount });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { regressions, improvements };
|
||||
}
|
||||
|
||||
function runTsc() {
|
||||
try {
|
||||
return execFileSync(
|
||||
process.platform === "win32" ? "npx.cmd" : "npx",
|
||||
["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG],
|
||||
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.stdout) return String(err.stdout);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function loadBaseline() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) return {};
|
||||
return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
}
|
||||
|
||||
function writeBaseline(counts) {
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(TSCONFIG)) {
|
||||
process.stderr.write(`[api-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log("[api-typecheck] Running tsc scoped to src/app/api/**…");
|
||||
const stdout = runTsc();
|
||||
const live = parseTscOutput(stdout);
|
||||
const baseline = loadBaseline();
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
const liveErrorCount = Object.values(live).reduce(
|
||||
(sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0),
|
||||
0
|
||||
);
|
||||
console.log(`apiTypecheckErrors=${liveErrorCount}`);
|
||||
|
||||
if (UPDATE) {
|
||||
writeBaseline(live);
|
||||
console.log(`[api-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (improvements.length > 0) {
|
||||
console.log(
|
||||
`[api-typecheck] ${improvements.length} baselined error(s) no longer present ` +
|
||||
`— run 'node scripts/check/check-api-typecheck.mjs --update' to ratchet the baseline down:\n` +
|
||||
improvements
|
||||
.map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
if (regressions.length > 0) {
|
||||
process.stderr.write(
|
||||
`[api-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` +
|
||||
`under src/app/api/ not covered by the frozen baseline:\n` +
|
||||
regressions
|
||||
.map((r) => ` ✗ ${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`)
|
||||
.join("\n") +
|
||||
`\n\nFix new API-route TypeScript regressions rather than widening the baseline.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[api-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
main();
|
||||
}
|
||||
@@ -1,89 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-db-rules.mjs
|
||||
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Três verificações:
|
||||
// (a) Todo módulo de domínio em src/lib/db/*.ts deve ser re-exportado por
|
||||
// src/lib/localDb.ts (camada de compat). Um módulo db NOVO que não é
|
||||
// re-exportado (e não está congelado) falha — força a decisão consciente
|
||||
// de expor ou justificar (Hard Rule #2).
|
||||
// (b) src/lib/localDb.ts é APENAS camada de re-export: nada de lógica
|
||||
// (function/class/arrow de negócio). Mata o anti-padrão de "só uma
|
||||
// funçãozinha aqui" que vira regra de negócio fora dos módulos db/.
|
||||
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Uma verificação:
|
||||
// (c) Nenhum SQL cru em src/app/api/**/route.ts ou open-sse/handlers/*.ts.
|
||||
// SQL deve viver em src/lib/db/ (Hard Rule #5). Ofensores pré-existentes
|
||||
// são congelados; QUALQUER novo SQL cru em rota/handler falha.
|
||||
// Stale-enforcement (6A.3): entradas em INTENTIONALLY_INTERNAL / EXTERNAL_DB_ALLOWED
|
||||
// que não suprimem nenhuma violação real → gate falha com instrução de remoção.
|
||||
// As antigas verificações (a) re-export completo via src/lib/localDb.ts e
|
||||
// (b) localDb.ts sem lógica foram REMOVIDAS: o barrel src/lib/localDb.ts foi
|
||||
// deletado (#11795) — consumidores importam módulos src/lib/db/* diretamente
|
||||
// (regra "never barrel-import", aplicada por eslint no-restricted-imports).
|
||||
// Stale-enforcement (6A.3): entradas em EXTERNAL_DB_ALLOWED que não suprimem
|
||||
// nenhuma violação real → gate falha com instrução de remoção.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { assertNoStale } from "./lib/allowlist.mjs";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const DB_DIR = path.join(cwd, "src/lib/db");
|
||||
const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts");
|
||||
const API_DIR = path.join(cwd, "src/app/api");
|
||||
const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
|
||||
|
||||
// (a) Módulos db/ que NÃO são re-exportados por localDb.ts por DESIGN (Hard Rule #2:
|
||||
// "Never barrel-import from localDb.ts — import specific db/ modules instead").
|
||||
// Cada entrada aqui foi auditada e é consumida via import direto de "@/lib/db/X"
|
||||
// (estático ou dinâmico) pelos seus consumidores — exatamente o padrão correto.
|
||||
// Re-exportar esses módulos via localDb.ts INCENTIVARIA o anti-padrão proibido.
|
||||
// O gate ainda bloqueia QUALQUER módulo db/ NOVO que não seja re-exportado E não
|
||||
// esteja nessa lista — mantendo a decisão consciente obrigatória (Hard Rule #2).
|
||||
// Legenda de classificação:
|
||||
// type-only = exporta apenas tipos (sem runtime API), não há o que re-exportar
|
||||
// db-internal = importado apenas dentro de src/lib/db/ (coordenação interna)
|
||||
// intentionally-internal = consumido por import direto fora de db/ (correto per Rule #2)
|
||||
// DEAD? = zero importers encontrados na auditoria de 2026-06-11; não deletar
|
||||
// sem investigação — pode ser reserva de schema ou F2 pendente
|
||||
export const INTENTIONALLY_INTERNAL = new Set([
|
||||
"_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types)
|
||||
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
|
||||
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
|
||||
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
|
||||
"backupRetention", // db-internal: importado só por db/backup.ts e db/migrationRunner.ts (política de retenção compartilhada; mora fora de backup.ts porque core.ts importa migrationRunner.ts — importar backup.ts de lá fecharia um ciclo, #10421)
|
||||
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
|
||||
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
|
||||
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
|
||||
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
|
||||
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
|
||||
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
|
||||
"compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404)
|
||||
"connectionRuntimeState", // intentionally-internal: warmupScheduler sqlite/redis stores importam diretamente de @/lib/db/connectionRuntimeState (Rule #2)
|
||||
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
|
||||
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
|
||||
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
|
||||
"domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy)
|
||||
"encryption", // intentionally-internal: 8+ callers (container, webhookDispatcher, cloudAgent/credentials, services/apiKey, 4+ routes, open-sse)
|
||||
"healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck)
|
||||
"jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts
|
||||
"migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB)
|
||||
"modelCapabilityOverrides", // intentionally-internal: src/app/api/model-capability-overrides/route.ts via import direto "@/lib/db/modelCapabilityOverrides" (#6727 — evita empurrar localDb.ts para o cap de 800 linhas)
|
||||
"notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts
|
||||
"obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts
|
||||
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
|
||||
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
|
||||
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
|
||||
"probeUtils", // db-internal: importado so por db/core.ts (retryProbeIfTransient no caminho da corruption-probe, #9541); testado por tests/unit/probe-9541-repro.test.ts
|
||||
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
|
||||
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
|
||||
"proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798)
|
||||
"proxySubscriptions", // db-internal: importado só por db/proxies.ts (addProxiesToScopePool — split do proxies.ts para ficar sob o cap de tamanho congelado, #7299); a função já é re-exportada por proxies.ts (que localDb.ts re-exporta)
|
||||
"recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests
|
||||
"schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948)
|
||||
"secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização)
|
||||
"serviceModels", // intentionally-internal: 3 callers (services/modelSync, services/bootstrap, /api/services/9router/models)
|
||||
"stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset
|
||||
"stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts
|
||||
"tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico)
|
||||
"webSessionDedup", // db-internal: importado só por db/providers.ts (webSessionCredentialKey/parseProviderSpecificData — helpers puros de dedup de credencial web-session split do providers.ts, #3368 PR6)
|
||||
]);
|
||||
|
||||
// Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED.
|
||||
// O comportamento do gate é idêntico — só o nome e os comentários mudaram (#3499).
|
||||
export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
|
||||
|
||||
// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500).
|
||||
// Esta rota NÃO consulta o DB do OmniRoute (getDbInstance) — ela abre o
|
||||
// SQLite de OUTRO aplicativo (Kiro) para auto-importar credenciais.
|
||||
@@ -97,16 +32,13 @@ export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
|
||||
// precisa de entrada aqui: o SQL contra o state.vscdb externo do Cursor vive
|
||||
// em src/lib/cursor/tokenExtractor.ts, fora do escopo desta checagem (que só
|
||||
// varre src/app/api/**/route.ts e open-sse/handlers/*.ts).
|
||||
const EXTERNAL_DB_ALLOWED = new Set([
|
||||
export const EXTERNAL_DB_ALLOWED = new Set([
|
||||
"src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo)
|
||||
]);
|
||||
|
||||
// Alias de retrocompatibilidade (testes/consumidores que importam KNOWN_RAW_SQL).
|
||||
// Comportamento do gate idêntico — só o nome e o enquadramento mudaram (#3500).
|
||||
const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
|
||||
|
||||
// Módulos sempre excluídos da checagem (a): não são domínio re-exportável.
|
||||
const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]);
|
||||
export const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
if (!fs.existsSync(dir)) return acc;
|
||||
@@ -118,59 +50,6 @@ function walk(dir, acc = []) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Lista os módulos de domínio em src/lib/db (top-level *.ts), excluindo
|
||||
// core/localDb/index, *.d.ts e qualquer subdiretório (migrations/, adapters/, __tests__/).
|
||||
export function collectDbModules(dbDir = DB_DIR) {
|
||||
if (!fs.existsSync(dbDir)) return [];
|
||||
return fs
|
||||
.readdirSync(dbDir, { withFileTypes: true })
|
||||
.filter((e) => e.isFile() && /\.ts$/.test(e.name) && !/\.d\.ts$/.test(e.name))
|
||||
.map((e) => e.name.replace(/\.ts$/, ""))
|
||||
.filter((name) => !DB_MODULE_EXCLUDE.has(name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Extrai os nomes de módulo re-exportados de localDb.ts a partir de
|
||||
// `... from "./db/X"` (cobre export {…}, export * e export type {…}).
|
||||
export function extractReexportedModules(localDbSource) {
|
||||
const re = /from\s+["']\.\/db\/([A-Za-z0-9_]+)["']/g;
|
||||
const out = new Set();
|
||||
let m;
|
||||
while ((m = re.exec(localDbSource))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// (a) Módulos db/ que não são re-exportados e não estão na lista de
|
||||
// intencionalmente-internos (INTENTIONALLY_INTERNAL). O gate falha para
|
||||
// qualquer módulo NOVO que não seja re-exportado nem justificado.
|
||||
export function findMissingReexports(dbModules, reexported, allowlist = INTENTIONALLY_INTERNAL) {
|
||||
return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod));
|
||||
}
|
||||
|
||||
// (b) localDb.ts deve conter SOMENTE import/export + comentários (sem lógica).
|
||||
// Remove comentários e strings, depois procura declarações de runtime.
|
||||
export function hasLogic(localDbSource) {
|
||||
const stripped = localDbSource
|
||||
// comentários de bloco
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
// comentários de linha
|
||||
.replace(/\/\/[^\n]*/g, "")
|
||||
// template strings
|
||||
.replace(/`(?:\\[\s\S]|[^\\`])*`/g, '""')
|
||||
// strings simples/duplas (paths de import etc.)
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
||||
.replace(/'(?:\\.|[^'\\])*'/g, '""');
|
||||
|
||||
// function/class declaradas, ou atribuição a função (const X = (…) =>, const X = function).
|
||||
const logicPatterns = [
|
||||
/(^|[^.\w])function\s+[A-Za-z_$]/, // function decl (não method .foo())
|
||||
/(^|[^.\w])class\s+[A-Za-z_$]/, // class decl
|
||||
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(/, // const X = (…) ... (arrow/call)
|
||||
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?function\b/, // const X = function
|
||||
];
|
||||
return logicPatterns.some((rx) => rx.test(stripped));
|
||||
}
|
||||
|
||||
// SQL cru é sempre uma STRING passada a db.prepare()/exec(): casamos os padrões
|
||||
// SÓ dentro de literais de string (não em código JS — `import … from`, `.set(`,
|
||||
// `new Set(`, `delete x` etc. são falsos positivos se varrermos o código todo).
|
||||
@@ -198,7 +77,7 @@ export function extractStringLiterals(code) {
|
||||
// tira as aspas/crases delimitadoras
|
||||
out.push(m[0].slice(1, -1));
|
||||
}
|
||||
return out.join("\n | ||||