mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 20:32:25 +03:00
merge(release/v3.8.51): refresh onto 3b752f9d4c
Bring inherited Fast Quality Gates / ESLint / unit-test / docs-sync fixes from #11940/#11955/#11975. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -3023,3 +3023,11 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# corpus-aware retrieval. Higher values keep more index entries hot.
|
||||
# Used by: src/lib/localCorpus/configured.ts
|
||||
# OMNIROUTE_CORPUS_CACHE_SIZE=5
|
||||
|
||||
# Service-worker cache-busting id for the PWA shell (#11779). NEXT_PUBLIC_SW_BUILD_ID is
|
||||
# derived at build time from OMNIROUTE_SW_BUILD_ID, then SOURCE_VERSION (set by some PaaS
|
||||
# builders), then the git SHA — override only when the build cannot see git. Used by:
|
||||
# next.config.mjs, scripts/build/assembleStandalone.mjs, src/shared/components/PwaRegister.tsx.
|
||||
#OMNIROUTE_SW_BUILD_ID=2026-08-28T12-00-00
|
||||
#SOURCE_VERSION=abcdef0123456789
|
||||
#NEXT_PUBLIC_SW_BUILD_ID=abcdef0123456789
|
||||
|
||||
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -1,9 +1,16 @@
|
||||
name: Build App
|
||||
|
||||
# Manual-only since #11946. The hosted 7 GB runner can no longer build this tree — 19 of
|
||||
# the last 30 runs died with "The runner has received a shutdown signal" (VM out of
|
||||
# memory) ~8 min into `next build`, release/v3.8.51 itself included, even with the 10 GB
|
||||
# swapfile below. Triggered on `push: branches: ["**"]` it painted every branch and every
|
||||
# PR red while producing an artefact nothing downloads. The bundle is validated where a
|
||||
# build actually fits:
|
||||
# - main: ci.yml `Build` (self-hosted omni-build pool) on every merge
|
||||
# - release/**: nightly-release-green.yml (same pool, continuous)
|
||||
# Dispatch this workflow by hand when a hosted build artefact is genuinely needed.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@@ -609,13 +609,24 @@ jobs:
|
||||
# Dynamic runner: when the release captain flips the USE_VPS_RUNNER repo var to
|
||||
# 'true' (scripts/vps/release-runner-up.sh does it after the self-hosted VM is
|
||||
# online), the heavy jobs run on the dedicated 32-core VPS runners (label
|
||||
# omni-release) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# omni-build) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# Safety: fork PRs NEVER reach the self-hosted runner — the expression falls
|
||||
# back to ubuntu-latest unless the PR head repo is this repository (push /
|
||||
# dispatch events are own-origin by definition). Any failure path (VM down,
|
||||
# var unset/false) also falls back to ubuntu-latest.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
# The .113 pool runs ONE next-build with room to spare and two at the edge: the
|
||||
# box has 31 GB and a single next-build peaks at 14–16 GB RSS. On 2026-08-28
|
||||
# 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.
|
||||
concurrency:
|
||||
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
|
||||
cancel-in-progress: false
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -649,14 +660,14 @@ jobs:
|
||||
# Keep standalone/node_modules intact: package/electron jobs consume the
|
||||
# Next-traced standalone tree and must not replace it with root node_modules.
|
||||
run: |
|
||||
tar -czf /tmp/e2e-build.tar.gz \
|
||||
tar -czf "$RUNNER_TEMP/e2e-build.tar.gz" \
|
||||
--exclude='.build/next/cache' \
|
||||
.build/next
|
||||
- name: Upload Next.js build for downstream jobs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/e2e-build.tar.gz
|
||||
path: ${{ runner.temp }}/e2e-build.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
package-artifact:
|
||||
@@ -679,10 +690,14 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
|
||||
# it only rebuilds if the downloaded standalone artifact is missing.
|
||||
- run: npm run build:cli
|
||||
@@ -770,10 +785,14 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
@@ -960,7 +979,11 @@ jobs:
|
||||
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
|
||||
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
|
||||
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
|
||||
timeout-minutes: 20
|
||||
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
|
||||
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
|
||||
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
|
||||
# blocking job was green. The upload step now has its own ceiling; this is headroom.
|
||||
timeout-minutes: 30
|
||||
needs: test-unit
|
||||
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
|
||||
env:
|
||||
@@ -1039,6 +1062,10 @@ jobs:
|
||||
# (if-no-files-found: warn) — Sonar consumes the same file.
|
||||
- name: Upload coverage to Codecov (informational)
|
||||
if: always()
|
||||
# Informational means informational: its own ceiling and continue-on-error, so a
|
||||
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
@@ -1233,10 +1260,14 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
|
||||
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
|
||||
# critical path. The balancer self-verifies completeness and exits non-zero on
|
||||
|
||||
10
.github/workflows/dast-smoke.yml
vendored
10
.github/workflows/dast-smoke.yml
vendored
@@ -1,7 +1,15 @@
|
||||
name: DAST smoke (PR)
|
||||
# PRs into main only since #11946. The job's "Build CLI bundle" step is a backend-only
|
||||
# `next build`; on the hosted 7 GB runner it fits main's tree (~5.5 min) but dies on
|
||||
# release/v3.8.51 (VM shutdown ~7 min in, before the server even starts), and because the
|
||||
# job is continue-on-error the result was a permanently red advisory check on every
|
||||
# release PR — noise, not signal. DAST coverage for release/** lives on the nightly rail
|
||||
# (nightly-schemathesis.yml, nightly-llm-security.yml); dispatch this workflow by hand
|
||||
# to smoke a release branch on demand.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: ["main", "release/**"]
|
||||
branches: ["main"]
|
||||
# Runner-cost guard (#8084): the CLI-bundle build alone is 6-11min; a docs-only PR
|
||||
# cannot change DAST behavior, so skip the whole workflow for pure docs/markdown
|
||||
# changes. Any code path in the diff still runs the full smoke.
|
||||
|
||||
22
.github/workflows/electron-release.yml
vendored
22
.github/workflows/electron-release.yml
vendored
@@ -10,6 +10,11 @@ on:
|
||||
description: "Release version (e.g., v1.6.8)"
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# Least-privilege default: read-only at the top level; each job grants the writes it
|
||||
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
|
||||
@@ -76,6 +81,9 @@ 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:
|
||||
@@ -161,6 +169,9 @@ 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:
|
||||
@@ -347,6 +358,8 @@ 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
|
||||
@@ -462,11 +475,20 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
|
||||
permissions:
|
||||
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
|
||||
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
|
||||
# A reusable workflow's job cannot request more permission than the caller grants,
|
||||
# so a `read` here makes GitHub reject the run at startup (startup_failure).
|
||||
#
|
||||
# `actions: read` for the same reason: the called `publish` job downloads the next-build
|
||||
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
|
||||
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
|
||||
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
|
||||
# this block a superset of every job's permissions in npm-publish.yml.
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write # npm provenance (forwarded to the reusable workflow)
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
|
||||
10
.github/workflows/nightly-llm-security.yml
vendored
10
.github/workflows/nightly-llm-security.yml
vendored
@@ -10,7 +10,10 @@ permissions:
|
||||
jobs:
|
||||
promptfoo-guard:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
runs-on: ubuntu-latest
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -46,7 +49,10 @@ jobs:
|
||||
|
||||
garak:
|
||||
name: garak probes (skip without provider secret)
|
||||
runs-on: ubuntu-latest
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
|
||||
# it there makes GitHub reject the file on push (startup_failure on every push).
|
||||
# Map the secret into a job-level env and gate each step on a presence check, so
|
||||
|
||||
4
.github/workflows/nightly-release-green.yml
vendored
4
.github/workflows/nightly-release-green.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
|
||||
# no local noauth CLIs => zero machine-specific false positives) and no contention.
|
||||
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
# On a push, only run for a push to main — a push to release/* is handled by
|
||||
# release-green above. Schedule/dispatch always run (they also sweep main).
|
||||
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
|
||||
5
.github/workflows/nightly-resilience.yml
vendored
5
.github/workflows/nightly-resilience.yml
vendored
@@ -78,7 +78,10 @@ jobs:
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
runs-on: ubuntu-latest
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
|
||||
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
|
||||
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
|
||||
|
||||
5
.github/workflows/nightly-schemathesis.yml
vendored
5
.github/workflows/nightly-schemathesis.yml
vendored
@@ -10,7 +10,10 @@ permissions:
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: Schemathesis — OpenAPI contract fuzz (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
44
.github/workflows/npm-publish.yml
vendored
44
.github/workflows/npm-publish.yml
vendored
@@ -23,11 +23,12 @@ on:
|
||||
- next
|
||||
- historic
|
||||
publish_mode:
|
||||
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
|
||||
description: "auto = publish through npm Trusted Publishing (OIDC, no token, no 2FA prompt — the default); staged = npm stage publish (owner approves with 2FA); direct = legacy token publish (emergency fallback only)"
|
||||
required: false
|
||||
default: "staged"
|
||||
default: "auto"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- staged
|
||||
- direct
|
||||
workflow_call:
|
||||
@@ -62,7 +63,7 @@ jobs:
|
||||
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
|
||||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
@@ -204,8 +205,11 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
RUN=""
|
||||
# $RUNNER_TEMP, never /tmp: on the .113 pool /tmp is a 12 GB tmpfs (RAM). Parking
|
||||
# this 1.3 GB artefact there took 27–32 min of the 76-min publish job — the
|
||||
# same bytes upload from disk in 2 min. RUNNER_TEMP is per-runner and on disk.
|
||||
for candidate in $CANDIDATES; do
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir "$RUNNER_TEMP/next-build" 2>/dev/null; then
|
||||
RUN="$candidate"
|
||||
break
|
||||
fi
|
||||
@@ -215,8 +219,8 @@ jobs:
|
||||
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
|
||||
rm -rf /tmp/next-build
|
||||
tar -xzf "$RUNNER_TEMP/next-build/e2e-build.tar.gz" -C .
|
||||
rm -rf "$RUNNER_TEMP/next-build"
|
||||
if [ -f .build/next/standalone/server.js ]; then
|
||||
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
|
||||
else
|
||||
@@ -404,8 +408,34 @@ jobs:
|
||||
fi
|
||||
npm --version
|
||||
|
||||
# Trusted Publishing (OIDC): npm mints a short-lived credential for THIS run from
|
||||
# GitHub's id-token — no NPM_TOKEN secret, no 2FA prompt, provenance included, and
|
||||
# it is the bypass npm sanctions now that tokens which skip 2FA are being retired
|
||||
# (gh.io/npm-gat-bypass2fa-deprecation). Requires the package's Trusted Publisher to
|
||||
# be configured on npmjs.com (owner: diegosouzapw/OmniRoute, workflow
|
||||
# npm-publish.yml) and a github-hosted runner — which is why this job exists.
|
||||
# Without that configuration `npm publish` fails with ENEEDAUTH: re-dispatch with
|
||||
# publish_mode=staged or direct. Automatic publishing was the flow up to v3.8.48;
|
||||
# v3.8.49 moved to staged (WS1.3) to keep a leaked token from publishing alone —
|
||||
# OIDC gives the same guarantee without the manual approve.
|
||||
- name: Publish to npm (Trusted Publishing / OIDC — automatic)
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode == 'auto'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||
# Deliberately NO NODE_AUTH_TOKEN in this step: npm >= 11.5 detects the GitHub
|
||||
# OIDC token itself. Always pass --tag explicitly (defense in depth: an older
|
||||
# VERSION can never claim `@latest`).
|
||||
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) via Trusted Publishing"
|
||||
|
||||
- name: Publish to npm (staged — owner approves with 2FA)
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct'
|
||||
# Only on an explicit request now: Trusted Publishing below is the default.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'staged'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
|
||||
23
.github/workflows/quality.yml
vendored
23
.github/workflows/quality.yml
vendored
@@ -61,6 +61,9 @@ jobs:
|
||||
name: Build (advisory)
|
||||
needs: changes
|
||||
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
|
||||
# (#11946, 2026-08-29: build.yml is now workflow_dispatch-only — the hosted runner cannot
|
||||
# build this tree in any profile, 8/8 recent fork PRs included — so own-origin PRs rely on
|
||||
# ci.yml `Build` after merge to main and on nightly-release-green for release/**.)
|
||||
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
|
||||
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
|
||||
# never fires here, and this is the only pre-merge build signal they get. Measured
|
||||
@@ -189,8 +192,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.
|
||||
# Security scanners — same hardened install as ci.yml quality-extended
|
||||
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
|
||||
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
|
||||
@@ -460,6 +466,12 @@ jobs:
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
# A shard finishes in ~10 min. Without a ceiling a hung test process holds the PR for
|
||||
# GitHub's 6 h default: on 2026-08-28 shard 1/4 sat 64 min without a line of output
|
||||
# (twice, same spot — a timing race, gone on the third run) while the other three
|
||||
# shards were long green. 30 min = 3x the normal wall-clock; a shard that needs more
|
||||
# is a hang, not a slow run, and a fast red with a re-run beats a silent 6 h hold.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -524,8 +536,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.
|
||||
- name: ESLint (baseline congelado — warning novo = vermelho)
|
||||
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
||||
run: npm run lint:json -- --max-warnings 0
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -594,6 +594,18 @@ inside your feature branch (a base-red fix is its own freeze-gated `fix/release-
|
||||
PR); and if you must open a PR anyway, add `⚠️ base-red inherited: #<issue>` to the PR body so
|
||||
reviewers and CI babysitters do not chase ghosts.
|
||||
|
||||
### Sync-back landings are fast-forward, never squash
|
||||
|
||||
A `main → release/vX+1` sync-back (Phase 5 of `/generate-release`, or any later "bring main's
|
||||
post-release commits over" PR) must reach the release branch as the merge commit it already is:
|
||||
`git merge-base --is-ancestor origin/release/vX+1 <head>` then
|
||||
`git push origin <head>:refs/heads/release/vX+1` (GitHub marks the PR merged). Squash-merging it
|
||||
drops `main` from the release branch's ancestry and the next sync-back re-conflicts on every file
|
||||
main touched (551 conflicts on the v3.8.50 → v3.8.51 sync before the two-step merge). After
|
||||
landing, `git merge-base --is-ancestor origin/main origin/release/vX+1` must be true — and check
|
||||
that `config/quality/eslint-suppressions.json` / `quality-baseline.json` carried main's freezes
|
||||
(they merge as "ours" silently). Details: `.agents/skills/generate-release/phases/phase-5-next-cycle.md`.
|
||||
|
||||
---
|
||||
|
||||
## Upstream contributions
|
||||
|
||||
4
changelog.d/features/npm-trusted-publishing-oidc.md
Normal file
4
changelog.d/features/npm-trusted-publishing-oidc.md
Normal file
@@ -0,0 +1,4 @@
|
||||
- The npm publish is automatic again, through npm Trusted Publishing (OIDC): the hosted
|
||||
`stage-npm` job publishes with a short-lived credential minted from GitHub's id-token —
|
||||
no `NPM_TOKEN`, no 2FA prompt, provenance attached. `publish_mode=staged` (owner
|
||||
approves with 2FA) and `direct` (token) remain available on `workflow_dispatch`.
|
||||
@@ -0,0 +1,5 @@
|
||||
- Fixed the Alibaba free-tier allowlist test that went red on its own once the
|
||||
shipped catalog's `validUntil` (2026-08-27) passed, leaving every PR and `main`
|
||||
with a failing `Unit Tests (1/8)`. The test now builds its own packs with dates
|
||||
it controls, and covers the expired-pack fallback that production has actually
|
||||
been serving.
|
||||
1
changelog.d/fixes/11879-xai-xhigh-reasoning-effort.md
Normal file
1
changelog.d/fixes/11879-xai-xhigh-reasoning-effort.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** xAI `reasoning_effort: "xhigh"` now reaches grok-4.6+ instead of being silently clamped to `"high"` ([#11879](https://github.com/diegosouzapw/OmniRoute/pull/11879)) — thanks @NoxzRCW
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** the providers page no longer crashes into the error boundary when a provider id collides with an `Object.prototype` member (`constructor`, `__proto__`); icon lookups are own-property guarded ([#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)) — thanks @NoxzRCW
|
||||
1
changelog.d/fixes/11881-skills-shorthand-tool-schema.md
Normal file
1
changelog.d/fixes/11881-skills-shorthand-tool-schema.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(skills):** injected skill tools declared in shorthand (`{"content": "string"}`) now forward valid JSON Schema, unblocking providers that validate tool schemas strictly such as Zhipu GLM on the Console Go tier ([#11881](https://github.com/diegosouzapw/OmniRoute/pull/11881)) — thanks @NoxzRCW
|
||||
1
changelog.d/fixes/11882-simulate-route-step-warnings.md
Normal file
1
changelog.d/fixes/11882-simulate-route-step-warnings.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api):** `POST /api/playground/simulate-route` now surfaces `combo-ref` and `provider-wildcard` persisted combo steps with a specific warning (naming the referenced combo, or the unresolved `provider/model` wildcard) instead of folding them into a generic "unsupported step" count; a `provider-wildcard` step is also now included as an unresolved target so the operator can see it is in the route (ported from [#11882](https://github.com/diegosouzapw/OmniRoute/pull/11882) — thanks @NoxzRCW).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(translator):** the streaming OpenAI→Claude translator keeps upstream usage, including prompt-cache tokens, when it arrives on a trailing `choices: []` chunk (Fireworks and any upstream using `stream_options.include_usage`) ([#11883](https://github.com/diegosouzapw/OmniRoute/pull/11883)) — thanks @NoxzRCW
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(sse):** the OpenAI→Claude stream translator now defers the terminal `message_delta`/`message_stop` emission until the real usage block has arrived (or a genuine end-of-stream flush forces it) instead of emitting it immediately on `finish_reason` — previously, when the trailing usage-only chunk (`{"choices":[],"usage":{...}}`) arrived *after* the `finish_reason` chunk (the normal order for Fireworks/vLLM/Together and other `stream_options.include_usage` upstreams), the client-visible `message_delta` still carried stale/zero usage even though `state.usage` was internally corrected too late to matter (ported from [#11915](https://github.com/diegosouzapw/OmniRoute/pull/11915) — thanks @HouMinXi).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(sse):** `stripStore()` now forces `store=false` for stateless OpenAI-compatible Responses-API targets (unless the connection has explicitly opted in via `providerSpecificData.openaiStoreEnabled`), instead of only handling the `openai`/`agentrouter` cases — a client-supplied `store` value previously passed through untouched to backends that don't actually persist responses server-side ([#11916](https://github.com/diegosouzapw/OmniRoute/pull/11916) — thanks @HouMinXi).
|
||||
1
changelog.d/fixes/11918-custom-node-canonical-prefix.md
Normal file
1
changelog.d/fixes/11918-custom-node-canonical-prefix.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(models):** custom provider-node models (synced, custom, and alias-backed) now appear under their configured prefix in the unified catalog when the operator's model-id prefix mode is canonical, instead of being dropped whenever alias-inclusion was otherwise disabled ([#11918](https://github.com/diegosouzapw/OmniRoute/pull/11918) — thanks @HouMinXi).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(models):** the unified model catalog now suppresses stale static registry models (including effort-tier variants) for any provider whose active connection has an authoritative live synced catalog, not only providers already using exclusive-synced-listing — a connection with `providerUsesAuthoritativeLiveCatalog` previously kept serving both the live-synced models and the stale static rows side by side ([#11919](https://github.com/diegosouzapw/OmniRoute/pull/11919) — thanks @HouMinXi).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(ui):** `ProviderIcon`'s three remaining unguarded lookups (`PROVIDER_ICON_ALIASES`, `LOCAL_SVG_ALIASES`, `THEMED_SVGS`) now use `Object.hasOwn()` own-property checks — a provider id such as `constructor` or `__proto__` previously resolved through the prototype chain instead of falling through to the unknown-provider CDN fallback (`getLobeProviderIcon()` itself was already guarded by [#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)); `ProviderPageHeader`'s `color` field is also now optional, matching the rest of the component's defensive typing (ported from [#11920](https://github.com/diegosouzapw/OmniRoute/pull/11920) — thanks @HouMinXi).
|
||||
1
changelog.d/fixes/v3850-electron-release-assets.md
Normal file
1
changelog.d/fixes/v3850-electron-release-assets.md
Normal file
@@ -0,0 +1 @@
|
||||
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets
|
||||
7
changelog.d/fixes/v3851-sweep-reds.md
Normal file
7
changelog.d/fixes/v3851-sweep-reds.md
Normal file
@@ -0,0 +1,7 @@
|
||||
- Drained the reds every PR against `release/v3.8.51` was born with: documented the
|
||||
three service-worker build-id variables, registered the six retirement/tunnel tests
|
||||
with the mutation gate, approved `eslint-plugin-react-hooks` in the dependency
|
||||
allowlist, added the six `combo.sort.*` strings to `vi` and `pt-BR`, pointed the
|
||||
ChatGPT Web doc at the real migration-168 test, worded the g4f hint around the
|
||||
member key, and realigned four tests to the retired-provider catalog and the
|
||||
legacy-schema fixtures the retirement migrations touch.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Added a unit test that fails seven days before any dated pack under `config/`
|
||||
(`validUntil` and sibling keys) lapses, naming the file and key. The Alibaba
|
||||
free-tier pack expired on 2026-08-27 and turned every PR red the next morning
|
||||
with no commit involved; renewal now happens on someone's terms, not the clock's.
|
||||
@@ -0,0 +1,3 @@
|
||||
- `check:workflows` now fails (under `--strict`/`--ratchet`) when any job routed to a
|
||||
self-hosted runner publishes with `--provenance` — npm rejects that with `422` at the
|
||||
registry, which in v3.8.50 only surfaced after the tag and Docker images were public.
|
||||
@@ -0,0 +1,5 @@
|
||||
- `scripts/ops/runner-janitor.sh` now proves a path is idle with one `lsof`
|
||||
snapshot and removes stale leftovers itself (tmpfs after 3 h — it is RAM — disk
|
||||
after 24 h), kills orphan `next-build` processes, prunes checkouts of stopped
|
||||
runners, and alerts on memory pressure; `--dry-run` shows exactly what it would
|
||||
do. `docs/ops/RUNNER_BOX.md` reconciled to the measured box (31 GB, 10 listeners).
|
||||
@@ -0,0 +1,5 @@
|
||||
- The `next-build` artefact (1.3 GB) is now written and read under `$RUNNER_TEMP`
|
||||
(per-runner, on disk) instead of `/tmp`, which on the self-hosted pool is a
|
||||
12 GB tmpfs in RAM. Landing it there took 27–32 of the publish job's 76 minutes,
|
||||
and the fixed `/tmp/e2e-build.tar.gz` name let E2E jobs on different runners
|
||||
overwrite each other's download.
|
||||
3
changelog.d/maintenance/11897-ci-heavy-build-lane.md
Normal file
3
changelog.d/maintenance/11897-ci-heavy-build-lane.md
Normal file
@@ -0,0 +1,3 @@
|
||||
- The CI `build` job now runs in two concurrency lanes — `main` and pull requests —
|
||||
so a release build is never queued behind (or OOM-killed beside) PR builds on the
|
||||
self-hosted pool, which holds one `next-build` comfortably and two at the edge.
|
||||
1
changelog.d/maintenance/11924-eslint-refreeze-v3851.md
Normal file
1
changelog.d/maintenance/11924-eslint-refreeze-v3851.md
Normal file
@@ -0,0 +1 @@
|
||||
- Re-freeze the ESLint suppressions on `release/v3.8.51` from a clean-room measurement (2 stale file entries pruned, 55 pre-existing `no-explicit-any` in six new files frozen under #11924) and drop the dead `GPT_SIZE_MAP` constant orphaned by the Adobe Firefly client split, so `No new ESLint warnings` stops failing every PR with exit 2 (Refs #11924)
|
||||
1
changelog.d/maintenance/11924-type-the-frozen-any.md
Normal file
1
changelog.d/maintenance/11924-type-the-frozen-any.md
Normal file
@@ -0,0 +1 @@
|
||||
- Type the 55 `no-explicit-any` sites that had been frozen under #11924 — four redundant casts in `socksConnectorWithFamily.ts` (undici/socks types already accept them) and the mocks/fixtures of the socks-timeout and isFree suites — and drop their suppression entries; the ESLint ratchet shrinks from 5487 to 5432 (Closes #11924)
|
||||
1
changelog.d/maintenance/11946-hosted-build-rail.md
Normal file
1
changelog.d/maintenance/11946-hosted-build-rail.md
Normal file
@@ -0,0 +1 @@
|
||||
- Take the two hosted-runner builds off the PR rail: `Build App` (`build.yml`) is `workflow_dispatch`-only and `DAST smoke (PR)` runs only for PRs into `main` — the 7 GB hosted VM cannot build `release/v3.8.51` in any profile (19/30 red, VM shutdown ~8 min into `next build`) and both checks had turned into permanent noise on every release PR; the bundle stays validated by `ci.yml` on `main` and by `nightly-release-green` on `release/**` (Closes #11946)
|
||||
1
changelog.d/maintenance/11965-nightly-jobs-omni-light.md
Normal file
1
changelog.d/maintenance/11965-nightly-jobs-omni-light.md
Normal file
@@ -0,0 +1 @@
|
||||
- Move the four nightly jobs that build the backend (`nightly-schemathesis`, `nightly-llm-security` promptfoo + garak, `nightly-resilience` axe-a11y) off the hosted 7 GB runner — where they died on `release/v3.8.51` unseen — onto the box's new `omni-light` pool (two listeners), and document the reshaped fleet (4 active OmniRoute listeners: 2 `omni-build` + 2 `omni-light`, janitor ceiling 4) (Closes #11965)
|
||||
@@ -0,0 +1 @@
|
||||
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it
|
||||
@@ -0,0 +1 @@
|
||||
- CI hardening on the PR rail: the four `Unit Tests fast-path` shards get `timeout-minutes: 30` (a hung shard held a PR for 64 min instead of GitHub's 6 h default) and both ESLint file caches lose their `restore-keys` fallback, so a cache built under another suppressions file or lint config can no longer report stale verdicts (Refs #11600, #11924)
|
||||
4
changelog.d/maintenance/ci-omni-build-runner-label.md
Normal file
4
changelog.d/maintenance/ci-omni-build-runner-label.md
Normal file
@@ -0,0 +1,4 @@
|
||||
- Every CI job that runs a `next build` (`build`, the npm `publish`, both release-green
|
||||
validations) now targets the `omni-build` runner label, which only two of the eight
|
||||
self-hosted runners carry. The box holds one build comfortably and two at the edge; a
|
||||
third now queues on GitHub instead of being OOM-killed by the kernel.
|
||||
@@ -2,7 +2,8 @@
|
||||
"_comment": "Allowlist anti-slopsquatting (check-deps.mjs). Toda dep nova exige adicao EXPLICITA aqui apos verificar que e legitima.",
|
||||
"_justifications": {
|
||||
"@testing-library/dom": "Peer dep obrigatoria de @testing-library/react v16 (adicionada no PR #11224); Refs #9985.",
|
||||
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985."
|
||||
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985.",
|
||||
"eslint-plugin-react-hooks": "React Hooks lint rules (set-state-in-effect, immutability, refs, purity) pinned at 7.0.1 by the release/v3.8.51 cycle; the 224 findings it raised are tracked in #11924. Refs #11924."
|
||||
},
|
||||
"allowed": [
|
||||
"@atjsh/llmlingua-2",
|
||||
@@ -48,8 +49,8 @@
|
||||
"clsx",
|
||||
"commander",
|
||||
"concurrently",
|
||||
"cross-env",
|
||||
"cron-parser",
|
||||
"cross-env",
|
||||
"csv-stringify",
|
||||
"ctrf",
|
||||
"dompurify",
|
||||
@@ -60,6 +61,7 @@
|
||||
"esbuild",
|
||||
"eslint",
|
||||
"eslint-config-next",
|
||||
"eslint-plugin-react-hooks",
|
||||
"eslint-plugin-sonarjs",
|
||||
"express",
|
||||
"fast-check",
|
||||
@@ -102,9 +104,9 @@
|
||||
"node-loader",
|
||||
"node-machine-id",
|
||||
"omniglyph",
|
||||
"onnxruntime-node",
|
||||
"open",
|
||||
"opencode-ai",
|
||||
"onnxruntime-node",
|
||||
"ora",
|
||||
"parse5",
|
||||
"pino",
|
||||
@@ -131,10 +133,10 @@
|
||||
"tailwind-merge",
|
||||
"tailwindcss",
|
||||
"tls-client-node",
|
||||
"turndown",
|
||||
"turndown-plugin-gfm",
|
||||
"tsup",
|
||||
"tsx",
|
||||
"turndown",
|
||||
"turndown-plugin-gfm",
|
||||
"type-coverage",
|
||||
"typescript",
|
||||
"typescript-eslint",
|
||||
|
||||
@@ -401,11 +401,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"open-sse/services/adobeFireflyClient.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"open-sse/services/adobeFireflySession.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -2566,11 +2561,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/providers/validation/webProvidersA.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/lib/providers/validation/webProvidersB.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -6532,4 +6522,4 @@
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"_rebaseline_2026_07_03_v3844_ipfilter_release_green": "testFrozen bumps: models-catalog-route 1507->1600, perplexity-web 959->999, route-edge-coverage 1234->1241 (last is my #5975 comment +7). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.",
|
||||
"_rebaseline_2026_07_03_v3844_residual_release_green": "Residual file-size drift on tip 716041223: providerLimits.ts 955->982 + accountFallback.ts 1790->1864 (production god-files grown by parallel-session merges e.g. #6128; ideally DECOMPOSE not rebaseline, tracked as debt) + sse-auth.test.ts 1553->1600. None mine.",
|
||||
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
|
||||
"_rebaseline_2026_08_28_mergebatch_v3851_noxzrcw_skills_pipeline_drift": "/merge-batch 2026-08-28 (v3.8.51): tests/integration/skills-pipeline.test.ts already measured 1008->1009 (gate) on the pure release/v3.8.51 tip before boarding any PR in this batch (#11883/#11881/#11880/#11879 — none touch this file); pre-existing drift inherited from an earlier already-merged PR, rebaselined here so the gate stays green for this batch.",
|
||||
"_rebaseline_2026_07_09_pr6647_winget_claude_detect": "PR #6647 (enjoyer-hub, /implement-prs sync): cliRuntime.ts 1100->1110 (split('\\n').length metric; +10, was already exactly at the 1100 frozen cap). Adds the WinGet-installed Claude Code fallback path (%LOCALAPPDATA%\\Microsoft\\WinGet\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe) to getKnownToolPaths() alongside the two sibling Claude Code paths, so WinGet installs are auto-detected without CLI_CLAUDE_BIN. The package folder name (62 chars) forces Prettier's 100-char width to break the path.join call across the full 10-line multi-arg form used elsewhere in this same function for long paths; irreducible without changing the shared getKnownToolPaths() structure. Covered by the PR's own regression test (tests/unit/cli-runtime-detection.test.ts, win32-gated).",
|
||||
"_rebaseline_2026_07_03_review_prs_release_green": "Release-green unblock (2026-07-03, /review-prs): the quality.yml fast-gates job was base-red for EVERY PR->release from growth inherited via already-merged PRs on the release tip — no offending PR branch left to fix in-place. Prod frozen raised: ApiManagerPageClient.tsx 3017->3058, OAuthModal.tsx 969->989, cliRuntime.ts 1090->1100, webProvidersA.ts 805->809. Test frozen raised: deepseek-web.test.ts 1081->1092. Real sizes (check-file-size.mjs reported). These stay frozen (cannot grow further); structural shrink tracked under decomposition roadmap #3501; the release captain's rebaseline-at-release supersedes this note. Bundled with the #5695 quick-start test regex fix (multi-line <Link> tolerance) in the same release-green PR.",
|
||||
"_rebaseline_2026_07_02_5798_release_green": "Release-green unblock #5798 / PR #5896 (2026-07-02): the quality.yml fast-gates job was base-red for EVERY PR->release (whole queue failing), from growth inherited via already-merged PRs — no offending PR branch left to fix. Prod frozen raised: AddApiKeyModal.tsx 869->905, providerPageHelpers.ts 996->1021, RequestLoggerV2.tsx 1316->1553, src/sse/services/auth.ts 2403->2405, antigravity.ts 1806->1813, base.ts 1502->1536 (1533 inherited + 3 lines from this PR's own typecheck:core fix in resolveBaseUrl), advancedTools.ts 1118->1120, accountFallback.ts 1783->1790, openai-to-kiro.ts 842->853, openai-responses.ts 1035->1092, stream.ts 2710->2727; new-above-cap frozen: webProvidersA.ts 805, tokenHealthCheck.ts 830. Test frozen raised: cc-compatible-provider 1179->1217, translator-openai-to-kiro 999->1088, web-cookie-providers-new 827->845; new-above-cap: response-sanitizer.test.ts 906. These files remain frozen (cannot grow further); the release captain's rebaseline-at-release supersedes this note.",
|
||||
@@ -195,7 +196,7 @@
|
||||
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
|
||||
"tests/integration/chat-pipeline.test.ts": 2077,
|
||||
"tests/integration/chatcore-compression-integration.test.ts": 1448,
|
||||
"tests/integration/skills-pipeline.test.ts": 1006,
|
||||
"tests/integration/skills-pipeline.test.ts": 1009,
|
||||
"tests/unit/account-fallback-service.test.ts": 2032,
|
||||
"tests/unit/adobe-firefly.test.ts": 1477,
|
||||
"tests/unit/batch_api.test.ts": 1721,
|
||||
|
||||
@@ -82,9 +82,10 @@
|
||||
"tightenSlack": 10
|
||||
},
|
||||
"openapiCoverage.pct": {
|
||||
"value": 38.4,
|
||||
"value": 39,
|
||||
"direction": "up",
|
||||
"eps": 0.5,
|
||||
"_tighten_2026_08_28_v3851_eslint_refreeze": "38.4 -> 39. Aperto EXIGIDO pelo step --require-tighten do job No new ESLint warnings na PR #11955 (release/v3.8.51): assim que o ESLint voltou a medir 0/0, o ratchet passou a cobrar o aperto. 39 = valor medido pelo collect-metrics do CI no run 33213844112 e reproduzido numa sala limpa da ponta 777d9d1629 (clone --depth 1 + npm ci do lockfile). A cobertura melhorou porque as rotas novas do ciclo entraram documentadas em docs/openapi.yaml; nenhuma rota tocada nesta PR. Aperto = gate mais ESTRITO, nunca mascaramento.",
|
||||
"_rebaseline_2026_08_21_v3850_cycle_drift": "39.2 -> 38.4. Measured locally and in CI collect-metrics on release/v3.8.50 (260/677 implemented routes documented). Cycle added internal/dashboard routes faster than docs/openapi.yaml; documenting LOCAL_ONLY catch-all and service-management paths in the public spec would be gaming (same class as v3.8.34/v3.8.39/v3.8.47). This PR (#10988) adds 0 API routes.",
|
||||
"_tighten_2026_08_06_v3850_sweepreds": "38.0 -> 39.2 (aperto EXIGIDO pelo step 'Require-tighten (blocking)', que estava vermelho em ~60 PRs abertas de release/v3.8.50 — base-red herdado, nao defeito das PRs). A cobertura melhorou no ciclo porque as rotas novas entraram documentadas. 39.2 = valor medido pelo CI Quality Ratchet no run 31088889488; o tip puro 2ddbbc61a6 mede 39.3 localmente (npm run check:openapi-coverage: 247/628 rotas), entao 39.2 e o valor conservador dos dois. Aperto = gate mais ESTRITO, nunca mascaramento.",
|
||||
"_tighten_2026_07_04_v3844_release": "36.9 -> 39.3 (aperto exigido pelo --require-tighten no PR de release #5925). A cobertura OpenAPI melhorou no ciclo (9 rotas documentadas em 8fb020676 + as rotas novas de #5939/#5817/#6034/#5998 documentadas junto das features). 39.3 = valor medido pelo CI Quality Ratchet no run 28708141003 (tip 00c55afcb).",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: "Release Checklist"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-08-28
|
||||
---
|
||||
|
||||
# Release Checklist
|
||||
|
||||
> **Last updated:** 2026-06-28 — v3.8.40
|
||||
> **Last updated:** 2026-08-28 — v3.8.51
|
||||
> Streamlined release flow that leverages Claude Code skills for automation.
|
||||
>
|
||||
> **Keep the queue/branch green between releases:** see [RELEASE_GREEN.md](./RELEASE_GREEN.md)
|
||||
@@ -37,7 +37,21 @@ npm run test:e2e # optional but recommended
|
||||
/capture-release-evidences-cc
|
||||
```
|
||||
|
||||
## npm Staged Publishing (default since v3.8.49 — WS1.3/D2)
|
||||
## npm Trusted Publishing (default since v3.8.51) — staged on request, direct as fallback
|
||||
|
||||
`npm-publish.yml` publishes through **npm Trusted Publishing (OIDC)** by default: the
|
||||
`stage-npm` job (github-hosted) exchanges GitHub's id-token for a short-lived npm
|
||||
credential for that run — no long-lived npm token in the repository secrets, no 2FA prompt, provenance attached.
|
||||
That is the bypass npm sanctions now that tokens which skip 2FA are being retired;
|
||||
it restores the fully automatic flow the project had up to v3.8.48 while keeping the
|
||||
WS1.3 guarantee (a leaked token cannot publish alone — there is no token).
|
||||
|
||||
**One-time setup (owner):** npmjs.com → package `omniroute` → Settings → *Trusted
|
||||
Publisher* → GitHub: owner `diegosouzapw`, repo `OmniRoute`, workflow `npm-publish.yml`
|
||||
(environment: none). Until that exists, the automatic step fails with `ENEEDAUTH`:
|
||||
re-dispatch with `publish_mode=staged` (below) or `direct`.
|
||||
|
||||
### Staged publishing (on request — `publish_mode=staged`)
|
||||
|
||||
The npm-publish workflow no longer publishes directly: it boots the packed tarball
|
||||
(`check:pack-boot`) and then runs `npm stage publish` — the exact bytes are parked on
|
||||
|
||||
@@ -4,32 +4,75 @@ title: Self-Hosted Runner Box Operations
|
||||
|
||||
# Self-Hosted Runner Box Operations (.113 pool)
|
||||
|
||||
The self-hosted pool (`self-hosted, omni-release` labels) runs on the 16 GB box at
|
||||
`192.168.0.113`. Two failure modes recurred on release days and were, until v3.8.49,
|
||||
manual discipline; the **janitor script codifies them** (WS3.3 of the quality plan):
|
||||
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):
|
||||
|
||||
1. **Orphaned temp/work dirs** filling the disk → disk-full SQLite errors mid-job.
|
||||
2. **>4 concurrent runners** → OOM-killed jobs (8-wide killed jobs twice on the
|
||||
v3.8.47 release day; 4-wide is the proven ceiling).
|
||||
| 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) |
|
||||
|
||||
## Install the janitor (one-time, on the box)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/omniroute-ops
|
||||
sudo cp scripts/ops/runner-janitor.sh /opt/omniroute-ops/
|
||||
sudo chmod +x /opt/omniroute-ops/runner-janitor.sh
|
||||
( sudo crontab -l 2>/dev/null; echo '*/30 * * * * /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1' ) | sudo crontab -
|
||||
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
|
||||
```
|
||||
|
||||
What it does every 30min: sweeps runner temp leftovers older than 24h, alerts at
|
||||
≥85% root-disk usage, and alerts when more than the runner ceiling (default 4, tunable
|
||||
via the script's own environment) of `Runner.Listener` processes are up. Alerts land in `/var/log/runner-janitor.log`
|
||||
with a non-zero exit (grep for `⚠`).
|
||||
`lsof` is required: the janitor proves a path is idle with one snapshot of open
|
||||
files before removing it, and without the tool it removes nothing and says so
|
||||
(exit 1). Try any change with `--dry-run` first — it prints exactly what it would
|
||||
do and touches nothing.
|
||||
|
||||
What it does every run: sweeps our own leftovers (`runner-*`, `omniroute-*`,
|
||||
`next-build*`, `e2e-build.tar.gz`) after **3 h on tmpfs** and 24 h on disk
|
||||
`_work/_temp`; kills a `next-build` older than 75 min (no job runs that long — on
|
||||
2026-08-27 one ran 70 min after GitHub had declared its job lost); prunes 48 h-old
|
||||
checkouts of runners whose unit is **stopped**; alerts on disk ≥ 85 %, memory PSI
|
||||
`full/avg60` ≥ 10 %, and more listeners than `MAX_ACTIVE_RUNNERS` (with an
|
||||
omniroute/other breakdown). Exit 1 = attention needed; read the log.
|
||||
|
||||
## Runner units: KillMode
|
||||
|
||||
The runner's default `KillMode=process` leaves `Runner.Worker → npm → next-build`
|
||||
alive when a unit is stopped or restarted — an orphan build keeps eating RAM and
|
||||
CPU with no job attached. Every OmniRoute unit carries a drop-in
|
||||
(`/etc/systemd/system/actions.runner.diegosouzapw-OmniRoute.<name>.service.d/10-killmode.conf`)
|
||||
with `KillMode=mixed`: SIGTERM to the listener first, SIGKILL to the whole cgroup at
|
||||
`TimeoutStop`. It takes effect on the unit's next restart — restart **one runner at
|
||||
a time, only when idle**, with the idle check and the restart in the same command.
|
||||
|
||||
## Operating rules
|
||||
|
||||
- **Ceiling: 4 runners** on the 16 GB box. Runners 5–8 stay STOPPED except for
|
||||
explicit off-peak experiments — never during a release window.
|
||||
- Stopping a runner mid-job cancels the job (observed live): `systemctl stop`
|
||||
only when its runner is idle (`Runner.Listener` without a `Runner.Worker` child).
|
||||
- **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).
|
||||
- **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
|
||||
died on `release/v3.8.51` with nobody watching. Worst case on the box is 2 heavy + 2 light ≈
|
||||
30 + 12 GB — over 31 GB of RAM, inside the 16 GB of swap; the real fix for headroom is more RAM
|
||||
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).
|
||||
- **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;
|
||||
let it.
|
||||
- Stopping a runner mid-job cancels the job (observed live): `systemctl stop` only
|
||||
when its listener has no `Runner.Worker` child — and do it in one command.
|
||||
- Workflows must not park artefacts in `/tmp` (it is RAM). Download to
|
||||
`$RUNNER_TEMP` (on disk, per runner) — the 1.3 GB `next-build` artefact took 27–32
|
||||
minutes to land on the tmpfs and 2 minutes to upload from disk.
|
||||
- The `.15` VPS is homologation-only — never runs CI runners.
|
||||
|
||||
@@ -130,4 +130,4 @@ Retirement regression guards live in:
|
||||
- `tests/unit/chatgpt-web-runtime-block.test.ts`
|
||||
- `tests/unit/chatgpt-web-image-handler-retirement.test.ts`
|
||||
- `tests/unit/chatgpt-web-source-retirement.test.ts`
|
||||
- `tests/unit/migration-163-retire-chatgpt-web.test.ts`
|
||||
- `tests/unit/migration-168-retire-chatgpt-web.test.ts`
|
||||
|
||||
@@ -59,6 +59,9 @@ These **must** be set before the first run. Without them, the application will e
|
||||
| `API_KEY_SECRET` | **Yes** | _(none)_ | `src/lib/db/apiKeys.ts` | AES encryption key for API key values at rest in SQLite. Generate with `openssl rand -hex 32`. |
|
||||
| `INITIAL_PASSWORD` | **Yes** | `CHANGEME` | Bootstrap script | Sets the initial admin dashboard password (matches `.env.example` default — kept obviously insecure to force a change). **Change before first use.** After login, change via Dashboard → Settings → Security. |
|
||||
| `OMNIROUTE_WS_BRIDGE_SECRET` | **Yes** (production) | _(unset)_ | `src/app/api/internal/codex-responses-ws/route.ts` | Shared secret for the internal Codex Responses WebSocket bridge. Authenticates bridge requests between the Electron/browser WS relay and OmniRoute. ⚠️ **REQUIRED in production — when unset, all WS bridge requests are rejected.** Generate with `openssl rand -base64 32`. |
|
||||
| `OMNIROUTE_SW_BUILD_ID` | No | _(git SHA)_ | `next.config.mjs`, `scripts/build/assembleStandalone.mjs` | Explicit service-worker cache-busting id for the PWA shell (#11779); first in the resolution chain. |
|
||||
| `SOURCE_VERSION` | No | _(unset)_ | `next.config.mjs`, `scripts/build/assembleStandalone.mjs` | Second in the chain — set by PaaS builders (e.g. Heroku-style) as the deployed commit. |
|
||||
| `NEXT_PUBLIC_SW_BUILD_ID` | No | _(derived)_ | `src/shared/components/PwaRegister.tsx` | Build-time public value the client uses to register `/sw.js?v=…`; derived from the two above, then the git SHA. |
|
||||
| `OMNIROUTE_PEER_STAMP_TOKEN` | No (auto) | _(auto per boot)_ | `src/server/authz/policies/management.ts` | Per-process secret proving the trusted peer-IP stamp came from OmniRoute's own HTTP server (`scripts/dev/peer-stamp.mjs`). The authz middleware trusts request locality (loopback/LAN gating of LOCAL_ONLY routes) only when the stamp carries this token. Auto-generated each boot — leave unset; only pin it for multi-process setups that must share the stamp. |
|
||||
|
||||
### Generation Commands
|
||||
|
||||
@@ -265,7 +265,7 @@ export function orderHeaders(
|
||||
* Apply a CLI fingerprint to headers and body.
|
||||
* Returns { headers, bodyString } with the correct ordering.
|
||||
*/
|
||||
function stripInternalBodyFields(body: unknown): unknown {
|
||||
export function stripInternalBodyFields(body: unknown): unknown {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||||
|
||||
const record = body as Record<string, unknown>;
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
addParamToBlocklist,
|
||||
isAutoLearnGloballyEnabled,
|
||||
} from "@/lib/db/paramFilters";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts";
|
||||
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
|
||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||
import {
|
||||
@@ -582,6 +582,8 @@ export class BaseExecutor {
|
||||
if (cloned[key] === "") delete cloned[key];
|
||||
}
|
||||
|
||||
stripInternalBodyFields(cloned);
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
@@ -1393,6 +1395,7 @@ export class BaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
stripInternalBodyFields(transformedBody);
|
||||
let bodyString = JSON.stringify(transformedBody);
|
||||
|
||||
const shouldFingerprint =
|
||||
|
||||
@@ -2808,7 +2808,12 @@ export async function handleChatCore({
|
||||
log?.debug?.("PARAMS", `Renamed max_completion_tokens to max_tokens for ${model}`);
|
||||
}
|
||||
|
||||
stripStore(translatedBody, provider, targetFormat);
|
||||
stripStore(
|
||||
translatedBody,
|
||||
provider,
|
||||
targetFormat,
|
||||
credentials?.providerSpecificData as Record<string, unknown> | null | undefined
|
||||
);
|
||||
|
||||
// Chat clients may send stream_options.include_usage, but OpenAI Responses
|
||||
// upstreams (including Azure AI Foundry /responses) reject stream_options.
|
||||
|
||||
@@ -26,8 +26,21 @@ export function usesClaudeBridge(
|
||||
export function stripStore(
|
||||
body: Record<string, unknown>,
|
||||
provider: string,
|
||||
targetFormat: string
|
||||
targetFormat: string,
|
||||
providerSpecificData?: unknown
|
||||
): void {
|
||||
if (provider.startsWith("openai-compatible-") && targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
const psd =
|
||||
providerSpecificData && typeof providerSpecificData === "object"
|
||||
? (providerSpecificData as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (psd?.openaiStoreEnabled === true) {
|
||||
return;
|
||||
}
|
||||
body.store = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const supportsStore =
|
||||
provider === "openai" ||
|
||||
(provider === "agentrouter" && targetFormat === FORMATS.OPENAI_RESPONSES);
|
||||
|
||||
@@ -226,45 +226,6 @@ export const NANO_SIZE_MAP: Record<string, Record<string, { width: number; heigh
|
||||
},
|
||||
};
|
||||
|
||||
const GPT_SIZE_MAP: Record<string, Record<string, { width: number; height: number }>> = {
|
||||
"1K": {
|
||||
"1:1": { width: 1024, height: 1024 },
|
||||
"5:4": { width: 1120, height: 896 },
|
||||
"9:16": { width: 720, height: 1280 },
|
||||
"21:9": { width: 1456, height: 624 },
|
||||
"16:9": { width: 1280, height: 720 },
|
||||
"4:3": { width: 1152, height: 864 },
|
||||
"3:2": { width: 1248, height: 832 },
|
||||
"4:5": { width: 896, height: 1120 },
|
||||
"3:4": { width: 864, height: 1152 },
|
||||
"2:3": { width: 832, height: 1248 },
|
||||
},
|
||||
"2K": {
|
||||
"1:1": { width: 2048, height: 2048 },
|
||||
"5:4": { width: 2240, height: 1792 },
|
||||
"9:16": { width: 1440, height: 2560 },
|
||||
"21:9": { width: 3024, height: 1296 },
|
||||
"16:9": { width: 2560, height: 1440 },
|
||||
"4:3": { width: 2304, height: 1728 },
|
||||
"3:2": { width: 2496, height: 1664 },
|
||||
"4:5": { width: 1792, height: 2240 },
|
||||
"3:4": { width: 1728, height: 2304 },
|
||||
"2:3": { width: 1664, height: 2496 },
|
||||
},
|
||||
"4K": {
|
||||
"1:1": { width: 2880, height: 2880 },
|
||||
"5:4": { width: 3200, height: 2560 },
|
||||
"9:16": { width: 2160, height: 3840 },
|
||||
"21:9": { width: 3696, height: 1584 },
|
||||
"16:9": { width: 3840, height: 2160 },
|
||||
"4:3": { width: 3264, height: 2448 },
|
||||
"3:2": { width: 3504, height: 2336 },
|
||||
"4:5": { width: 2560, height: 3200 },
|
||||
"3:4": { width: 2448, height: 3264 },
|
||||
"2:3": { width: 2336, height: 3504 },
|
||||
},
|
||||
};
|
||||
|
||||
export const PIXEL_SIZE_TO_RATIO: Record<string, string> = {
|
||||
"1024x1024": "1:1",
|
||||
"1536x1536": "1:1",
|
||||
|
||||
@@ -173,26 +173,6 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [
|
||||
}
|
||||
),
|
||||
|
||||
// ── Volcano Engine Ark Console ───────────────────────────
|
||||
config(
|
||||
"volcengine-console",
|
||||
"Volcano Engine Ark Console",
|
||||
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
|
||||
"https://console.volcengine.com",
|
||||
[
|
||||
{ type: "cookie", name: "digest", domain: ".volcengine.com" },
|
||||
{ type: "cookie", name: "AccountID", domain: ".volcengine.com" },
|
||||
{ type: "cookie", name: "csrfToken", domain: ".volcengine.com" },
|
||||
{ type: "cookie", name: "userInfo", domain: ".volcengine.com" },
|
||||
],
|
||||
"Log in to the Volcano Engine Ark console. The console session is used to discover Agent/Coding Plan API keys and live quota usage.",
|
||||
{
|
||||
cookieDomain: ".volcengine.com",
|
||||
successUrlPattern: /console\.volcengine\.com\/ark/i,
|
||||
pollingConfig: { timeout: 300_000, minLoginTime: 3000 },
|
||||
}
|
||||
),
|
||||
|
||||
// ── Kimi Web ──────────────────────────────────────────────
|
||||
config(
|
||||
"kimi-web",
|
||||
|
||||
@@ -194,50 +194,75 @@ function stopTextBlock(state, results) {
|
||||
state.textBlockStarted = false;
|
||||
}
|
||||
|
||||
// Convert OpenAI stream chunk to Claude format
|
||||
export function openaiToClaudeResponse(chunk, state) {
|
||||
if (!chunk || !chunk.choices?.[0]) return null;
|
||||
// Harvest the upstream usage block from any chunk, including trailing
|
||||
// usage-only chunks that carry `choices: []` (#11817).
|
||||
function trackUsageFromChunk(chunk, state) {
|
||||
if (!chunk.usage || typeof chunk.usage !== "object") return;
|
||||
const promptTokens =
|
||||
typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
|
||||
const outputTokens =
|
||||
typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
|
||||
|
||||
const results = [];
|
||||
const choice = chunk.choices[0];
|
||||
const delta = choice.delta;
|
||||
// Extract cache tokens from prompt_tokens_details
|
||||
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
|
||||
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
|
||||
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
|
||||
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
|
||||
|
||||
// Track usage from OpenAI chunk if available
|
||||
if (chunk.usage && typeof chunk.usage === "object") {
|
||||
const promptTokens =
|
||||
typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
|
||||
const outputTokens =
|
||||
typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
|
||||
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
|
||||
// Because OpenAI's prompt_tokens includes all prompt-side tokens
|
||||
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
|
||||
|
||||
// Extract cache tokens from prompt_tokens_details
|
||||
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
|
||||
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
|
||||
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
|
||||
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
|
||||
state.usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
};
|
||||
|
||||
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
|
||||
// Because OpenAI's prompt_tokens includes all prompt-side tokens
|
||||
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
|
||||
|
||||
state.usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
};
|
||||
|
||||
// Add cache_read_input_tokens if present
|
||||
if (cacheReadTokens > 0) {
|
||||
state.usage.cache_read_input_tokens = cacheReadTokens;
|
||||
}
|
||||
|
||||
// Add cache_creation_input_tokens if present
|
||||
if (cacheCreateTokens > 0) {
|
||||
state.usage.cache_creation_input_tokens = cacheCreateTokens;
|
||||
}
|
||||
|
||||
// Note: completion_tokens_details.reasoning_tokens is already included in output_tokens
|
||||
// No need to add separately as Claude expects total output_tokens
|
||||
// Add cache_read_input_tokens if present
|
||||
if (cacheReadTokens > 0) {
|
||||
state.usage.cache_read_input_tokens = cacheReadTokens;
|
||||
}
|
||||
|
||||
// Add cache_creation_input_tokens if present
|
||||
if (cacheCreateTokens > 0) {
|
||||
state.usage.cache_creation_input_tokens = cacheCreateTokens;
|
||||
}
|
||||
|
||||
// Note: completion_tokens_details.reasoning_tokens is already included in output_tokens
|
||||
// No need to add separately as Claude expects total output_tokens
|
||||
}
|
||||
|
||||
// Convert OpenAI stream chunk to Claude format
|
||||
export function openaiToClaudeResponse(chunk, state) {
|
||||
if (!chunk && !state.pendingClaudeFinishChoice) return null;
|
||||
|
||||
const results = [];
|
||||
const chunkUsage = chunk?.usage;
|
||||
const hasChunkUsage = chunkUsage && typeof chunkUsage === "object";
|
||||
|
||||
// Usage must be harvested BEFORE the choices guard: many OpenAI-compatible
|
||||
// upstreams (Fireworks, vLLM, Together, …) deliver the authoritative usage
|
||||
// block — including prompt_tokens_details.cached_tokens — on a trailing
|
||||
// usage-only chunk shaped `{"choices":[],"usage":{...}}`. Returning early on
|
||||
// that chunk discarded the real numbers and left downstream accounting on
|
||||
// OmniRoute's own tokenizer estimate (#11817).
|
||||
//
|
||||
// Harvesting alone is not enough: if the finish_reason chunk arrives BEFORE
|
||||
// this trailing usage chunk (the normal order for these upstreams), the
|
||||
// finish block below fires immediately and emits message_delta with
|
||||
// whatever state.usage held at that moment — zero/stale, since the real
|
||||
// trailing chunk hasn't been seen yet. The finish deferral below
|
||||
// (pendingClaudeFinishChoice) holds the terminal emission open until either
|
||||
// real usage has arrived or a genuine flush forces it, so the message_delta
|
||||
// actually sent to the client carries the correct numbers (#11817 follow-up).
|
||||
if (chunk) trackUsageFromChunk(chunk, state);
|
||||
|
||||
const chunkChoice = chunk?.choices?.[0];
|
||||
const flushingPendingFinish = !chunkChoice && Boolean(state.pendingClaudeFinishChoice);
|
||||
const choice = chunkChoice || state.pendingClaudeFinishChoice;
|
||||
if (!choice) return null;
|
||||
if (flushingPendingFinish) state.pendingClaudeFinishChoice = null;
|
||||
const delta = choice.delta;
|
||||
// First chunk - ALWAYS send message_start first
|
||||
if (!state.messageStartSent) {
|
||||
state.messageStartSent = true;
|
||||
@@ -489,6 +514,11 @@ export function openaiToClaudeResponse(chunk, state) {
|
||||
// guard therefore misfired and silently dropped the terminal message_delta/message_stop
|
||||
// for Responses→Claude streams (#5828 regression).
|
||||
if (choice.finish_reason && !state.claudeFinishEmitted) {
|
||||
if (!hasChunkUsage && !flushingPendingFinish) {
|
||||
state.pendingClaudeFinishChoice = choice;
|
||||
return results.length > 0 ? results : null;
|
||||
}
|
||||
|
||||
state.claudeFinishEmitted = true;
|
||||
stopThinkingBlock(state, results);
|
||||
stopTextBlock(state, results);
|
||||
|
||||
@@ -49,13 +49,15 @@ export function socksConnectorWithFamily(
|
||||
const isDisabled = connectTimeout === 0;
|
||||
// SOCKS lib: 0 throws (isValidTimeoutValue: value>0) and undefined → DEFAULT_TIMEOUT 30s;
|
||||
// undici: 0 disables (core/util.js: if (!opts.timeout) return noop), undefined → 10s. Divergence intentional.
|
||||
const handshakeTimeout = isDisabled ? undefined : (connectTimeout ?? resolveSocksHandshakeTimeoutMs());
|
||||
const handshakeTimeout = isDisabled
|
||||
? undefined
|
||||
: (connectTimeout ?? resolveSocksHandshakeTimeoutMs());
|
||||
const tlsTimeout = connectTimeout;
|
||||
// Sequential budget: both phases bounded by the same connectTimeout → wall-time up to 60s for https
|
||||
// (vs 30s direct). Shared-deadline alternative rejected as unjustified complexity.
|
||||
const build = _buildConnectorForTest ?? buildConnector;
|
||||
const undiciConnect = build(
|
||||
tlsTimeout !== undefined ? ({ ...tlsOpts, timeout: tlsTimeout } as any) : tlsOpts
|
||||
tlsTimeout !== undefined ? { ...tlsOpts, timeout: tlsTimeout } : tlsOpts
|
||||
);
|
||||
const socketOptions = buildSocksFamilySocketOptions(family);
|
||||
return async (options, callback) => {
|
||||
@@ -69,7 +71,7 @@ export function socksConnectorWithFamily(
|
||||
const r = await SocksClient.createConnection({
|
||||
command: "connect",
|
||||
proxy,
|
||||
timeout: handshakeTimeout as any,
|
||||
timeout: handshakeTimeout,
|
||||
destination: { host: hostname, port: resolvePort(protocol, port) },
|
||||
existing_socket: httpSocket as never,
|
||||
socket_options: socketOptions as never,
|
||||
@@ -97,6 +99,6 @@ export function createSocksDispatcherWithFamily(
|
||||
};
|
||||
return new Agent({
|
||||
...rest,
|
||||
connect: socksConnectorWithFamily(proxy, family, connect as any, connectTimeout as any),
|
||||
connect: socksConnectorWithFamily(proxy, family, connect, connectTimeout),
|
||||
});
|
||||
}
|
||||
|
||||
101
scripts/ad-hoc/codemod-rm-maxretries.mjs
Normal file
101
scripts/ad-hoc/codemod-rm-maxretries.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-shot codemod (#11966): give every recursive temp-dir removal in tests the retry
|
||||
* options Node already supports, so a WAL/backup/worker still writing into the directory
|
||||
* turns into a retried delete instead of a red shard:
|
||||
*
|
||||
* rmSync(dir, { recursive: true, force: true })
|
||||
* → rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
*
|
||||
* Applies to `rmSync(`, `fs.rmSync(`, `rm(` / `fs.rm(` / `fs.promises.rm(` (async) and
|
||||
* `rmdirSync(` calls whose option object literal contains `recursive: true` and no
|
||||
* `maxRetries`. Only the option object is touched — call sites, assertions and imports are
|
||||
* left as they are. Usage: node scripts/ad-hoc/codemod-rm-maxretries.mjs [dir=tests]
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.argv[2] || "tests";
|
||||
const CALL = /\b(?:fs\.promises\.|fsp\.|fs\.|promises\.)?(?:rmSync|rmdirSync|rm)\(/g;
|
||||
let files = 0;
|
||||
let sites = 0;
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === "node_modules" || e.name === "fixtures") continue;
|
||||
walk(p, out);
|
||||
} else if (/\.(ts|tsx|mts|cts|js|mjs|cjs)$/.test(e.name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the closing brace of the option object literal that starts at `open`.
|
||||
function objectEnd(src, open) {
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
const c = src[i];
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
} else if (c === '"' || c === "'" || c === "`") {
|
||||
const q = c;
|
||||
i++;
|
||||
while (i < src.length && src[i] !== q) {
|
||||
if (src[i] === "\\") i++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (const file of walk(root)) {
|
||||
const src = fs.readFileSync(file, "utf8");
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let touched = 0;
|
||||
for (const m of src.matchAll(CALL)) {
|
||||
const callStart = m.index + m[0].length;
|
||||
// Locate the option object: the first `{` before the call's closing paren at depth 0.
|
||||
let depth = 0;
|
||||
let objOpen = -1;
|
||||
for (let i = callStart; i < src.length; i++) {
|
||||
const c = src[i];
|
||||
if (c === "(" || c === "[") depth++;
|
||||
else if (c === ")" || c === "]") {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
} else if (c === "{" && depth === 0) {
|
||||
objOpen = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (objOpen === -1) continue;
|
||||
const objClose = objectEnd(src, objOpen);
|
||||
if (objClose === -1) continue;
|
||||
const obj = src.slice(objOpen, objClose + 1);
|
||||
if (!/\brecursive:\s*true\b/.test(obj) || /\bmaxRetries\b/.test(obj)) continue;
|
||||
// Insert before the closing brace, respecting an existing trailing comma / newline.
|
||||
const inner = obj.slice(1, -1);
|
||||
const trimmed = inner.replace(/\s+$/, "");
|
||||
const trailing = inner.slice(trimmed.length);
|
||||
const sep = trimmed.endsWith(",") ? " " : ", ";
|
||||
const multiline = /\n/.test(trailing);
|
||||
const insert = multiline
|
||||
? `${trimmed}${trimmed.endsWith(",") ? "" : ","}\n${trailing.replace(/\n$/, "")} maxRetries: 5,\n retryDelay: 100,${trailing}`
|
||||
: `${trimmed}${sep}maxRetries: 5, retryDelay: 100${trailing}`;
|
||||
out += src.slice(last, objOpen + 1) + insert;
|
||||
last = objClose;
|
||||
touched++;
|
||||
}
|
||||
if (touched) {
|
||||
out += src.slice(last);
|
||||
fs.writeFileSync(file, out);
|
||||
files++;
|
||||
sites += touched;
|
||||
}
|
||||
}
|
||||
console.log(`[codemod-rm-maxretries] ${sites} call site(s) in ${files} file(s) under ${root}`);
|
||||
@@ -173,7 +173,7 @@ function arg(name, fallback = "") {
|
||||
}
|
||||
|
||||
function git(root, args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8" });
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
function changedEntries(root, base) {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { findProvenanceOnSelfHosted, formatProvenanceFinding } from "./lib/provenanceRunner.mjs";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
|
||||
@@ -275,6 +276,23 @@ export function runZizmor(workflowsDir) {
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hard rule (not a lint count): `--provenance` inside a job that runs on a
|
||||
* self-hosted runner. npm answers 422 at the registry, and in v3.8.50 that
|
||||
* answer only came after the tag, the GitHub Release and the Docker images were
|
||||
* already out. Blocks under --strict AND --ratchet (the CI mode); plain mode
|
||||
* reports it like everything else.
|
||||
* @param {string[]} files absolute workflow paths
|
||||
*/
|
||||
export function runProvenanceRunnerCheck(files) {
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
findings.push(...findProvenanceOnSelfHosted(text, path.relative(ROOT, file)));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const hasActionlint = isBinaryAvailable("actionlint");
|
||||
const hasZizmor = isBinaryAvailable("zizmor");
|
||||
@@ -350,6 +368,16 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const provenanceFindings = runProvenanceRunnerCheck(workflowFiles);
|
||||
if (provenanceFindings.length > 0) {
|
||||
console.error(
|
||||
`[check-workflows] provenance×self-hosted: ${provenanceFindings.length} finding(s) — HARD RULE:`
|
||||
);
|
||||
provenanceFindings.forEach((f) => console.error(` ${formatProvenanceFinding(f)}`));
|
||||
} else if (!QUIET) {
|
||||
console.log("[check-workflows] provenance×self-hosted: OK (0 findings)");
|
||||
}
|
||||
|
||||
const total = actionlintCount + zizmorCount;
|
||||
process.stdout.write(`workflowFindings=${total}\n`);
|
||||
process.stdout.write(`actionlintFindings=${actionlintCount}\n`);
|
||||
@@ -357,6 +385,15 @@ function main() {
|
||||
// Read this line with the count above: a finding total is only reproducible against the
|
||||
// version that produced it. See zizmorVersion().
|
||||
process.stdout.write(`zizmorVersion=${hasZizmor ? zizmorVersion() : "absent"}\n`);
|
||||
process.stdout.write(`provenanceRunnerFindings=${provenanceFindings.length}\n`);
|
||||
if ((STRICT || RATCHET) && provenanceFindings.length > 0) {
|
||||
console.error(
|
||||
`\n[check-workflows] FAIL — ${provenanceFindings.length} job(s) publish with --provenance from a self-hosted runner.\n` +
|
||||
" npm rejects that with 422 at the registry. Move the upload step to a github-hosted job\n" +
|
||||
" (see .github/workflows/npm-publish.yml `stage-npm` for the pattern)."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (STRICT && total > 0) {
|
||||
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);
|
||||
|
||||
90
scripts/check/lib/configExpiry.mjs
Normal file
90
scripts/check/lib/configExpiry.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* scripts/check/lib/configExpiry.mjs
|
||||
*
|
||||
* Finds dated validity fields in JSON config packs so a test can fail BEFORE
|
||||
* they lapse. Origin: config/alibaba-free-tier-allowlist.json carried
|
||||
* `"validUntil": "2026-08-27"`; on 2026-08-28 the loader started (correctly)
|
||||
* rejecting the pack and a test that asserted "the shipped pack loads" turned
|
||||
* every PR and main red with no commit involved (#11866). A time bomb, not a
|
||||
* regression — and the only kind of defect a diff review can never catch.
|
||||
*
|
||||
* Pure helpers; the repo-wide assertion lives in
|
||||
* tests/unit/config-expiry-time-bomb.test.ts.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const EXPIRY_KEY =
|
||||
/^(validUntil|valid_until|validTo|valid_to|expiresAt|expires_at|expiry|expires)$/;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Walks a parsed JSON value and returns every string-valued expiry field.
|
||||
* @returns {{ file: string, keyPath: string, raw: string, expiresAt: number|null }[]}
|
||||
*/
|
||||
export function collectExpiryFields(value, file, keyPath = []) {
|
||||
const out = [];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v, i) => out.push(...collectExpiryFields(v, file, [...keyPath, String(i)])));
|
||||
return out;
|
||||
}
|
||||
if (!value || typeof value !== "object") return out;
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
const kp = [...keyPath, key];
|
||||
if (EXPIRY_KEY.test(key) && typeof v === "string") {
|
||||
const ms = Date.parse(v);
|
||||
out.push({ file, keyPath: kp.join("."), raw: v, expiresAt: Number.isFinite(ms) ? ms : null });
|
||||
} else if (v && typeof v === "object") {
|
||||
out.push(...collectExpiryFields(v, file, kp));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @returns {"expired"|"expiring"|"ok"|"unparseable"} */
|
||||
export function classifyExpiry(field, nowMs, warnDays = 7) {
|
||||
if (field.expiresAt === null) return "unparseable";
|
||||
if (field.expiresAt < nowMs) return "expired";
|
||||
if (field.expiresAt < nowMs + warnDays * DAY_MS) return "expiring";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
/** All *.json under dir, recursively, skipping node_modules. Sorted for stable output. */
|
||||
export function walkJsonFiles(dir) {
|
||||
const out = [];
|
||||
const stack = [dir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(current, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name !== "node_modules") stack.push(full);
|
||||
} else if (e.isFile() && e.name.endsWith(".json")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans every JSON file under `dir`; `file` in the result is relative to `dir`
|
||||
* with forward slashes, so allowlists can key on it portably.
|
||||
*/
|
||||
export function scanConfigExpiry(dir) {
|
||||
return walkJsonFiles(dir).flatMap((f) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(f, "utf8"));
|
||||
} catch {
|
||||
return []; // not this scanner's job to validate JSON
|
||||
}
|
||||
return collectExpiryFields(parsed, path.relative(dir, f).split(path.sep).join("/"));
|
||||
});
|
||||
}
|
||||
83
scripts/check/lib/provenanceRunner.mjs
Normal file
83
scripts/check/lib/provenanceRunner.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* scripts/check/lib/provenanceRunner.mjs
|
||||
*
|
||||
* npm refuses `--provenance` from a self-hosted runner:
|
||||
*
|
||||
* 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
|
||||
* Unsupported GitHub Actions runner environment: "self-hosted".
|
||||
* Only "github-hosted" runners are supported when publishing with provenance.
|
||||
*
|
||||
* v3.8.50 hit this at the very end of a 76-minute publish job — after the tag,
|
||||
* the GitHub Release and the Docker images were already public — because
|
||||
* `USE_VPS_RUNNER` had been turned on (2026-08-02) with no release in between to
|
||||
* surface it. The combination is greppable, so it must fail in CI the moment a
|
||||
* workflow introduces it, not four weeks later at the registry.
|
||||
*
|
||||
* Pure: takes workflow YAML text, returns the offending (job, step) pairs.
|
||||
*/
|
||||
import { load as yamlLoad } from "js-yaml";
|
||||
|
||||
const SELF_HOSTED = /\bself-hosted\b/;
|
||||
const EXPRESSION = /\$\{\{/;
|
||||
// Lookahead, not \b: `--provenance-file=…` is a different flag (a pre-built
|
||||
// bundle) and must not match — a word boundary sits between "e" and "-".
|
||||
const PROVENANCE = /(^|\s)--provenance(?=\s|=|$)/m;
|
||||
|
||||
/**
|
||||
* Classifies a job's `runs-on` value.
|
||||
* @returns {"self-hosted"|"hosted"|"unknown"}
|
||||
* "unknown" = an expression with no literal `self-hosted` in it (e.g.
|
||||
* `${{ matrix.os }}`); the check does not guess, it skips.
|
||||
*/
|
||||
export function classifyRunsOn(runsOn) {
|
||||
if (runsOn == null) return "unknown";
|
||||
if (typeof runsOn === "string") {
|
||||
if (SELF_HOSTED.test(runsOn)) return "self-hosted";
|
||||
return EXPRESSION.test(runsOn) ? "unknown" : "hosted";
|
||||
}
|
||||
if (Array.isArray(runsOn)) {
|
||||
return runsOn.some((v) => typeof v === "string" && SELF_HOSTED.test(v))
|
||||
? "self-hosted"
|
||||
: "hosted";
|
||||
}
|
||||
if (typeof runsOn === "object") {
|
||||
// { group: ..., labels: ... } form
|
||||
const labels = runsOn.labels;
|
||||
return classifyRunsOn(Array.isArray(labels) ? labels : labels == null ? "" : String(labels));
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} yamlText
|
||||
* @param {string} fileName used only for reporting
|
||||
* @returns {{ file: string, job: string, step: string }[]}
|
||||
*/
|
||||
export function findProvenanceOnSelfHosted(yamlText, fileName = "<workflow>") {
|
||||
let doc;
|
||||
try {
|
||||
doc = yamlLoad(yamlText);
|
||||
} catch {
|
||||
// actionlint owns syntax; an unparseable file is not this rule's finding.
|
||||
return [];
|
||||
}
|
||||
const jobs =
|
||||
doc && typeof doc === "object" && doc.jobs && typeof doc.jobs === "object" ? doc.jobs : {};
|
||||
const findings = [];
|
||||
for (const [jobName, job] of Object.entries(jobs)) {
|
||||
if (!job || typeof job !== "object") continue;
|
||||
if (classifyRunsOn(job["runs-on"]) !== "self-hosted") continue;
|
||||
const steps = Array.isArray(job.steps) ? job.steps : [];
|
||||
steps.forEach((step, i) => {
|
||||
if (step && typeof step.run === "string" && PROVENANCE.test(step.run)) {
|
||||
findings.push({ file: fileName, job: jobName, step: step.name || `#${i + 1}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Human-readable line per finding, used by the CLI. */
|
||||
export function formatProvenanceFinding(f) {
|
||||
return `${f.file}: job "${f.job}", step "${f.step}" runs \`--provenance\` on a self-hosted runner — npm rejects that (422). Move the upload to a github-hosted job.`;
|
||||
}
|
||||
@@ -1,53 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# runner-janitor — self-hosted runner box hygiene (WS3.3, v3.8.49 quality plan).
|
||||
# runner-janitor — self-hosted runner box hygiene for the .113 pool.
|
||||
#
|
||||
# The .113 runner box has recurring failure modes that until now were manual
|
||||
# discipline: orphaned tmpfs/work dirs filling the disk, and >4 concurrent
|
||||
# runners OOM-killing jobs (16 GB box; incidents on the v3.8.47 release day).
|
||||
# Install via cron on the box (see docs/ops/RUNNER_BOX.md):
|
||||
# */30 * * * * /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
|
||||
# Runs from cron every 30 min (see docs/ops/RUNNER_BOX.md). It ACTS on what it
|
||||
# can prove is safe and ALERTS on what needs an operator decision. Reads of
|
||||
# "is this in use?" and the removal happen in the same command, never in two
|
||||
# passes: a check-then-delete with a gap is how a live Build job lost its _work
|
||||
# on 2026-08-27.
|
||||
#
|
||||
# Measured box (2026-08-28): 31 GB RAM, 32 cores, 15 GB swap, /tmp = 12 GB
|
||||
# tmpfs (RAM!), 188 GB disk. A single `next-build` peaks at ~14 GB, so two
|
||||
# concurrent heavy builds saturate the box and three take it down (06:42Z that
|
||||
# day: load 56, two jobs lost). The v3.8.50 postmortem (Parte III) has the numbers.
|
||||
#
|
||||
# What it does, in order:
|
||||
# 1) sweep stale artefacts our tooling leaves behind — tmpfs bases after 3 h
|
||||
# (they hold RAM), disk _work/_temp bases after 24 h; only names we create,
|
||||
# only when no process has them open
|
||||
# 2) kill zombie builds: a `next-build` older than ZOMBIE_BUILD_MAX_MIN has no
|
||||
# job attached (a real Build step measures ~26 min). On 2026-08-27 one ran
|
||||
# 70 minutes after GitHub had already declared its job lost, eating 3.6 GB
|
||||
# and a full core set. KillMode=mixed on the units covers systemctl
|
||||
# stop/restart; this covers the lost-connection path.
|
||||
# 3) prune 48 h-old checkouts under _work of runners whose unit is INACTIVE
|
||||
# (stopped runners cannot be mid-job; active ones are never touched)
|
||||
# 4) alert: root disk >= DISK_ALERT_PCT, memory PSI full/avg60 >= threshold,
|
||||
# Runner.Listener count above the ceiling (with a per-project breakdown —
|
||||
# the box also hosts OmniHeuris and OmniMind runners)
|
||||
#
|
||||
# Usage: runner-janitor.sh [--dry-run] [--help]
|
||||
# Exit codes: 0 healthy · 1 attention needed (printed to stdout for the log).
|
||||
set -euo pipefail
|
||||
|
||||
MAX_ACTIVE_RUNNERS="${MAX_ACTIVE_RUNNERS:-4}"
|
||||
DISK_ALERT_PCT="${DISK_ALERT_PCT:-85}"
|
||||
WORK_DIR_MAX_AGE_HOURS="${WORK_DIR_MAX_AGE_HOURS:-24}"
|
||||
STATUS=0
|
||||
|
||||
echo "[janitor] $(date -u +%FT%TZ) start"
|
||||
|
||||
# 1) Sweep stale runner temp/work leftovers (>24h — no legitimate job runs that long).
|
||||
# Hardened for a root cron on world-writable paths: never follow a symlinked base
|
||||
# (a compromised runner could plant one), -P + -xdev so the sweep cannot traverse
|
||||
# out of the filesystem, and patterns narrowed to names OUR tooling creates
|
||||
# (no generic tmp* — unrelated system temp files are out of scope).
|
||||
for base in /tmp /home/*/actions-runner*/_work/_temp; do
|
||||
[ -d "$base" ] || continue
|
||||
[ -L "$base" ] && { echo "[janitor] skip symlinked base: $base"; continue; }
|
||||
find -P "$base" -xdev -maxdepth 1 \( -name 'runner-*' -o -name 'omniroute-*' \) \
|
||||
! -type l -mmin +$((WORK_DIR_MAX_AGE_HOURS * 60)) -exec rm -rf {} + 2>/dev/null || true
|
||||
DRY_RUN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*) echo "unknown argument: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
echo "[janitor] stale temp sweep done"
|
||||
|
||||
# 2) Disk pressure — alert loudly before SQLITE_FULL kills jobs mid-run.
|
||||
USAGE=$(df --output=pcent / | tail -1 | tr -dc '0-9')
|
||||
if [ "$USAGE" -ge "$DISK_ALERT_PCT" ]; then
|
||||
echo "[janitor] ⚠ ROOT DISK ${USAGE}% >= ${DISK_ALERT_PCT}% — clean before the next heavy run"
|
||||
STATUS=1
|
||||
MAX_ACTIVE_RUNNERS="${MAX_ACTIVE_RUNNERS:-8}"
|
||||
DISK_ALERT_PCT="${DISK_ALERT_PCT:-85}"
|
||||
TMPFS_MAX_AGE_HOURS="${TMPFS_MAX_AGE_HOURS:-3}"
|
||||
WORK_TEMP_MAX_AGE_HOURS="${WORK_TEMP_MAX_AGE_HOURS:-24}"
|
||||
WORK_CHECKOUT_MAX_AGE_HOURS="${WORK_CHECKOUT_MAX_AGE_HOURS:-48}"
|
||||
ZOMBIE_BUILD_MAX_MIN="${ZOMBIE_BUILD_MAX_MIN:-75}"
|
||||
ZOMBIE_BUILD_COMM="${ZOMBIE_BUILD_COMM:-next-build}"
|
||||
PSI_FULL_AVG60_ALERT="${PSI_FULL_AVG60_ALERT:-10}"
|
||||
# Overridable so the unit test can point everything at a fixture tree.
|
||||
JANITOR_TMP_BASES="${JANITOR_TMP_BASES-/tmp}"
|
||||
JANITOR_WORK_TEMP_BASES="${JANITOR_WORK_TEMP_BASES-/opt/actions-runner*/_work/_temp /home/*/actions-runner*/_work/_temp}"
|
||||
JANITOR_RUNNER_DIRS="${JANITOR_RUNNER_DIRS-/opt/actions-runner*}"
|
||||
JANITOR_PSI_FILE="${JANITOR_PSI_FILE:-/proc/pressure/memory}"
|
||||
JANITOR_DF_PATH="${JANITOR_DF_PATH:-/}"
|
||||
|
||||
STATUS=0
|
||||
say() { echo "[janitor] $*"; }
|
||||
|
||||
# "Is anything using this?" — ONE snapshot of every open path on the box
|
||||
# (lsof -Fn), then a prefix match per candidate. `lsof +D <dir>` walks the whole
|
||||
# tree instead and took minutes on a 5 GB leftover — unusable from cron. An
|
||||
# absent lsof means "cannot prove idle": the sweep keeps the path and says so.
|
||||
LSOF_BIN="${JANITOR_LSOF:-lsof}"
|
||||
have_busy_tools() { command -v "$LSOF_BIN" >/dev/null 2>&1; }
|
||||
SNAP=""
|
||||
cleanup() { [ -n "$SNAP" ] && rm -f -- "$SNAP"; }
|
||||
trap cleanup EXIT
|
||||
# One lsof for the whole run (~13 s / 83k lines on the box), kept ONLY for the
|
||||
# bases we sweep — 460 candidates grepping a re-printed 83k-line string was the
|
||||
# slow part, not lsof itself.
|
||||
snapshot_open_paths() {
|
||||
have_busy_tools || return 0
|
||||
SNAP=$(mktemp) || return 0
|
||||
local prefixes="" b
|
||||
for b in $JANITOR_TMP_BASES $JANITOR_WORK_TEMP_BASES; do [ -d "$b" ] && prefixes="$prefixes"$'\n'"$b/"; done
|
||||
# -F n: one "n<path>" line per open file; -w: no warnings
|
||||
"$LSOF_BIN" -w -Fn 2>/dev/null | sed -n 's/^n//p' | grep -F -f <(printf '%s' "$prefixes" | sed '/^$/d') > "$SNAP" 2>/dev/null || true
|
||||
}
|
||||
is_busy() {
|
||||
local p="$1"
|
||||
[ -n "$SNAP" ] && [ -s "$SNAP" ] || return 1
|
||||
# exact path, or anything beneath it when it is a directory
|
||||
grep -qxF -- "$p" "$SNAP" && return 0
|
||||
[ -d "$p" ] && grep -qF -- "$p/" "$SNAP"
|
||||
}
|
||||
|
||||
# sweep <base> <max-age-minutes>: only names our tooling creates, never through
|
||||
# a symlinked base, never across a filesystem, and remove+check in one step.
|
||||
sweep() {
|
||||
local base="$1" max_min="$2" p
|
||||
[ -d "$base" ] || return 0
|
||||
[ -L "$base" ] && { say "skip symlinked base: $base"; return 0; }
|
||||
while IFS= read -r -d '' p; do
|
||||
if ! have_busy_tools; then say "cannot prove idle (lsof missing — apt install lsof), kept: $p"; STATUS=1; continue; fi
|
||||
if is_busy "$p"; then say "busy, kept: $p"; continue; fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then say "would remove ($(( max_min / 60 ))h+): $p"; else rm -rf -- "$p" && say "removed ($(( max_min / 60 ))h+): $p"; fi
|
||||
done < <(find -P "$base" -xdev -mindepth 1 -maxdepth 1 \
|
||||
\( -name 'runner-*' -o -name 'omniroute-*' -o -name 'next-build*' -o -name 'e2e-build.tar.gz' \) \
|
||||
! -type l -mmin "+$max_min" -print0 2>/dev/null || true)
|
||||
}
|
||||
|
||||
say "$(date -u +%FT%TZ) start${DRY_RUN:+ (dry-run=$DRY_RUN)} busy-tools=$(have_busy_tools && echo ok || echo MISSING)"
|
||||
|
||||
# 1) stale artefacts — tmpfs is RAM, so it gets the short fuse
|
||||
snapshot_open_paths
|
||||
for base in $JANITOR_TMP_BASES; do sweep "$base" $(( TMPFS_MAX_AGE_HOURS * 60 )); done
|
||||
for base in $JANITOR_WORK_TEMP_BASES; do sweep "$base" $(( WORK_TEMP_MAX_AGE_HOURS * 60 )); done
|
||||
say "stale temp sweep done"
|
||||
|
||||
# 2) zombie builds
|
||||
ZOMBIES=0
|
||||
while read -r pid etimes comm; do
|
||||
[ -n "${pid:-}" ] || continue
|
||||
if [ "$etimes" -gt $(( ZOMBIE_BUILD_MAX_MIN * 60 )) ]; then
|
||||
say "⚠ zombie build pid=$pid comm=$comm age=$(( etimes / 60 ))min > ${ZOMBIE_BUILD_MAX_MIN}min — no job runs this long"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then say "[dry-run] would: kill -TERM $pid (then -KILL)"; else
|
||||
kill -TERM "$pid" 2>/dev/null || true; sleep 10
|
||||
kill -0 "$pid" 2>/dev/null && { kill -KILL "$pid" 2>/dev/null || true; say " needed SIGKILL"; }
|
||||
fi
|
||||
ZOMBIES=$(( ZOMBIES + 1 )); STATUS=1
|
||||
fi
|
||||
done < <(ps -eo pid=,etimes=,comm= 2>/dev/null | awk -v c="$ZOMBIE_BUILD_COMM" '$3 ~ ("^" c) {print $1, $2, $3}' || true)
|
||||
say "zombie builds: $ZOMBIES"
|
||||
|
||||
# 3) old checkouts of STOPPED runners
|
||||
for d in $JANITOR_RUNNER_DIRS; do
|
||||
[ -d "$d" ] && [ -f "$d/.runner" ] || continue
|
||||
agent=$(grep -o '"agentName": *"[^"]*"' "$d/.runner" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
[ -n "$agent" ] || continue
|
||||
unit=$(systemctl list-units --plain --no-legend "actions.runner.*.${agent}.service" 2>/dev/null | awk 'NR==1{print $1}')
|
||||
[ -n "$unit" ] || continue
|
||||
if systemctl is-active --quiet "$unit"; then continue; fi
|
||||
while IFS= read -r -d '' co; do
|
||||
if [ "$DRY_RUN" -eq 1 ]; then say "would prune checkout of stopped runner $agent: $co"; else rm -rf -- "$co" && say "pruned checkout of stopped runner $agent: $co"; fi
|
||||
done < <(find -P "$d/_work" -xdev -mindepth 2 -maxdepth 2 -type d -mmin "+$(( WORK_CHECKOUT_MAX_AGE_HOURS * 60 ))" -print0 2>/dev/null || true)
|
||||
done
|
||||
|
||||
# 4a) disk
|
||||
USAGE=$(df --output=pcent "$JANITOR_DF_PATH" 2>/dev/null | tail -1 | tr -dc '0-9')
|
||||
if [ "${USAGE:-0}" -ge "$DISK_ALERT_PCT" ]; then
|
||||
say "⚠ ROOT DISK ${USAGE}% >= ${DISK_ALERT_PCT}% — clean before the next heavy run"; STATUS=1
|
||||
else
|
||||
echo "[janitor] disk ${USAGE}% OK"
|
||||
say "disk ${USAGE:-?}% OK"
|
||||
fi
|
||||
|
||||
# 3) Concurrency ceiling — 8-wide OOMed the 16 GB box twice on release day;
|
||||
# 4 is the proven ceiling. This CODIFIES the rule that was manual discipline.
|
||||
# 4b) memory pressure (PSI) — the box swapped its way through the v3.8.50 publish
|
||||
if [ -r "$JANITOR_PSI_FILE" ]; then
|
||||
FULL60=$(awk '/^full/ {for(i=1;i<=NF;i++) if ($i ~ /^avg60=/) {sub("avg60=","",$i); print $i}}' "$JANITOR_PSI_FILE" 2>/dev/null || echo "")
|
||||
if [ -n "$FULL60" ] && awk -v v="$FULL60" -v t="$PSI_FULL_AVG60_ALERT" 'BEGIN{exit !(v+0 >= t+0)}'; then
|
||||
say "⚠ MEMORY PRESSURE psi full/avg60=${FULL60}% >= ${PSI_FULL_AVG60_ALERT}% — too many heavy jobs at once"; STATUS=1
|
||||
else
|
||||
say "memory psi full/avg60=${FULL60:-n/a}% OK"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4c) concurrency ceiling — alert with a breakdown; the fix is fewer/labelled
|
||||
# runners (an operator decision), not killing listeners from cron.
|
||||
ACTIVE=$(pgrep -fc "Runner.Listener" || true)
|
||||
OMNI=$(pgrep -fc "actions-runner-omniroute[^ ]*/bin[^ ]*/Runner.Listener" || true)
|
||||
if [ "${ACTIVE:-0}" -gt "$MAX_ACTIVE_RUNNERS" ]; then
|
||||
echo "[janitor] ⚠ ${ACTIVE} Runner.Listener processes > ceiling ${MAX_ACTIVE_RUNNERS} — stop the extra runners (systemctl stop actions.runner.<name>)"
|
||||
say "⚠ ${ACTIVE} Runner.Listener processes (omniroute=${OMNI:-0}, other=$(( ${ACTIVE:-0} - ${OMNI:-0} ))) > ceiling ${MAX_ACTIVE_RUNNERS} — stop idle extras: systemctl stop <unit> only when it has no Runner.Worker child"
|
||||
STATUS=1
|
||||
else
|
||||
echo "[janitor] runners active: ${ACTIVE:-0}/${MAX_ACTIVE_RUNNERS} OK"
|
||||
say "runners active: ${ACTIVE:-0}/${MAX_ACTIVE_RUNNERS} (omniroute=${OMNI:-0}) OK"
|
||||
fi
|
||||
|
||||
echo "[janitor] done status=$STATUS"
|
||||
say "done status=$STATUS"
|
||||
exit "$STATUS"
|
||||
|
||||
@@ -2037,7 +2037,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [config, setConfig] = useState(sanitizeComboRuntimeConfig(combo?.config));
|
||||
// Validate persisted enum; ensure reset on combo change not just first mount.
|
||||
const initialSortMethod = normalizeSortMethod(config.modelSort?.method);
|
||||
const initialSortMethod = normalizeSortMethod(
|
||||
(config.modelSort as { method?: unknown } | undefined)?.method
|
||||
);
|
||||
const [sortMethod, setSortMethod] = useState<SortMethod>(initialSortMethod);
|
||||
useEffect(() => {
|
||||
// Sync point: when the combo identity changes, re-derive sort method.
|
||||
@@ -2733,13 +2735,15 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
const rankings = await fetchProviderRankings();
|
||||
// Functional note: `next` is the post-batch snapshot. Concurrent single-add
|
||||
// racing this batch is low-probability single-user; last write wins.
|
||||
const sorted = await sortComboStepsByScore(next, rankings);
|
||||
setModels(sorted);
|
||||
const sorted = await sortComboStepsByScore(next as ComboStep[], rankings);
|
||||
setModels(sorted as typeof next);
|
||||
} catch {
|
||||
setModels(next);
|
||||
}
|
||||
} else {
|
||||
setModels(sortComboStepsSync(next, currentMethod as "provider" | "name"));
|
||||
setModels(
|
||||
sortComboStepsSync(next as ComboStep[], currentMethod as "provider" | "name") as typeof next
|
||||
);
|
||||
}
|
||||
setBuilderError("");
|
||||
};
|
||||
@@ -3642,7 +3646,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<ComboSortSelect value={sortMethod} onChange={handleSortChange} t={t} />
|
||||
<ComboSortSelect
|
||||
value={sortMethod}
|
||||
onChange={handleSortChange}
|
||||
t={(k, f) => getI18nOrFallback(t, k, f)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
website?: string;
|
||||
color: string;
|
||||
color?: string;
|
||||
apiType?: string;
|
||||
/** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */
|
||||
iconUrl?: string;
|
||||
|
||||
@@ -153,32 +153,60 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
const persistedSteps = Array.isArray(combo.models) ? combo.models : [];
|
||||
const modelSteps = persistedSteps.filter(
|
||||
(step: any) =>
|
||||
let unsupportedStepCount = 0;
|
||||
const targets = persistedSteps.flatMap((step: any) => {
|
||||
if (
|
||||
typeof step === "string" ||
|
||||
((step.kind === undefined || step.kind === "model") && typeof step.model === "string")
|
||||
);
|
||||
const unsupportedStepCount = persistedSteps.length - modelSteps.length;
|
||||
) {
|
||||
const value = typeof step === "string" ? step : step.model;
|
||||
const separator = value.indexOf("/");
|
||||
const parsedProvider = separator === -1 ? undefined : value.slice(0, separator);
|
||||
const parsedModel = separator === -1 ? value : value.slice(separator + 1);
|
||||
|
||||
return [
|
||||
{
|
||||
provider:
|
||||
typeof step === "string"
|
||||
? parsedProvider || "unknown"
|
||||
: step.providerId || step.provider || parsedProvider || "unknown",
|
||||
model: parsedModel,
|
||||
weight: typeof step === "string" ? undefined : step.weight,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// #11822 follow-up (see #11882): surface combo-ref and provider-wildcard
|
||||
// steps with a specific warning instead of folding them into a generic
|
||||
// "unsupported step" count. Structural combo references and wildcard
|
||||
// expansion are out of scope for this route-local simulator.
|
||||
if (step?.kind === "combo-ref") {
|
||||
warnings.push(
|
||||
`Step references combo "${String(step.comboName)}" — nested combos are not expanded by the simulator.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
if (step?.kind === "provider-wildcard") {
|
||||
warnings.push(
|
||||
`Step "${String(step.providerId)}/${String(step.modelPattern)}" is a provider wildcard — expanded at runtime, shown here unresolved.`
|
||||
);
|
||||
return [
|
||||
{
|
||||
provider: String(step.providerId ?? "unknown"),
|
||||
model: String(step.modelPattern ?? "*"),
|
||||
weight: typeof step.weight === "number" ? step.weight : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
unsupportedStepCount += 1;
|
||||
return [];
|
||||
});
|
||||
if (unsupportedStepCount > 0) {
|
||||
warnings.push(
|
||||
`Skipped ${unsupportedStepCount} unsupported persisted combo ${unsupportedStepCount === 1 ? "step" : "steps"}.`
|
||||
);
|
||||
}
|
||||
const targets = modelSteps.map((step: any) => {
|
||||
const value = typeof step === "string" ? step : step.model;
|
||||
const separator = value.indexOf("/");
|
||||
const parsedProvider = separator === -1 ? undefined : value.slice(0, separator);
|
||||
const parsedModel = separator === -1 ? value : value.slice(separator + 1);
|
||||
|
||||
return {
|
||||
provider:
|
||||
typeof step === "string"
|
||||
? parsedProvider || "unknown"
|
||||
: step.providerId || step.provider || parsedProvider || "unknown",
|
||||
model: parsedModel,
|
||||
weight: typeof step === "string" ? undefined : step.weight,
|
||||
};
|
||||
});
|
||||
comboInfo = { name: combo.name, strategy: combo.strategy, targets };
|
||||
} else if (body.combo) {
|
||||
comboInfo = body.combo;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry";
|
||||
import {
|
||||
getRegistryModelThinkingEfforts,
|
||||
getRegistryThinkingEfforts,
|
||||
providerUsesAuthoritativeLiveCatalog,
|
||||
REGISTRY,
|
||||
} from "@omniroute/open-sse/config/providerRegistry";
|
||||
import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model";
|
||||
@@ -994,11 +995,13 @@ async function buildUnifiedModelsResponseCore(
|
||||
// the fix, a provider with any synced model silently dropped ALL its
|
||||
// static models.
|
||||
//
|
||||
// Cursor exclusive listing: when an active synced catalog exists, drop
|
||||
// ALL static rows (including effort variants) so Test All / clients only
|
||||
// see live AvailableModels + injected auto*.
|
||||
// An authoritative active synced catalog replaces the static registry.
|
||||
// Partial discovery providers still use exact-id coverage suppression so
|
||||
// their intentionally omitted static routes remain available.
|
||||
const syncedForProvider = syncedModelIdsByCanonicalProvider.get(canonicalProviderId);
|
||||
const exclusiveListing = providerUsesExclusiveSyncedListing(canonicalProviderId);
|
||||
const exclusiveListing =
|
||||
providerUsesExclusiveSyncedListing(canonicalProviderId) ||
|
||||
providerUsesAuthoritativeLiveCatalog(canonicalProviderId);
|
||||
const providerHasSynced = syncedForProvider !== undefined && syncedForProvider.size > 0;
|
||||
const coveredBySynced = shouldSuppressStaticModelForExclusiveListing({
|
||||
exclusiveListing,
|
||||
@@ -1227,7 +1230,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (includeAlias) {
|
||||
if (includeAlias || Boolean(prefix)) {
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
@@ -1239,7 +1242,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
...syncedFields,
|
||||
});
|
||||
}
|
||||
if (includeAlias && modelType === "audio") {
|
||||
if ((includeAlias || Boolean(prefix)) && modelType === "audio") {
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
@@ -1652,7 +1655,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
? getCustomVisionCapabilityFields(model, aliasId, modelId)
|
||||
: null;
|
||||
|
||||
if (includeAlias) {
|
||||
if (includeAlias || Boolean(prefix)) {
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
@@ -1770,7 +1773,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
const visionFields =
|
||||
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId);
|
||||
|
||||
if (includeAlias) {
|
||||
if (includeAlias || Boolean(nodePrefix)) {
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
|
||||
@@ -7679,7 +7679,8 @@
|
||||
"backupCleanupSuccess": "Excluído(s) {backups} conjunto(s) de backup e {files} arquivo(s).",
|
||||
"backupCleanupFailed": "Falha ao limpar backups do banco de dados",
|
||||
"purgeQuotaSnapshotsSuccess": "{count} snapshots de cota removidos",
|
||||
"purgeQuotaSnapshotsFailed": "Falha ao remover snapshots de cota", "purgeCallLogsSuccess": "{count} logs de chamadas removidos",
|
||||
"purgeQuotaSnapshotsFailed": "Falha ao remover snapshots de cota",
|
||||
"purgeCallLogsSuccess": "{count} logs de chamadas removidos",
|
||||
"purgeCallLogsFailed": "Falha ao remover logs de chamadas",
|
||||
"purgeDetailedLogsSuccess": "{count} logs detalhados removidos",
|
||||
"purgeDetailedLogsFailed": "Falha ao remover logs detalhados",
|
||||
@@ -7689,7 +7690,8 @@
|
||||
"invalidJsonFileType": "Tipo de arquivo inválido. Apenas arquivos .json são permitidos.",
|
||||
"legacyJsonImportSuccess": "JSON legado importado com sucesso!",
|
||||
"jsonImportFailed": "Falha ao importar JSON",
|
||||
"jsonImportError": "Erro durante a importação de JSON", "storagePurgeData": "Limpar dados",
|
||||
"jsonImportError": "Erro durante a importação de JSON",
|
||||
"storagePurgeData": "Limpar dados",
|
||||
"storagePurgeDataDesc": "Excluir imediatamente todos os registros sem aplicar verificações de retenção. Use com cautela.",
|
||||
"storageRetentionCleanup": "Configurações de Retenção",
|
||||
"storageRetentionCleanupDesc": "Configure a retenção de registros operacionais e a limpeza de backup do banco de dados.",
|
||||
@@ -13937,5 +13939,17 @@
|
||||
"cta": "Obter uma chave de API",
|
||||
"partnerLinkNote": "Link de parceiro",
|
||||
"dismissAriaLabel": "Dispensar"
|
||||
},
|
||||
"combo": {
|
||||
"sort": {
|
||||
"label": "Ordenar por",
|
||||
"method": {
|
||||
"manual": "Manual",
|
||||
"provider": "Provedor",
|
||||
"score": "Pontuação (modelos gratuitos)",
|
||||
"name": "Nome"
|
||||
},
|
||||
"scoreHint": "A ordenação por pontuação vale só para provedores gratuitos; os demais ficam onde estão."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13939,5 +13939,17 @@
|
||||
"cta": "Lấy khóa API",
|
||||
"partnerLinkNote": "Liên kết đối tác",
|
||||
"dismissAriaLabel": "Đóng"
|
||||
},
|
||||
"combo": {
|
||||
"sort": {
|
||||
"label": "Sắp xếp theo",
|
||||
"method": {
|
||||
"manual": "Thủ công",
|
||||
"provider": "Nhà cung cấp",
|
||||
"score": "Điểm (mô hình miễn phí)",
|
||||
"name": "Tên"
|
||||
},
|
||||
"scoreHint": "Xếp hạng theo điểm chỉ áp dụng cho nhà cung cấp miễn phí; các nhà cung cấp khác giữ nguyên vị trí."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@
|
||||
* - honor explicit caller intent verbatim
|
||||
*/
|
||||
|
||||
const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high"]);
|
||||
const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
|
||||
|
||||
export type ReasoningEffort = "minimal" | "low" | "medium" | "high";
|
||||
export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export function normalizeXaiReasoningEffort(effort: unknown): ReasoningEffort | undefined {
|
||||
if (typeof effort !== "string") return undefined;
|
||||
const normalized = effort.toLowerCase();
|
||||
if (normalized === "max" || normalized === "xhigh") return "high";
|
||||
// "max" is not an xAI tier; "xhigh" is real on grok-4.6+ and xAI itself
|
||||
// degrades it to "high" on older models, so passing it through is always safe.
|
||||
if (normalized === "max") return "high";
|
||||
return VALID_EFFORTS.has(normalized) ? (normalized as ReasoningEffort) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,17 @@ function normalizeInputSchema(input: Record<string, unknown>): Record<string, un
|
||||
if (typeof input.type === "string") {
|
||||
return input;
|
||||
}
|
||||
// Expand shorthand values: skills may declare `{ "content": "string" }`
|
||||
// instead of `{ "content": { "type": "string" } }`. Forwarding the shorthand
|
||||
// verbatim produces invalid JSON Schema, which strict-validating upstreams
|
||||
// (Zhipu GLM behind Console Go) reject with a 400 for the entire request.
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
properties[key] = typeof value === "string" ? { type: value } : value;
|
||||
}
|
||||
return {
|
||||
type: "object",
|
||||
properties: input,
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
} from "@/lib/db/providerLimits";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { setQuotaCache } from "@/domain/quotaCache";
|
||||
import {
|
||||
buildClaudeExtraUsageConnectionUpdate,
|
||||
CLAUDE_EXTRA_USAGE_ERROR_SOURCE,
|
||||
isClaudeExtraUsageBlockEnabled,
|
||||
isClaudeExtraUsageQueued,
|
||||
} from "@/lib/providers/claudeExtraUsage";
|
||||
import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { clearRecoveredProviderState } from "@/sse/services/auth";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
@@ -526,48 +521,7 @@ export function shouldClearErrorStateOnValidProbe(
|
||||
* — keeps the connection locked, matching the kimi-coding partial-refresh
|
||||
* semantics.
|
||||
*/
|
||||
function windowStillExhaustedAfterRealReset(value: unknown, nowMs: number): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
if (value.unlimited === true) return false;
|
||||
const remaining =
|
||||
typeof value.remaining === "number"
|
||||
? value.remaining
|
||||
: typeof value.remainingPercentage === "number"
|
||||
? value.remainingPercentage
|
||||
: null;
|
||||
if (remaining !== null && remaining > 0) return false;
|
||||
if (value.resetAt == null) return true;
|
||||
const resetMs = Date.parse(String(value.resetAt));
|
||||
if (Number.isNaN(resetMs)) return true;
|
||||
return resetMs > nowMs;
|
||||
}
|
||||
|
||||
function isQuotaExhaustedCooldownReleasable(
|
||||
connection: Pick<
|
||||
ProviderConnectionLike,
|
||||
"lastErrorType" | "lastErrorSource" | "provider" | "providerSpecificData"
|
||||
>,
|
||||
usage: JsonRecord
|
||||
): boolean {
|
||||
if (connection.lastErrorType !== "quota_exhausted") return false;
|
||||
// An extra-usage block is a POLICY lock, not a quota window: the session and
|
||||
// weekly windows genuinely look recovered in the very same fetch, so the
|
||||
// window scan below would happily release it. It stays locked while the
|
||||
// policy is on and upstream still reports extra usage queued.
|
||||
if (
|
||||
connection.lastErrorSource === CLAUDE_EXTRA_USAGE_ERROR_SOURCE &&
|
||||
isClaudeExtraUsageBlockEnabled(connection.provider, connection.providerSpecificData) &&
|
||||
isClaudeExtraUsageQueued(usage)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const quotas = usage?.quotas;
|
||||
if (!isRecord(quotas)) return false;
|
||||
const values = Object.values(quotas);
|
||||
if (values.length === 0) return false;
|
||||
const nowMs = Date.now();
|
||||
return !values.some((value) => windowStillExhaustedAfterRealReset(value, nowMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is an explicit cooldown still in the future?
|
||||
@@ -600,17 +554,17 @@ export async function maybeClearRecoveredQuotaState(
|
||||
if (!hasUsableQuota(usage)) return connection;
|
||||
if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection;
|
||||
if (hasActiveCooldown(connection)) {
|
||||
// #11355 made an active rateLimitedUntil an unconditional stop, which is right
|
||||
// for an upstream-derived cooldown but over-broad for the one case #10534 was
|
||||
// built for: a Claude-subscription 429 persists a SYNTHETIC 1h cooldown because
|
||||
// the upstream sent no parseable reset. When the later poll shows every window
|
||||
// that governs this connection has really reset WITH quota available, holding
|
||||
// that synthetic cooldown just deadlocks the connection for an hour.
|
||||
//
|
||||
// Narrow by design: only lastErrorType "quota_exhausted" (the synthetic-cooldown
|
||||
// writer) is eligible, and a single still-exhausted or unknown-reset window keeps
|
||||
// the lock. Every other reason keeps #11355/#11277 semantics untouched.
|
||||
if (!isQuotaExhaustedCooldownReleasable(connection, usage)) return connection;
|
||||
// A future rateLimitedUntil written from a real upstream signal is a hard
|
||||
// statement no poller may overrule (#11277) — executor-sourced rate limits
|
||||
// and extra-usage policy blocks included. Only a SYNTHETIC cooldown (a
|
||||
// quota_exhausted lock persisted without an upstream reset, e.g. the
|
||||
// Claude-subscription poller's 1h lockout) yields to positive live-window
|
||||
// evidence that the real quota has already replenished past its reset.
|
||||
const syntheticRecoveryOverride =
|
||||
connection.lastErrorType === "quota_exhausted" &&
|
||||
connection.lastErrorSource !== "extra_usage" &&
|
||||
syntheticCooldownOutlivedByRealWindows(usage);
|
||||
if (!syntheticRecoveryOverride) return connection;
|
||||
}
|
||||
|
||||
const hasTransientState =
|
||||
|
||||
@@ -335,11 +335,19 @@ const ProviderIcon = memo(function ProviderIcon({
|
||||
fallbackColor,
|
||||
}: ProviderIconProps) {
|
||||
const { isDark } = useTheme();
|
||||
const normalizedId = PROVIDER_ICON_ALIASES[providerId.toLowerCase()] || providerId.toLowerCase();
|
||||
const localSvgId = LOCAL_SVG_ALIASES[normalizedId] || normalizedId;
|
||||
// Own-property guards: a providerId such as "constructor" or "__proto__" otherwise
|
||||
// resolves through Object.prototype, yielding a truthy-looking value that corrupts
|
||||
// downstream lookups instead of falling through to the unknown-provider path (#11853).
|
||||
const providerIdLower = providerId.toLowerCase();
|
||||
const normalizedId = Object.hasOwn(PROVIDER_ICON_ALIASES, providerIdLower)
|
||||
? PROVIDER_ICON_ALIASES[providerIdLower]
|
||||
: providerIdLower;
|
||||
const localSvgId = Object.hasOwn(LOCAL_SVG_ALIASES, normalizedId)
|
||||
? LOCAL_SVG_ALIASES[normalizedId]
|
||||
: normalizedId;
|
||||
const usesGenericIcon =
|
||||
GENERIC_PROVIDER_IDS.has(normalizedId) || GENERIC_PROVIDER_IDS.has(localSvgId);
|
||||
const themedSvg = THEMED_SVGS[normalizedId];
|
||||
const themedSvg = Object.hasOwn(THEMED_SVGS, normalizedId) ? THEMED_SVGS[normalizedId] : undefined;
|
||||
const hasSvg = KNOWN_SVGS.has(localSvgId);
|
||||
|
||||
const [failedAssets, setFailedAssets] = useState<Record<string, true>>({});
|
||||
|
||||
@@ -484,9 +484,17 @@ export function getLobeProviderIcon(
|
||||
providerId: string,
|
||||
type: "mono" | "color" = "color"
|
||||
): LobeIconComponent | null {
|
||||
const iconKey = LOBE_PROVIDER_ALIASES[providerId.toLowerCase()];
|
||||
if (!iconKey) return null;
|
||||
if (typeof providerId !== "string") return null;
|
||||
const aliasKey = providerId.toLowerCase();
|
||||
// Own-property guards: a providerId such as "constructor" or "__proto__"
|
||||
// otherwise resolves through Object.prototype, yielding a truthy iconKey
|
||||
// whose LOBE_ICON_COMPONENTS lookup is undefined -> `entry.color` throws and
|
||||
// takes down the whole providers dashboard via the error boundary.
|
||||
if (!Object.hasOwn(LOBE_PROVIDER_ALIASES, aliasKey)) return null;
|
||||
const iconKey = LOBE_PROVIDER_ALIASES[aliasKey];
|
||||
if (!iconKey || !Object.hasOwn(LOBE_ICON_COMPONENTS, iconKey)) return null;
|
||||
|
||||
const entry = LOBE_ICON_COMPONENTS[iconKey];
|
||||
if (!entry) return null;
|
||||
return type === "color" && entry.color ? entry.color : entry.mono;
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
"Anonymous access to Groq requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
|
||||
passthroughModels: true,
|
||||
authHint:
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
|
||||
notice: {
|
||||
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
|
||||
apiKeyUrl: "https://g4f.dev/members.html",
|
||||
@@ -714,7 +714,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
"Anonymous access to Gemini requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
|
||||
passthroughModels: true,
|
||||
authHint:
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
|
||||
notice: {
|
||||
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
|
||||
apiKeyUrl: "https://g4f.dev/members.html",
|
||||
@@ -733,7 +733,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
"Anonymous access to Pollinations requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
|
||||
passthroughModels: true,
|
||||
authHint:
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
|
||||
notice: {
|
||||
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
|
||||
apiKeyUrl: "https://g4f.dev/members.html",
|
||||
@@ -752,7 +752,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
"Anonymous access to hosted Ollama requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
|
||||
passthroughModels: true,
|
||||
authHint:
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
|
||||
notice: {
|
||||
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
|
||||
apiKeyUrl: "https://g4f.dev/members.html",
|
||||
@@ -771,7 +771,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
"Anonymous access to NVIDIA NIM requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
|
||||
passthroughModels: true,
|
||||
authHint:
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
|
||||
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
|
||||
notice: {
|
||||
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
|
||||
apiKeyUrl: "https://g4f.dev/members.html",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"$schema": "https://stryker-mutator.io/schemas/stryker-schema.json",
|
||||
"_comment": [
|
||||
"Mutation testing for the ~8 critical modules (Task 11 \u2014 Fase 7).",
|
||||
"NIGHTLY ONLY \u2014 DO NOT run on every PR. Mutation testing is expensive:",
|
||||
"Mutation testing for the ~8 critical modules (Task 11 — Fase 7).",
|
||||
"NIGHTLY ONLY — DO NOT run on every PR. Mutation testing is expensive:",
|
||||
" - Each mutant requires a full test suite execution.",
|
||||
" - The 8 modules produce ~200\u2013500 mutants; est. 30\u201390 min per run.",
|
||||
" - The 8 modules produce ~200–500 mutants; est. 30–90 min per run.",
|
||||
" - Wired to the nightly CI workflow (.github/workflows/nightly-mutation.yml),",
|
||||
" NOT to the 'lint' / 'quality-gate' PR jobs.",
|
||||
"",
|
||||
"TEST RUNNER \u2014 @stryker-mutator/tap-runner (NOT vitest):",
|
||||
"TEST RUNNER — @stryker-mutator/tap-runner (NOT vitest):",
|
||||
" The 8 critical modules are covered by node:test files in tests/unit/",
|
||||
" (run via `node --import tsx --test`), NOT by vitest. The vitest config",
|
||||
" only includes a small set of .test.tsx + open-sse/**/__tests__ files, so",
|
||||
@@ -22,24 +22,26 @@
|
||||
" npm install --save-dev @stryker-mutator/core @stryker-mutator/tap-runner",
|
||||
"",
|
||||
"Run manually:",
|
||||
" npm run test:mutation # full run (slow \u2014 nightly budget)",
|
||||
" npm run test:mutation # full run (slow — nightly budget)",
|
||||
" npx stryker run --dryRunOnly # validate the baseline only (no mutants)",
|
||||
" (single-module probe: temporarily narrow `mutate` + `tap.testFiles` in this file)",
|
||||
"",
|
||||
"VALIDATED 2026-06-15: `npx stryker run --dryRunOnly` exits 0 \u2014 all 129 covering",
|
||||
"VALIDATED 2026-06-15: `npx stryker run --dryRunOnly` exits 0 — all 129 covering",
|
||||
"test files run green in the Stryker sandbox and the perTest coverage map builds for",
|
||||
"all 8 instrumented modules (15k+ mutants). The baseline dry-run takes ~20 min with",
|
||||
"concurrency=1; the full mutation phase runs on top (advisory, capped by the workflow",
|
||||
"timeout). So the nightly produces REAL mutation scores for the 8 modules.",
|
||||
"",
|
||||
"Mutation score per module \u2192 quality-baseline.json key 'mutationScore.<module>'",
|
||||
"Direction: up (score can only improve; ratchet blocks drops \u2014 wired in a later INT phase)."
|
||||
"Mutation score per module → quality-baseline.json key 'mutationScore.<module>'",
|
||||
"Direction: up (score can only improve; ratchet blocks drops — wired in a later INT phase)."
|
||||
],
|
||||
"packageManager": "npm",
|
||||
"incremental": true,
|
||||
"incrementalFile": "reports/mutation/stryker-incremental.json",
|
||||
"testRunner": "tap",
|
||||
"plugins": ["@stryker-mutator/tap-runner"],
|
||||
"plugins": [
|
||||
"@stryker-mutator/tap-runner"
|
||||
],
|
||||
"tap": {
|
||||
"testFiles": [
|
||||
"tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts",
|
||||
@@ -389,7 +391,13 @@
|
||||
"tests/unit/vertex-passthrough-model-lockout.test.ts",
|
||||
"tests/unit/video-bridge-drilldown-route.test.ts",
|
||||
"tests/unit/video-bridge-route-security.test.ts",
|
||||
"tests/unit/xai-agent-tools-passthrough.test.ts"
|
||||
"tests/unit/xai-agent-tools-passthrough.test.ts",
|
||||
"tests/unit/combo/connection-aware-expansion.test.ts",
|
||||
"tests/unit/chatgpt-web-runtime-block.test.ts",
|
||||
"tests/unit/felo-web-runtime-block.test.ts",
|
||||
"tests/unit/microsoft-designer-web-runtime-block.test.ts",
|
||||
"tests/unit/qwen-web-runtime-block.test.ts",
|
||||
"tests/unit/tunnel-routes-error-sanitization.test.ts"
|
||||
],
|
||||
"nodeArgs": [
|
||||
"--import",
|
||||
@@ -405,7 +413,7 @@
|
||||
]
|
||||
},
|
||||
"_mutate_godfiles_excluded_comment": [
|
||||
"2026-06-18 (Onda 2 budget): chatCore.ts + combo.ts \u2014 the two god-files \u2014 were REMOVED",
|
||||
"2026-06-18 (Onda 2 budget): chatCore.ts + combo.ts — the two god-files — were REMOVED",
|
||||
"from `mutate`. They dominated ~2/3 of the ~15k mutants; the full 8-module run TIMED OUT",
|
||||
"at the 180min nightly cap (run 27705123780: 16:47:33 -> killed 19:47:48 = exactly 180min;",
|
||||
"the prior 120min scheduled run also timed out). #4078 made concurrency safe but the",
|
||||
@@ -425,23 +433,23 @@
|
||||
"comboContextCache/idempotency/passthroughHelpers/responseHeaders/sanitization/upstreamTimeouts).",
|
||||
"A follow-up then added DEDICATED unit tests for 6 more leaves (tests/unit/chatcore-headers,",
|
||||
"-log-truncation, -memory-extraction, -non-streaming-sse, -passthrough-tool-names,",
|
||||
"-executor-helpers \u2014 wired into tap.testFiles above) and added those leaves as batch h",
|
||||
"-executor-helpers — wired into tap.testFiles above) and added those leaves as batch h",
|
||||
"(headers/logTruncation/memoryExtraction/nonStreamingSse/passthroughToolNames/executorHelpers).",
|
||||
"A later follow-up added dedicated tests (NO mock.module \u2014 unavailable under the tap-runner; used",
|
||||
"A later follow-up added dedicated tests (NO mock.module — unavailable under the tap-runner; used",
|
||||
"fetch-override + crafted inputs + temp-DATA_DIR) for telemetryHelpers (both fns, all branches) and",
|
||||
"memorySkillsInjection (getSkillsProviderForFormat fully + injectMemoryAndSkills guards/empty-DB",
|
||||
"path) and added them as batch i.",
|
||||
"",
|
||||
"The FINAL chatCore leaf, semanticCache.ts, was added to batch i once its cache-HIT block had a",
|
||||
"fixture: chatcore-semantic-cache now SEEDS the real cache via setCachedResponse (the in-memory",
|
||||
"store getCachedResponse checks first \u2014 no mock.module needed) under the exact signature",
|
||||
"store getCachedResponse checks first — no mock.module needed) under the exact signature",
|
||||
"checkSemanticCache rebuilds, so the HIT branch runs end-to-end (status 200 / 'semantic' / 'HIT' /",
|
||||
"the stream + content-type ternaries / the cost fallback / the side-effect calls all get killed).",
|
||||
"ALL 15 chatCore leaves are now mutated.",
|
||||
"",
|
||||
"STILL EXCLUDED (follow-ups, NOT in `mutate` yet):",
|
||||
" - combo.ts + chatCore.ts barrels: their handleComboChat/handleChatCore CORES were not",
|
||||
" split (out of scope \u2014 Fase 3 ChatCoreContext refactor). The barrels are now thin-ish",
|
||||
" split (out of scope — Fase 3 ChatCoreContext refactor). The barrels are now thin-ish",
|
||||
" but still large; keep excluded until the cores are split.",
|
||||
"See project memory: Quality Gate v2 / Fase 9 (project-combo-split)."
|
||||
],
|
||||
@@ -498,7 +506,11 @@
|
||||
".worktrees",
|
||||
".stryker-tmp"
|
||||
],
|
||||
"reporters": ["progress", "html", "json"],
|
||||
"reporters": [
|
||||
"progress",
|
||||
"html",
|
||||
"json"
|
||||
],
|
||||
"htmlReporter": {
|
||||
"fileName": "reports/mutation/mutation.html"
|
||||
},
|
||||
@@ -525,11 +537,11 @@
|
||||
"would break the required all-green baseline dry-run (e.g. body-timeout-integration,",
|
||||
"heap-pressure, sse-heartbeat-integration, *-stream-readiness, chatcore-memory-pressure).",
|
||||
"It is enumerated (not a broad glob) so the Stryker dry-run stays tractable for the",
|
||||
"nightly budget \u2014 a glob over the full ~1300-file unit suite would make the per-test",
|
||||
"nightly budget — a glob over the full ~1300-file unit suite would make the per-test",
|
||||
"dry-run take hours. coverageAnalysis:perTest then narrows which files run per mutant.",
|
||||
"Regenerate the base union after adding/renaming covering tests, then re-prune flaky ones:",
|
||||
" grep -rlE \"circuitBreaker|publicCreds|accountFallback|routeGuard|services/auth|chatCore|services/combo|utils/error|public-client|account-fallback|route-guard|circuit-breaker\" tests/unit --include=\"*.test.ts\" | sort -u"
|
||||
],
|
||||
"dryRunTimeoutMinutes": 30,
|
||||
"_concurrency_comment": "concurrency=4 (was 1): the covering node:test files used to share SQLite/module state via the default DATA_DIR (~/.omniroute), so running them concurrently in the Stryker sandbox caused cross-file races that failed the all-green baseline. tap.nodeArgs now imports ./tests/_setup/isolateDataDir.ts, which gives each spawned test process its own temp DATA_DIR \u2014 eliminating the shared on-disk DB, so concurrency>1 is deterministic. A/B verified 2026-06-17: dry-run at concurrency=4 fails WITHOUT the isolation import (account-fallback-service tap exit 9) and passes WITH it. Raise further only if the runner has spare cores."
|
||||
"_concurrency_comment": "concurrency=4 (was 1): the covering node:test files used to share SQLite/module state via the default DATA_DIR (~/.omniroute), so running them concurrently in the Stryker sandbox caused cross-file races that failed the all-green baseline. tap.nodeArgs now imports ./tests/_setup/isolateDataDir.ts, which gives each spawned test process its own temp DATA_DIR — eliminating the shared on-disk DB, so concurrency>1 is deterministic. A/B verified 2026-06-17: dry-run at concurrency=4 fails WITHOUT the isolation import (account-fallback-service tap exit 9) and passes WITH it. Raise further only if the runner has spare cores."
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ if (!process.env.DATA_DIR) {
|
||||
// Best-effort cleanup so a long suite run does not leak hundreds of temp DBs.
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
// ignore — the OS reaps its temp dir eventually.
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ test.after(async () => {
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
core.closeDbInstance();
|
||||
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("primary healthy: request routes to Server A only", async () => {
|
||||
|
||||
@@ -286,7 +286,7 @@ export async function createChatPipelineHarness(prefix) {
|
||||
clearSkillState();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(testDataDir, { recursive: true });
|
||||
initTranslators();
|
||||
}
|
||||
@@ -300,7 +300,7 @@ export async function createChatPipelineHarness(prefix) {
|
||||
clearSkillState();
|
||||
resetAllCircuitBreakers();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
async function seedConnection(provider: string, overrides: SeedConnectionOverrides = {}) {
|
||||
|
||||
@@ -24,7 +24,7 @@ const DEFAULT_PATTERNS = [".bank.", ".gov.", "okta.com", "auth0.com"];
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ test.beforeEach(() => {
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST patterns ──────────────────────────────────────────────────────────
|
||||
@@ -48,7 +52,10 @@ test("POST /bypass: stores user patterns", async () => {
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as { ok: boolean; patterns: Array<{ pattern: string; source: string }> };
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
patterns: Array<{ pattern: string; source: string }>;
|
||||
};
|
||||
assert.equal(body.ok, true);
|
||||
assert.ok(Array.isArray(body.patterns));
|
||||
const userPatterns = body.patterns.filter((p) => p.source === "user");
|
||||
@@ -64,7 +71,7 @@ test("POST /bypass: invalid body returns 400", async () => {
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
||||
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 400 error");
|
||||
});
|
||||
@@ -83,7 +90,7 @@ test("GET /bypass: shows default + user patterns", async () => {
|
||||
|
||||
const res = await bypassRoute.GET();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as { patterns: Array<{ pattern: string; source: string }> };
|
||||
const body = (await res.json()) as { patterns: Array<{ pattern: string; source: string }> };
|
||||
assert.ok(Array.isArray(body.patterns));
|
||||
|
||||
const sources = new Set(body.patterns.map((p) => p.source));
|
||||
@@ -119,7 +126,10 @@ test("DELETE /bypass?pattern=X: removes a user pattern", async () => {
|
||||
})
|
||||
);
|
||||
assert.equal(deleteRes.status, 200);
|
||||
const deleteBody = await deleteRes.json() as { ok: boolean; patterns: Array<{ pattern: string; source: string }> };
|
||||
const deleteBody = (await deleteRes.json()) as {
|
||||
ok: boolean;
|
||||
patterns: Array<{ pattern: string; source: string }>;
|
||||
};
|
||||
assert.equal(deleteBody.ok, true);
|
||||
|
||||
// Verify it's gone
|
||||
@@ -142,19 +152,18 @@ test("DELETE /bypass: missing pattern param returns 400", async () => {
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
||||
assert.ok(!errMsg.includes("at /"), "stack trace leaked in DELETE 400");
|
||||
});
|
||||
|
||||
test("DELETE /bypass?pattern=X: no-op when pattern not in user list", async () => {
|
||||
const res = await bypassRoute.DELETE(
|
||||
new Request(
|
||||
"http://localhost/api/tools/agent-bridge/bypass?pattern=not-in-list.com",
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
new Request("http://localhost/api/tools/agent-bridge/bypass?pattern=not-in-list.com", {
|
||||
method: "DELETE",
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as { ok: boolean };
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
assert.equal(body.ok, true);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@ process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const certRoute = await import("../../src/app/api/tools/agent-bridge/cert/route.ts");
|
||||
const downloadRoute = await import("../../src/app/api/tools/agent-bridge/cert/download/route.ts");
|
||||
const regenerateRoute = await import("../../src/app/api/tools/agent-bridge/cert/regenerate/route.ts");
|
||||
const regenerateRoute =
|
||||
await import("../../src/app/api/tools/agent-bridge/cert/regenerate/route.ts");
|
||||
|
||||
function certDir() {
|
||||
return path.join(TEST_DATA_DIR, "mitm");
|
||||
@@ -30,7 +31,7 @@ function certFilePath() {
|
||||
}
|
||||
|
||||
function resetCertDir() {
|
||||
fs.rmSync(certDir(), { recursive: true, force: true });
|
||||
fs.rmSync(certDir(), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(certDir(), { recursive: true });
|
||||
}
|
||||
|
||||
@@ -39,7 +40,11 @@ test.beforeEach(() => {
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /cert ─────────────────────────────────────────────────────────────
|
||||
@@ -47,7 +52,7 @@ test.after(() => {
|
||||
test("GET /cert: returns exists:false when no cert file", async () => {
|
||||
const res = await certRoute.GET();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.equal(body.exists, false);
|
||||
assert.equal(body.trusted, false);
|
||||
assert.equal(body.path, null);
|
||||
@@ -59,7 +64,7 @@ test("GET /cert: returns exists:true when cert file present", async () => {
|
||||
|
||||
const res = await certRoute.GET();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.equal(body.exists, true);
|
||||
// trusted may be false in test env (no system store)
|
||||
assert.ok(typeof body.trusted === "boolean");
|
||||
@@ -83,7 +88,7 @@ test("POST /cert: returns 404 when no cert file", async () => {
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 404);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
||||
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 404 error message");
|
||||
});
|
||||
@@ -106,7 +111,7 @@ MIIBpDCCAQ2gAwIBAgIUFakeMITMCertForTestingOnlyXX==
|
||||
|
||||
// In test env: installCert may throw because the PEM is fake; we accept
|
||||
// either 200 (mocked) or 500 (real OS failure) — NOT a 500 with stack trace
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errMsg = (body.error as Record<string, unknown>)?.message as string | undefined;
|
||||
if (errMsg) {
|
||||
assert.ok(!errMsg.includes("at /"), "stack trace leaked in POST /cert error");
|
||||
@@ -118,7 +123,7 @@ MIIBpDCCAQ2gAwIBAgIUFakeMITMCertForTestingOnlyXX==
|
||||
test("GET /cert/download: 404 when no cert file", async () => {
|
||||
const res = await downloadRoute.GET();
|
||||
assert.equal(res.status, 404);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
||||
assert.ok(!errMsg.includes("at /"), "stack trace leaked in download 404");
|
||||
});
|
||||
|
||||
@@ -18,13 +18,12 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const mappingsRoute = await import(
|
||||
"../../src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts"
|
||||
);
|
||||
const mappingsRoute =
|
||||
await import("../../src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -33,18 +32,21 @@ test.beforeEach(() => {
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET (empty) ────────────────────────────────────────────────────────────
|
||||
|
||||
test("GET /mappings: returns empty array for new agent", async () => {
|
||||
const res = await mappingsRoute.GET(
|
||||
new Request("http://localhost/"),
|
||||
{ params: { id: "copilot" } }
|
||||
);
|
||||
const res = await mappingsRoute.GET(new Request("http://localhost/"), {
|
||||
params: { id: "copilot" },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as { mappings: unknown[] };
|
||||
const body = (await res.json()) as { mappings: unknown[] };
|
||||
assert.ok(Array.isArray(body.mappings));
|
||||
assert.equal(body.mappings.length, 0);
|
||||
});
|
||||
@@ -66,17 +68,21 @@ test("PUT → GET round-trip: stores and retrieves mappings", async () => {
|
||||
{ params: { id: "copilot" } }
|
||||
);
|
||||
assert.equal(putRes.status, 200);
|
||||
const putBody = await putRes.json() as { ok: boolean; mappings: Array<{ agent_id: string; source_model: string; target_model: string }> };
|
||||
const putBody = (await putRes.json()) as {
|
||||
ok: boolean;
|
||||
mappings: Array<{ agent_id: string; source_model: string; target_model: string }>;
|
||||
};
|
||||
assert.equal(putBody.ok, true);
|
||||
assert.equal(putBody.mappings.length, 2);
|
||||
|
||||
// GET reads back the same data
|
||||
const getRes = await mappingsRoute.GET(
|
||||
new Request("http://localhost/"),
|
||||
{ params: { id: "copilot" } }
|
||||
);
|
||||
const getRes = await mappingsRoute.GET(new Request("http://localhost/"), {
|
||||
params: { id: "copilot" },
|
||||
});
|
||||
assert.equal(getRes.status, 200);
|
||||
const getBody = await getRes.json() as { mappings: Array<{ source_model: string; target_model: string }> };
|
||||
const getBody = (await getRes.json()) as {
|
||||
mappings: Array<{ source_model: string; target_model: string }>;
|
||||
};
|
||||
assert.equal(getBody.mappings.length, 2);
|
||||
|
||||
const sources = getBody.mappings.map((m) => m.source_model).sort();
|
||||
@@ -108,11 +114,10 @@ test("PUT: replaces all previous mappings", async () => {
|
||||
);
|
||||
assert.equal(putRes.status, 200);
|
||||
|
||||
const getRes = await mappingsRoute.GET(
|
||||
new Request("http://localhost/"),
|
||||
{ params: { id: "cursor" } }
|
||||
);
|
||||
const body = await getRes.json() as { mappings: Array<{ source_model: string }> };
|
||||
const getRes = await mappingsRoute.GET(new Request("http://localhost/"), {
|
||||
params: { id: "cursor" },
|
||||
});
|
||||
const body = (await getRes.json()) as { mappings: Array<{ source_model: string }> };
|
||||
assert.equal(body.mappings.length, 1);
|
||||
assert.equal(body.mappings[0].source_model, "new-model");
|
||||
});
|
||||
@@ -136,7 +141,7 @@ test("PUT: empty mappings array clears all mappings", async () => {
|
||||
{ params: { id: "zed" } }
|
||||
);
|
||||
assert.equal(putRes.status, 200);
|
||||
const body = await putRes.json() as { mappings: unknown[] };
|
||||
const body = (await putRes.json()) as { mappings: unknown[] };
|
||||
assert.equal(body.mappings.length, 0);
|
||||
});
|
||||
|
||||
@@ -152,7 +157,7 @@ test("PUT: invalid body (missing mappings) returns 400", async () => {
|
||||
{ params: { id: "antigravity" } }
|
||||
);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
||||
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 400 error");
|
||||
});
|
||||
@@ -183,10 +188,9 @@ test("PUT: error responses do not leak stack traces", async () => {
|
||||
});
|
||||
|
||||
test("GET: error responses do not leak stack traces", async () => {
|
||||
const res = await mappingsRoute.GET(
|
||||
new Request("http://localhost/"),
|
||||
{ params: { id: "antigravity" } }
|
||||
);
|
||||
const res = await mappingsRoute.GET(new Request("http://localhost/"), {
|
||||
params: { id: "antigravity" },
|
||||
});
|
||||
const text = await res.text();
|
||||
assert.ok(!text.includes("at /"), "stack trace leaked in GET /mappings response");
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ const routeGuard = await import("../../src/server/authz/routeGuard.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ test.beforeEach(() => {
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ test.beforeEach(async () => {
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
// ── Auth tests ────────────────────────────────────────────────────────────────
|
||||
@@ -288,6 +288,6 @@ test("grok-build status uses GROK_HOME and returns its managed endpoint", async
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.GROK_HOME;
|
||||
else process.env.GROK_HOME = original;
|
||||
fs.rmSync(grokHome, { recursive: true, force: true });
|
||||
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,14 +25,13 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-8491-antigravit
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { AntigravityExecutor } = await import("../../open-sse/executors/antigravity.ts");
|
||||
const { clearAntigravityProjectCache } = await import(
|
||||
"../../open-sse/services/antigravityProjectBootstrap.ts"
|
||||
);
|
||||
const { clearAntigravityProjectCache } =
|
||||
await import("../../open-sse/services/antigravityProjectBootstrap.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,7 +90,11 @@ test("#8491 PART A: runtime-discovered projectId must be persisted to the connec
|
||||
throw new Error(`Expected an envelope but got a ${result.status} Response`);
|
||||
}
|
||||
assert.equal(loadCodeAssistCalls, 1, "loadCodeAssist must be called to recover the project");
|
||||
assert.equal(result.project, DISCOVERED_PROJECT_ID, "the in-flight request uses the discovered id");
|
||||
assert.equal(
|
||||
result.project,
|
||||
DISCOVERED_PROJECT_ID,
|
||||
"the in-flight request uses the discovered id"
|
||||
);
|
||||
|
||||
const persisted = await providersDb.getProviderConnectionById(connection.id);
|
||||
assert.equal(
|
||||
|
||||
@@ -25,7 +25,7 @@ async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ test.beforeEach(async () => {
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("API keys routes require management auth when login protection is enabled", async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ async function resetStorage() {
|
||||
delete process.env.ENABLE_SOCKS5_PROXY;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ test.beforeEach(async () => {
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("critical routes: v1 management proxies covers auth, lookup, where-used, patch, and delete branches", async () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ const auditRoute = await import("../../src/app/api/compliance/audit-log/route.ts
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ test.beforeEach(() => {
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -268,7 +268,7 @@ async function stopProcess(child: ReturnType<typeof spawn>) {
|
||||
async function removeDirWithRetry(dir: string) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === 4) throw error;
|
||||
|
||||
@@ -373,7 +373,7 @@ async function resetStorage() {
|
||||
invalidateMemorySettingsCache();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
initTranslators();
|
||||
}
|
||||
@@ -512,7 +512,7 @@ test.after(async () => {
|
||||
clearInflight();
|
||||
resetAllCircuitBreakers();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("chat pipeline handles OpenAI passthrough with valid API key auth", async () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ async function resetStorage() {
|
||||
readCacheDb.invalidateDbCache();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ test.after(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.closeDbInstance();
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ test.after(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.closeDbInstance();
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
|
||||
@@ -22,12 +22,13 @@ process.env.JWT_SECRET = "test-jwt-secret-codewhale";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/codewhale-settings/route.ts");
|
||||
const { GET, POST, DELETE } =
|
||||
await import("../../src/app/api/cli-tools/codewhale-settings/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ test("codewhale-settings POST: writes primary ~/.codewhale/config.toml for a fre
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -173,7 +174,7 @@ test("codewhale-settings POST: syncs an existing legacy ~/.deepseek/config.toml"
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -201,7 +202,7 @@ test("codewhale-settings GET: falls back to legacy ~/.deepseek/config.toml when
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -238,7 +239,7 @@ test("codewhale-settings DELETE: removes primary and legacy config files", async
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -272,7 +273,7 @@ test("codewhale-settings route.ts: does not call exec() or spawn() directly", ()
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -8,9 +8,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-deepseek-tui-settings-")
|
||||
);
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-deepseek-tui-settings-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret-deepseek-tui";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-deepseek-tui";
|
||||
@@ -18,14 +16,13 @@ process.env.JWT_SECRET = "test-jwt-secret-deepseek-tui";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/deepseek-tui-settings/route.ts"
|
||||
);
|
||||
const { GET, POST, DELETE } =
|
||||
await import("../../src/app/api/cli-tools/deepseek-tui-settings/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -103,10 +100,7 @@ test("deepseek-tui-settings POST: writes config.toml with valid body", async ()
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Unexpected status ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
@@ -120,7 +114,7 @@ test("deepseek-tui-settings POST: writes config.toml with valid body", async ()
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,23 +130,20 @@ test("deepseek-tui-settings DELETE: removes config file", async () => {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, "config.toml"),
|
||||
"# managed by OmniRoute (plan 14)\n[openai]\nbase_url = \"http://localhost:20128\"\n"
|
||||
'# managed by OmniRoute (plan 14)\n[openai]\nbase_url = "http://localhost:20128"\n'
|
||||
);
|
||||
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/cli-tools/deepseek-tui-settings", { method: "DELETE" })
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Expected 200/403/500, got ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,7 +177,7 @@ test("deepseek-tui-settings route.ts: does not call exec() or spawn() directly",
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -19,14 +19,12 @@ const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
// Import route handlers
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/forge-settings/route.ts"
|
||||
);
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/forge-settings/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -107,10 +105,7 @@ test("forge-settings POST: writes config.toml with valid body", async () => {
|
||||
);
|
||||
|
||||
// 200 = success; 403 = write guard active (test env); 500 = backup dir issue
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Unexpected status ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
@@ -126,7 +121,7 @@ test("forge-settings POST: writes config.toml with valid body", async () => {
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -143,16 +138,13 @@ test("forge-settings DELETE: removes config file when it exists", async () => {
|
||||
fs.mkdirSync(forgeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(forgeDir, "config.toml"),
|
||||
"# managed by OmniRoute (plan 14)\n[openai]\nbase_url = \"http://localhost:20128\"\n"
|
||||
'# managed by OmniRoute (plan 14)\n[openai]\nbase_url = "http://localhost:20128"\n'
|
||||
);
|
||||
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/cli-tools/forge-settings", { method: "DELETE" })
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Expected 200/403/500, got ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
@@ -160,7 +152,7 @@ test("forge-settings DELETE: removes config file when it exists", async () => {
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -194,7 +186,7 @@ test("forge-settings route.ts: does not call exec() or spawn() directly", () =>
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -39,7 +39,7 @@ const { GET, POST, DELETE } =
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ test("grok-build-settings POST: writes [model.omniroute] section and preserves e
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -219,7 +219,7 @@ test("grok-build-settings DELETE: removes our section, preserves the rest, resto
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -237,7 +237,7 @@ test("grok-build-settings DELETE: no-op success when no config file exists", asy
|
||||
assert.equal(body.success, true);
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -289,7 +289,7 @@ test("grok-build-settings: honors GROK_HOME and rejects a relative value", async
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.GROK_HOME;
|
||||
else process.env.GROK_HOME = original;
|
||||
fs.rmSync(grokHome, { recursive: true, force: true });
|
||||
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -313,7 +313,7 @@ test("grok-build-settings POST: returns 409 for an unowned omniroute slot", asyn
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.GROK_HOME;
|
||||
else process.env.GROK_HOME = original;
|
||||
fs.rmSync(grokHome, { recursive: true, force: true });
|
||||
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -342,7 +342,7 @@ test("grok-build-settings POST: resolves keyId to an unmasked key", async () =>
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.GROK_HOME;
|
||||
else process.env.GROK_HOME = original;
|
||||
fs.rmSync(grokHome, { recursive: true, force: true });
|
||||
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -376,7 +376,7 @@ test("grok-build-settings route.ts: does not call exec() or spawn() directly", (
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -21,7 +21,7 @@ const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/jcode-se
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ test("jcode-settings POST: writes [providers.omniroute] into config.toml", async
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ test("jcode-settings DELETE: removes only the OmniRoute-managed block", async ()
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ test("jcode-settings route.ts: does not call exec() or spawn() directly", () =>
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -22,9 +22,7 @@ process.env.JWT_SECRET = "test-jwt-secret-letta";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/letta-settings/route.ts"
|
||||
);
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/letta-settings/route.ts");
|
||||
|
||||
let tmpHome: string;
|
||||
let origHome: string | undefined;
|
||||
@@ -40,7 +38,7 @@ function req(init?: RequestInit) {
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -58,7 +56,7 @@ test.beforeEach(async () => {
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
|
||||
@@ -189,7 +187,7 @@ test("letta-settings: error responses do not leak stack traces", async () => {
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -59,7 +59,7 @@ function seedOmpDb() {
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ test.beforeEach(async () => {
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
|
||||
@@ -190,7 +190,7 @@ test("omp-settings: error responses do not leak stack traces", async () => {
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -16,14 +16,12 @@ process.env.JWT_SECRET = "test-jwt-secret-pi";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/pi-settings/route.ts"
|
||||
);
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/pi-settings/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -101,10 +99,7 @@ test("pi-settings POST: writes config.json with valid body", async () => {
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Unexpected status ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
@@ -118,7 +113,7 @@ test("pi-settings POST: writes config.json with valid body", async () => {
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,17 +140,14 @@ test("pi-settings DELETE: removes OmniRoute fields from existing config", async
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/cli-tools/pi-settings", { method: "DELETE" })
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Expected 200/403/500, got ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -189,7 +181,7 @@ test("pi-settings route.ts: does not call exec() or spawn() directly", () => {
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -16,14 +16,12 @@ process.env.JWT_SECRET = "test-jwt-secret-smelt";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/smelt-settings/route.ts"
|
||||
);
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/smelt-settings/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -101,10 +99,7 @@ test("smelt-settings POST: writes config.json with valid body", async () => {
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Unexpected status ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
@@ -118,7 +113,7 @@ test("smelt-settings POST: writes config.json with valid body", async () => {
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,17 +140,14 @@ test("smelt-settings DELETE: removes OmniRoute fields from existing config", asy
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/cli-tools/smelt-settings", { method: "DELETE" })
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Expected 200/403/500, got ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -189,7 +181,7 @@ test("smelt-settings route.ts: does not call exec() or spawn() directly", () =>
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
@@ -44,6 +44,6 @@ test("Codex Spark cooldown survives a fresh process without creating child conne
|
||||
assert.equal(after.connectionId, before.connectionId);
|
||||
assert.deepEqual(after.upstreamModels, ["gpt-5.5"]);
|
||||
} finally {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -305,6 +305,6 @@ test("chat completions streams Codex Responses reasoning through real route HTTP
|
||||
globalThis.fetch = originalFetch;
|
||||
if (routeServer) await closeServer(routeServer);
|
||||
core.closeDbInstance({ checkpointMode: null });
|
||||
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user