mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-29 02:22:10 +03:00
Compare commits
28 Commits
dependabot
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777d9d1629 | ||
|
|
9661611e31 | ||
|
|
24c0643a94 | ||
|
|
a94fe23e89 | ||
|
|
8dfdd95187 | ||
|
|
226538fa27 | ||
|
|
fb7445eaa3 | ||
|
|
9968e1ce6e | ||
|
|
f907b5ea8e | ||
|
|
5b38ec717d | ||
|
|
5ade9e0851 | ||
|
|
33763f06cc | ||
|
|
6b259812a7 | ||
|
|
dc75a02ca7 | ||
|
|
3d2832b836 | ||
|
|
cea1baa797 | ||
|
|
dd35750e5f | ||
|
|
c661e1c811 | ||
|
|
529e4415c5 | ||
|
|
f08f35d6f0 | ||
|
|
d846692c30 | ||
|
|
c5ebbb733c | ||
|
|
b8c7ee599d | ||
|
|
f564b64f7d | ||
|
|
9dc8eab70e | ||
|
|
e71be03398 | ||
|
|
09de69edc7 | ||
|
|
e4683cd22d |
@@ -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
|
||||
|
||||
43
.github/workflows/ci.yml
vendored
43
.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
|
||||
@@ -1233,10 +1252,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
|
||||
|
||||
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
|
||||
|
||||
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 }}
|
||||
|
||||
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).
|
||||
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.
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,66 @@ 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 | **10 listeners**: 8 OmniRoute + OmniHeuris + OmniMind | all share the memory above |
|
||||
|
||||
## 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=8 /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).
|
||||
- **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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS,
|
||||
ALIBABA_NO_FREE_TIER_TEXT_MODELS,
|
||||
@@ -30,31 +30,90 @@ test("built-in allowlist includes operator free models and excludes paid blockli
|
||||
assert.equal(isAlibabaBuiltinFreeTierTextModel("qwen3.7-max"), false);
|
||||
});
|
||||
|
||||
test("allowlist JSON pack overrides embedded lists when valid", () => {
|
||||
/**
|
||||
* The shipped `config/alibaba-free-tier-allowlist.json` carries a `validUntil`,
|
||||
* so asserting against it made this test a time bomb: it went red on its own on
|
||||
* 2026-08-28, the day after the pack expired, and stayed red on every PR and on
|
||||
* `main` (#11866). Nothing had changed — the clock moved.
|
||||
*
|
||||
* Production was never affected: an expired pack falls back to the embedded
|
||||
* list by design. So the contract worth pinning is the BEHAVIOR on both sides of
|
||||
* the expiry, with packs this test owns and dates it controls — never the
|
||||
* freshness of the catalog that ships in the repo.
|
||||
*/
|
||||
function withAllowlistPack(
|
||||
pack: Record<string, unknown>,
|
||||
assertions: () => void
|
||||
): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "alibaba-allowlist-"));
|
||||
const packPath = join(dir, "allowlist.json");
|
||||
writeFileSync(packPath, JSON.stringify(pack), "utf8");
|
||||
|
||||
const previousPath = process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH;
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-alibaba-allowlist-"));
|
||||
const packPath = path.join(fixtureDir, "allowlist.json");
|
||||
|
||||
process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH = packPath;
|
||||
resetAlibabaFreeTierAllowlistCache();
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
packPath,
|
||||
JSON.stringify({
|
||||
asOf: "2026-08-27",
|
||||
capable: ["qwen3.6-plus"],
|
||||
noFreeTier: ["qwen3.7-max"],
|
||||
})
|
||||
);
|
||||
process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH = packPath;
|
||||
resetAlibabaFreeTierAllowlistCache();
|
||||
|
||||
const pack = loadAlibabaFreeTierAllowlistPack();
|
||||
assert.ok(pack);
|
||||
assert.ok(isAlibabaFreeTierAllowlistPackValid(pack));
|
||||
assert.ok(pack.capable.includes("qwen3.6-plus"));
|
||||
assertions();
|
||||
} finally {
|
||||
if (previousPath) process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH = previousPath;
|
||||
else delete process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH;
|
||||
resetAlibabaFreeTierAllowlistCache();
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("allowlist JSON pack overrides embedded lists while it is still valid", () => {
|
||||
withAllowlistPack(
|
||||
{
|
||||
asOf: "2026-07-28",
|
||||
validUntil: "2999-01-01",
|
||||
capable: ["pack-only-capable-model", "qwen3.6-plus"],
|
||||
noFreeTier: ["pack-only-paid-model"],
|
||||
},
|
||||
() => {
|
||||
const pack = loadAlibabaFreeTierAllowlistPack();
|
||||
assert.ok(pack, "a pack inside its validity window must load");
|
||||
assert.ok(isAlibabaFreeTierAllowlistPackValid(pack!));
|
||||
assert.ok(pack!.capable.includes("qwen3.6-plus"));
|
||||
// Positive anchor: the pack must actually REPLACE the embedded list, not
|
||||
// merely load. `pack-only-capable-model` exists nowhere else.
|
||||
assert.equal(isAlibabaBuiltinFreeTierTextModel("pack-only-capable-model"), true);
|
||||
assert.equal(isAlibabaBuiltinNoFreeTierTextModel("pack-only-paid-model"), true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("an expired allowlist pack is ignored and the embedded list serves instead", () => {
|
||||
// This is the path production has actually been on since 2026-08-27, and it
|
||||
// had no coverage at all — which is why the expiry surfaced as a red test
|
||||
// rather than as a deliberate, understood fallback.
|
||||
withAllowlistPack(
|
||||
{
|
||||
asOf: "2026-07-28",
|
||||
validUntil: "2026-08-27",
|
||||
capable: ["pack-only-capable-model"],
|
||||
noFreeTier: ["pack-only-paid-model"],
|
||||
},
|
||||
() => {
|
||||
assert.equal(loadAlibabaFreeTierAllowlistPack(), null, "expired pack must not load");
|
||||
assert.equal(isAlibabaBuiltinFreeTierTextModel("pack-only-capable-model"), false);
|
||||
// The embedded list must be what answers once the pack is rejected.
|
||||
assert.equal(isAlibabaBuiltinFreeTierTextModel("qwen3.6-plus"), true);
|
||||
assert.equal(isAlibabaBuiltinNoFreeTierTextModel("qwen3.7-max"), true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("isAlibabaFreeTierAllowlistPackValid compares against the instant it is given", () => {
|
||||
const pack = { asOf: "2026-07-28", validUntil: "2026-08-27", capable: ["x"], noFreeTier: [] };
|
||||
assert.equal(isAlibabaFreeTierAllowlistPackValid(pack, Date.parse("2026-08-26")), true);
|
||||
assert.equal(isAlibabaFreeTierAllowlistPackValid(pack, Date.parse("2026-08-28")), false);
|
||||
// No expiry declared means the pack never goes stale on its own.
|
||||
assert.equal(
|
||||
isAlibabaFreeTierAllowlistPackValid(
|
||||
{ asOf: "2026-07-28", capable: ["x"], noFreeTier: [] },
|
||||
Date.parse("2999-01-01")
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,10 @@ function writeManifest(
|
||||
recordType: "manifest",
|
||||
schemaVersion: 1,
|
||||
expectedAssetCount: records.filter((record) => record.recordType === "asset").length,
|
||||
auditedCommit: "091589089cd134a94df9f6cdab9ba562b2cefd18",
|
||||
// HEAD instead of a pinned SHA: the fast-unit shards run on a shallow checkout,
|
||||
// where a historical commit object does not exist and the gate would reject
|
||||
// the fixture before exercising what the test is about.
|
||||
auditedCommit: gitObjectId("HEAD"),
|
||||
auditedAt: "2026-08-26",
|
||||
legalScope:
|
||||
"Provenance records source matching only; it does not establish copyright or trademark clearance.",
|
||||
@@ -98,12 +101,53 @@ function gitObjectId(revision: string) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function gitRootCommit() {
|
||||
const result = spawnSync("git", ["-C", REPO_ROOT, "rev-list", "--max-parents=0", "HEAD"], {
|
||||
function gitHasCommit(objectId: string) {
|
||||
return (
|
||||
spawnSync("git", ["-C", REPO_ROOT, "cat-file", "-e", `${objectId}^{commit}`], {
|
||||
encoding: "utf8",
|
||||
}).status === 0
|
||||
);
|
||||
}
|
||||
|
||||
function isShallowRepository() {
|
||||
const result = spawnSync("git", ["-C", REPO_ROOT, "rev-parse", "--is-shallow-repository"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return result.stdout.trim().split(/\r?\n/)[0];
|
||||
return result.status === 0 && result.stdout.trim() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* A commit whose tree is empty, so every physical provider file is "missing"
|
||||
* from its snapshot. Built as a dangling object (no ref is written) so it also
|
||||
* works on the shallow checkouts the unit shards use, where the root commit is
|
||||
* the grafted HEAD itself and would match the physical snapshot exactly.
|
||||
*/
|
||||
function emptyTreeCommit() {
|
||||
const tree = spawnSync("git", ["-C", REPO_ROOT, "hash-object", "-w", "-t", "tree", "--stdin"], {
|
||||
input: "",
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(tree.status, 0, tree.stderr);
|
||||
const identity = {
|
||||
GIT_AUTHOR_NAME: "provenance-fixture",
|
||||
GIT_AUTHOR_EMAIL: "provenance-fixture@example.invalid",
|
||||
GIT_COMMITTER_NAME: "provenance-fixture",
|
||||
GIT_COMMITTER_EMAIL: "provenance-fixture@example.invalid",
|
||||
};
|
||||
const commit = spawnSync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
REPO_ROOT,
|
||||
"commit-tree",
|
||||
tree.stdout.trim(),
|
||||
"-m",
|
||||
"provenance fixture: empty snapshot",
|
||||
],
|
||||
{ encoding: "utf8", env: { ...process.env, ...identity } }
|
||||
);
|
||||
assert.equal(commit.status, 0, commit.stderr);
|
||||
return commit.stdout.trim();
|
||||
}
|
||||
|
||||
function workflowJob(source: string, name: string) {
|
||||
@@ -479,7 +523,7 @@ test("provider asset provenance gate binds auditedCommit to the physical provide
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
records[0] = { ...records[0], auditedCommit: gitRootCommit() };
|
||||
records[0] = { ...records[0], auditedCommit: emptyTreeCommit() };
|
||||
writeFileSync(
|
||||
fixture.manifestPath,
|
||||
`${records.map((record) => JSON.stringify(record)).join("\n")}\n`
|
||||
@@ -497,11 +541,17 @@ test("provider asset provenance gate binds auditedCommit to the physical provide
|
||||
}
|
||||
});
|
||||
|
||||
test("repository provider asset manifest covers the audited 142-file snapshot", () => {
|
||||
const result = runGate(
|
||||
join(REPO_ROOT, "public/providers"),
|
||||
join(REPO_ROOT, "config/quality/provider-assets-provenance.jsonl")
|
||||
);
|
||||
test("repository provider asset manifest covers the audited 142-file snapshot", (t) => {
|
||||
const manifestPath = join(REPO_ROOT, "config/quality/provider-assets-provenance.jsonl");
|
||||
const { auditedCommit } = JSON.parse(readFileSync(manifestPath, "utf8").split("\n")[0]);
|
||||
if (!gitHasCommit(auditedCommit) && isShallowRepository()) {
|
||||
// The real manifest pins a historical commit. The unit shards check out with
|
||||
// depth 1, so it is not fetched there; the gate itself still runs on both
|
||||
// blocking rails with fetch-depth 0 (asserted by the test right below).
|
||||
t.skip(`shallow checkout without auditedCommit ${auditedCommit}`);
|
||||
return;
|
||||
}
|
||||
const result = runGate(join(REPO_ROOT, "public/providers"), manifestPath);
|
||||
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(
|
||||
|
||||
145
tests/unit/check-workflows-provenance-runner.test.ts
Normal file
145
tests/unit/check-workflows-provenance-runner.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
classifyRunsOn,
|
||||
findProvenanceOnSelfHosted,
|
||||
} from "../../scripts/check/lib/provenanceRunner.mjs";
|
||||
|
||||
/**
|
||||
* v3.8.50, 10th publish attempt, 76 minutes in — after the tag, the GitHub
|
||||
* Release and the Docker images were already public:
|
||||
*
|
||||
* 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
|
||||
* Unsupported GitHub Actions runner environment: "self-hosted".
|
||||
*
|
||||
* `USE_VPS_RUNNER` had routed the publish job to the .113 pool on 2026-08-02;
|
||||
* no release happened between 07-30 and 08-28, so nothing surfaced it. The
|
||||
* pairing is pure text, so it must fail the workflow lint on the PR that
|
||||
* introduces it.
|
||||
*/
|
||||
const ROOT = join(import.meta.dirname, "../..");
|
||||
const WORKFLOWS = join(ROOT, ".github/workflows");
|
||||
|
||||
// The exact runs-on expression npm-publish.yml used when it broke.
|
||||
const VPS_EXPR =
|
||||
"${{ (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' }}";
|
||||
|
||||
function workflow(runsOn: string, run: string, extra = ""): string {
|
||||
return [
|
||||
"name: t",
|
||||
"on: push",
|
||||
"jobs:",
|
||||
" publish:",
|
||||
` runs-on: ${runsOn}`,
|
||||
extra,
|
||||
" steps:",
|
||||
" - name: upload",
|
||||
` run: ${run}`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("classifyRunsOn: literal, array, object-with-labels and the fromJSON expression are self-hosted", () => {
|
||||
assert.equal(classifyRunsOn("self-hosted"), "self-hosted");
|
||||
assert.equal(classifyRunsOn(["self-hosted", "omni-release"]), "self-hosted");
|
||||
assert.equal(classifyRunsOn({ group: "Default", labels: ["self-hosted"] }), "self-hosted");
|
||||
assert.equal(classifyRunsOn(VPS_EXPR), "self-hosted");
|
||||
});
|
||||
|
||||
test("classifyRunsOn: hosted labels are hosted, opaque expressions are unknown (never guessed)", () => {
|
||||
assert.equal(classifyRunsOn("ubuntu-latest"), "hosted");
|
||||
assert.equal(classifyRunsOn(["ubuntu-latest"]), "hosted");
|
||||
assert.equal(classifyRunsOn("${{ matrix.os }}"), "unknown");
|
||||
assert.equal(classifyRunsOn(undefined), "unknown");
|
||||
});
|
||||
|
||||
test("flags --provenance inside a job routed to the self-hosted pool", () => {
|
||||
const found = findProvenanceOnSelfHosted(
|
||||
workflow(
|
||||
JSON.stringify(VPS_EXPR),
|
||||
'npm stage publish --provenance --access public --tag "$TAG"'
|
||||
),
|
||||
"npm-publish.yml"
|
||||
);
|
||||
assert.deepEqual(found, [{ file: "npm-publish.yml", job: "publish", step: "upload" }]);
|
||||
});
|
||||
|
||||
test("also catches the literal label and the --provenance-file form", () => {
|
||||
assert.equal(
|
||||
findProvenanceOnSelfHosted(workflow("self-hosted", "npm publish --provenance")).length,
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
findProvenanceOnSelfHosted(
|
||||
workflow("[self-hosted, omni-release]", "npm publish --provenance-file=./p.json")
|
||||
).length,
|
||||
0,
|
||||
"--provenance-file is a different flag (a pre-built bundle) and is not what the registry rejects"
|
||||
);
|
||||
assert.equal(
|
||||
findProvenanceOnSelfHosted(workflow("self-hosted", "npm publish --provenance=true")).length,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test("does not flag hosted jobs, unknown runners, or self-hosted jobs without the flag", () => {
|
||||
assert.deepEqual(
|
||||
findProvenanceOnSelfHosted(workflow("ubuntu-latest", "npm publish --provenance")),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
findProvenanceOnSelfHosted(workflow("${{ matrix.os }}", "npm publish --provenance")),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
findProvenanceOnSelfHosted(workflow("self-hosted", "npm publish --access public")),
|
||||
[]
|
||||
);
|
||||
// The word only in a step NAME or a comment is not a finding.
|
||||
assert.deepEqual(
|
||||
findProvenanceOnSelfHosted(
|
||||
[
|
||||
"name: t",
|
||||
"on: push",
|
||||
"jobs:",
|
||||
" j:",
|
||||
" runs-on: self-hosted",
|
||||
" steps:",
|
||||
" - name: provenance note",
|
||||
" run: echo hi # --provenance later",
|
||||
"",
|
||||
].join("\n")
|
||||
),
|
||||
[],
|
||||
"a comment after the command is still part of the run string — accept that the regex is conservative"
|
||||
);
|
||||
});
|
||||
|
||||
test("reusable-workflow jobs (uses:) and unparseable YAML are not this rule's findings", () => {
|
||||
const reusable = [
|
||||
"name: t",
|
||||
"on: push",
|
||||
"jobs:",
|
||||
" j:",
|
||||
" uses: ./.github/workflows/x.yml",
|
||||
"",
|
||||
].join("\n");
|
||||
assert.deepEqual(findProvenanceOnSelfHosted(reusable), []);
|
||||
assert.deepEqual(findProvenanceOnSelfHosted("jobs: [unclosed"), []);
|
||||
});
|
||||
|
||||
test("regression guard: no workflow in this repo publishes with --provenance from a self-hosted runner", () => {
|
||||
const files = readdirSync(WORKFLOWS).filter((f) => /\.ya?ml$/.test(f));
|
||||
assert.ok(files.length > 10, "expected the real workflow set");
|
||||
const findings = files.flatMap((f) =>
|
||||
findProvenanceOnSelfHosted(readFileSync(join(WORKFLOWS, f), "utf8"), f)
|
||||
);
|
||||
assert.deepEqual(
|
||||
findings,
|
||||
[],
|
||||
`npm rejects provenance from self-hosted runners (422) — move the upload to a github-hosted job: ${JSON.stringify(findings)}`
|
||||
);
|
||||
});
|
||||
135
tests/unit/config-expiry-time-bomb.test.ts
Normal file
135
tests/unit/config-expiry-time-bomb.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
classifyExpiry,
|
||||
collectExpiryFields,
|
||||
scanConfigExpiry,
|
||||
} from "../../scripts/check/lib/configExpiry.mjs";
|
||||
|
||||
/**
|
||||
* Time bombs: config packs with a `validUntil` (or sibling key) that lapse with
|
||||
* no commit involved. The Alibaba free-tier pack expired on 2026-08-27 and from
|
||||
* the 28th every PR and main carried a red Unit Tests shard (#11866). Nothing a
|
||||
* diff review could have caught.
|
||||
*
|
||||
* This suite fails SEVEN DAYS BEFORE any pack under config/ lapses, naming the
|
||||
* file and key, so renewal happens on someone's terms instead of the clock's.
|
||||
*/
|
||||
const ROOT = join(import.meta.dirname, "../..");
|
||||
const CONFIG_DIR = join(ROOT, "config");
|
||||
const DAY = 86_400_000;
|
||||
const WARN_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Packs known to be expired/expiring, each pinned to the issue that owns the
|
||||
* renewal decision. An entry whose pack is no longer expiring FAILS below as a
|
||||
* stale allowlist entry — remove it when the pack is renewed.
|
||||
*/
|
||||
const ALLOWLIST: Record<string, string> = {
|
||||
"alibaba-free-tier-allowlist.json":
|
||||
"#11866 — validUntil 2026-08-27 has passed; the loader already falls back to the embedded list, and renewing the curated free-tier pack is an operator data decision, not a test fix",
|
||||
};
|
||||
|
||||
const NOW = Date.UTC(2026, 7, 28); // 2026-08-28, fixed: this suite must not itself depend on the clock
|
||||
const day = (offset: number) => new Date(NOW + offset * DAY).toISOString().slice(0, 10);
|
||||
|
||||
test("collectExpiryFields: finds nested and array-nested expiry keys, ignores non-string values", () => {
|
||||
const fields = collectExpiryFields(
|
||||
{
|
||||
validUntil: day(3),
|
||||
nested: { expiresAt: day(30), other: "x" },
|
||||
list: [{ expiry: day(-1) }, { expires: 12345 }],
|
||||
expires_at: "not a date",
|
||||
},
|
||||
"pack.json"
|
||||
);
|
||||
assert.deepEqual(
|
||||
fields.map((f) => [f.keyPath, f.expiresAt === null ? null : "date"]),
|
||||
[
|
||||
["validUntil", "date"],
|
||||
["nested.expiresAt", "date"],
|
||||
["list.0.expiry", "date"],
|
||||
["expires_at", null],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyExpiry: expired / expiring inside the warning window / ok / unparseable", () => {
|
||||
const f = (raw: string) => ({
|
||||
file: "p",
|
||||
keyPath: "validUntil",
|
||||
raw,
|
||||
expiresAt: Number.isFinite(Date.parse(raw)) ? Date.parse(raw) : null,
|
||||
});
|
||||
assert.equal(classifyExpiry(f(day(-1)), NOW, WARN_DAYS), "expired");
|
||||
assert.equal(
|
||||
classifyExpiry(f(day(0)), NOW, WARN_DAYS),
|
||||
"expiring",
|
||||
"lapsing today is already too late to be 'ok'"
|
||||
);
|
||||
assert.equal(classifyExpiry(f(day(6)), NOW, WARN_DAYS), "expiring");
|
||||
assert.equal(classifyExpiry(f(day(8)), NOW, WARN_DAYS), "ok");
|
||||
assert.equal(classifyExpiry(f("never"), NOW, WARN_DAYS), "unparseable");
|
||||
});
|
||||
|
||||
test("scanConfigExpiry: walks a config tree, skips node_modules and invalid JSON, keys files portably", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cfg-expiry-"));
|
||||
try {
|
||||
mkdirSync(join(dir, "sub"), { recursive: true });
|
||||
mkdirSync(join(dir, "node_modules", "dep"), { recursive: true });
|
||||
writeFileSync(join(dir, "a.json"), JSON.stringify({ validUntil: day(3) }));
|
||||
writeFileSync(join(dir, "sub", "b.json"), JSON.stringify({ deep: { expiresAt: day(40) } }));
|
||||
writeFileSync(
|
||||
join(dir, "node_modules", "dep", "c.json"),
|
||||
JSON.stringify({ validUntil: day(-5) })
|
||||
);
|
||||
writeFileSync(join(dir, "broken.json"), "{ not json");
|
||||
writeFileSync(join(dir, "notes.txt"), JSON.stringify({ validUntil: day(-5) }));
|
||||
const found = scanConfigExpiry(dir).map((f) => `${f.file}:${f.keyPath}`);
|
||||
assert.deepEqual(found, ["a.json:validUntil", "sub/b.json:deep.expiresAt"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(`repo: no pack under config/ lapses within ${WARN_DAYS} days unless its renewal is tracked`, (t) => {
|
||||
const fields = scanConfigExpiry(CONFIG_DIR);
|
||||
// Positive anchor: the scanner must be seeing SOMETHING, or a renamed key
|
||||
// would silently turn this whole suite into a no-op.
|
||||
assert.ok(
|
||||
fields.length >= 1,
|
||||
"expected at least one dated pack under config/ (the Alibaba allowlist) — if the key was renamed, extend EXPIRY_KEY"
|
||||
);
|
||||
|
||||
const failures: string[] = [];
|
||||
const seenAllowlisted = new Set<string>();
|
||||
for (const f of fields) {
|
||||
const status = classifyExpiry(f, Date.now(), WARN_DAYS);
|
||||
const tracked = ALLOWLIST[f.file];
|
||||
if (status === "unparseable") {
|
||||
t.diagnostic(`${f.file} ${f.keyPath}="${f.raw}" is not a date — not monitored`);
|
||||
continue;
|
||||
}
|
||||
if (status === "ok") continue;
|
||||
if (tracked) {
|
||||
seenAllowlisted.add(f.file);
|
||||
t.diagnostic(`${f.file} ${f.keyPath}=${f.raw} is ${status} — tracked: ${tracked}`);
|
||||
continue;
|
||||
}
|
||||
failures.push(
|
||||
`${f.file} → ${f.keyPath}=${f.raw} is ${status}: renew the pack (or track it in ALLOWLIST with its issue)`
|
||||
);
|
||||
}
|
||||
for (const file of Object.keys(ALLOWLIST)) {
|
||||
if (!seenAllowlisted.has(file)) {
|
||||
failures.push(
|
||||
`stale ALLOWLIST entry: ${file} is no longer expired/expiring — remove it (${ALLOWLIST[file]})`
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(failures, [], failures.join("\n"));
|
||||
});
|
||||
@@ -223,6 +223,12 @@ function createRecoverableDb(sqliteFile) {
|
||||
auth_type TEXT,
|
||||
name TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
test_status TEXT,
|
||||
error_code TEXT,
|
||||
last_error TEXT,
|
||||
last_error_at TEXT,
|
||||
last_error_type TEXT,
|
||||
last_error_source TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -659,6 +665,12 @@ test(
|
||||
name TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
test_status TEXT,
|
||||
error_code TEXT,
|
||||
last_error TEXT,
|
||||
last_error_at TEXT,
|
||||
last_error_type TEXT,
|
||||
last_error_source TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -70,6 +70,12 @@ function runGeminiToClaude(geminiChunk) {
|
||||
const converted = openaiToClaudeResponse(chunk, claudeState);
|
||||
if (converted) claudeEvents.push(...converted);
|
||||
}
|
||||
// End-of-stream flush: production calls the translator once more with `null`
|
||||
// when the upstream stream closes (open-sse/utils/stream.ts flush →
|
||||
// translateResponse(..., null, state)). Since dd35750e5f a finish chunk that
|
||||
// carries no usage is deferred until that flush, so the driver must mirror it.
|
||||
const flushed = openaiToClaudeResponse(null, claudeState);
|
||||
if (flushed) claudeEvents.push(...flushed);
|
||||
return { openaiEvents, claudeEvents };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Regression for #11853 — the providers dashboard crashed with
|
||||
* "Cannot read properties of undefined (reading 'color')" and rendered a
|
||||
* misleading "Failed to load providers — check your connection" card.
|
||||
*
|
||||
* `getLobeProviderIcon()` indexed two plain object literals without own-property
|
||||
* guards. A provider id whose lowercased form is an Object.prototype member
|
||||
* resolves through the prototype chain: `LOBE_PROVIDER_ALIASES["constructor"]`
|
||||
* returns the Object constructor (truthy, so the `if (!iconKey) return null`
|
||||
* guard passes), then `LOBE_ICON_COMPONENTS[<that function>]` is undefined and
|
||||
* `entry.color` throws — taking the whole page down through the App Router
|
||||
* error boundary, since ProviderIcon calls this for every provider card.
|
||||
*
|
||||
* Only `constructor` and `__proto__` are reachable: every other Object.prototype
|
||||
* member is camelCase and no longer collides after `.toLowerCase()`.
|
||||
*
|
||||
* Runner: node --import tsx/esm --test tests/unit/lobe-provider-icons-prototype-collision-11853.test.ts
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { getLobeProviderIcon } = await import("../../src/shared/components/lobeProviderIcons.ts");
|
||||
|
||||
test("#11853 — prototype-colliding provider ids return null instead of throwing", () => {
|
||||
for (const id of ["constructor", "__proto__", "CONSTRUCTOR", "__PROTO__"]) {
|
||||
for (const type of ["color", "mono"] as const) {
|
||||
assert.doesNotThrow(() => getLobeProviderIcon(id, type), `${id} (${type}) must not throw`);
|
||||
assert.equal(getLobeProviderIcon(id, type), null, `${id} (${type}) must resolve to null`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("#11853 — camelCase prototype members were already safe and stay safe", () => {
|
||||
for (const id of ["valueOf", "toString", "hasOwnProperty", "isPrototypeOf"]) {
|
||||
assert.equal(getLobeProviderIcon(id), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("#11853 — no regression: known providers still resolve, unknown ones still null", () => {
|
||||
assert.notEqual(getLobeProviderIcon("openai"), null);
|
||||
assert.notEqual(getLobeProviderIcon("anthropic"), null);
|
||||
assert.equal(getLobeProviderIcon("definitely-not-a-provider"), null);
|
||||
});
|
||||
|
||||
test("#11853 — a non-string provider id does not throw", () => {
|
||||
assert.doesNotThrow(() => getLobeProviderIcon(undefined as unknown as string));
|
||||
assert.equal(getLobeProviderIcon(undefined as unknown as string), null);
|
||||
});
|
||||
@@ -115,7 +115,12 @@ test("unified catalog suppresses stale OpenAI chat rows but retains typed media"
|
||||
assert.equal(ids.has("openai/gpt-5.2-codex"), false);
|
||||
assert.equal(ids.has("openai/sora-2"), false);
|
||||
assert.equal(ids.has("openai/sora-2-pro"), false);
|
||||
assert.equal(ids.has("openai/gpt-5.6-sol"), true);
|
||||
// Since #11919 (fixes #11829) an authoritative live catalog REPLACES the static
|
||||
// registry: a static-only row like gpt-5.6-sol that the synced catalog does not
|
||||
// list is suppressed instead of leaking into /v1/models. The lifecycle contract
|
||||
// this file guards (#8627: stale chat rows suppressed, typed media retained)
|
||||
// is unchanged — only the "static rows survive a sync" expectation moved.
|
||||
assert.equal(ids.has("openai/gpt-5.6-sol"), false);
|
||||
assert.equal(body.data.find((item) => item.id === "openai/gpt-image-2")?.type, "image");
|
||||
});
|
||||
|
||||
|
||||
109
tests/unit/models-catalog-custom-node-prefix.test.ts
Normal file
109
tests/unit/models-catalog-custom-node-prefix.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-custom-node-prefix-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const aliasesDb = await import("../../src/lib/db/models/aliases.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const modelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
const modelsRoute = await import("../../src/app/api/v1/models/route.ts");
|
||||
|
||||
const NODE_ID = "openai-compatible-chat-550e8400-e29b-41d4-a716-446655440000";
|
||||
const PREFIX = "infrex";
|
||||
const EXPECTED_IDS = [
|
||||
`${PREFIX}/synced-model`,
|
||||
`${PREFIX}/custom-model`,
|
||||
`${PREFIX}/alias-backed-model`,
|
||||
];
|
||||
|
||||
async function resetStorage(): Promise<void> {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
modelsCatalog.__resetCatalogBuilderRunsForTest();
|
||||
}
|
||||
|
||||
async function seedCustomNode(): Promise<void> {
|
||||
await providersDb.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: "openai-compatible",
|
||||
name: "Infrex",
|
||||
prefix: PREFIX,
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
});
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: NODE_ID,
|
||||
authType: "apikey",
|
||||
name: "infrex-primary",
|
||||
apiKey: "test-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
},
|
||||
});
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(
|
||||
NODE_ID,
|
||||
(connection as { id: string }).id,
|
||||
[{ id: "synced-model", source: "imported", supportedEndpoints: ["chat"] }]
|
||||
);
|
||||
await modelsDb.addCustomModel(NODE_ID, "custom-model", "Custom Model");
|
||||
await aliasesDb.setModelAlias("alias-backed-model", `${NODE_ID}/alias-backed-model`);
|
||||
}
|
||||
|
||||
async function getCatalogIds(mode: "alias" | "canonical" | "dual"): Promise<string[]> {
|
||||
modelsCatalog.__resetCatalogBuilderRunsForTest();
|
||||
const response = await modelsRoute.GET(
|
||||
new Request(`http://localhost/api/v1/models?prefix=${mode}`)
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { data: Array<{ id: string }> };
|
||||
return body.data.map((model) => model.id);
|
||||
}
|
||||
|
||||
function assertCustomNodeModels(ids: string[]): void {
|
||||
const actualCustomNodeIds = ids.filter((id) => EXPECTED_IDS.includes(id)).sort();
|
||||
assert.deepEqual(
|
||||
actualCustomNodeIds,
|
||||
[...EXPECTED_IDS].sort(),
|
||||
`expected each custom node source exactly once in ${JSON.stringify(ids)}`
|
||||
);
|
||||
assert.equal(
|
||||
ids.some((id) => id.startsWith(`${NODE_ID}/`)),
|
||||
false,
|
||||
"catalog must not expose the internal provider node id"
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
await seedCustomNode();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("alias mode exposes every custom node model under its configured prefix", async () => {
|
||||
assertCustomNodeModels(await getCatalogIds("alias"));
|
||||
});
|
||||
|
||||
test("canonical mode keeps custom node models under their configured prefix", async () => {
|
||||
assertCustomNodeModels(await getCatalogIds("canonical"));
|
||||
});
|
||||
|
||||
test("dual mode exposes each custom node model once under its configured prefix", async () => {
|
||||
assertCustomNodeModels(await getCatalogIds("dual"));
|
||||
});
|
||||
120
tests/unit/models-catalog-static-synced-suppression.test.ts
Normal file
120
tests/unit/models-catalog-static-synced-suppression.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
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-model-catalog-static-synced-suppression-")
|
||||
);
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET =
|
||||
process.env.API_KEY_SECRET || "model-catalog-static-synced-suppression-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const catalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
const { REGISTRY } = await import("@omniroute/open-sse/config/providerRegistry");
|
||||
|
||||
const LIVE_MODEL = "google/gemma-4-31b-it";
|
||||
|
||||
function getStaticModel(provider: string) {
|
||||
const model = REGISTRY[provider]?.models?.[0];
|
||||
assert.ok(model, `${provider} must define a static registry model for this regression test`);
|
||||
return model;
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(TEST_DATA_DIR, "logs/application"), { recursive: true });
|
||||
catalog.__resetCatalogBuilderRunsForTest();
|
||||
}
|
||||
|
||||
async function seedConnection(provider: string, name: string) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name,
|
||||
apiKey: "test-api-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
}
|
||||
|
||||
async function getCatalogIds(): Promise<Set<string>> {
|
||||
const response = await catalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<{ id: string }> };
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
return new Set(body.data.map((model) => model.id));
|
||||
}
|
||||
|
||||
test.beforeEach(resetStorage);
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("active authoritative live catalog suppresses stale static registry models", async () => {
|
||||
const staticNvidiaModel = getStaticModel("nvidia").id;
|
||||
const connection = await seedConnection("nvidia", "nvidia-static-synced-suppression");
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("nvidia", connection.id as string, [
|
||||
{ id: LIVE_MODEL, name: "Gemma 4 31B", source: "imported" },
|
||||
]);
|
||||
|
||||
const ids = await getCatalogIds();
|
||||
|
||||
assert.equal(ids.has(`nvidia/${LIVE_MODEL}`), true);
|
||||
assert.equal(ids.has(`nvidia/${staticNvidiaModel}`), false);
|
||||
});
|
||||
|
||||
test("static registry remains fallback when active connection has no live catalog", async () => {
|
||||
const staticNvidiaModel = getStaticModel("nvidia").id;
|
||||
await seedConnection("nvidia", "nvidia-fallback-static");
|
||||
|
||||
const ids = await getCatalogIds();
|
||||
|
||||
assert.equal(ids.has(`nvidia/${staticNvidiaModel}`), true);
|
||||
});
|
||||
|
||||
test("authoritative live catalog suppresses static effort-tier variants on sync", async () => {
|
||||
const connection = await seedConnection("glm", "glm-authoritative-effort-suppression");
|
||||
const glmStaticModel = REGISTRY.glm?.models?.find(
|
||||
(model) =>
|
||||
Array.isArray(model.supportedThinkingEfforts) && model.supportedThinkingEfforts.length > 0
|
||||
);
|
||||
assert.ok(glmStaticModel, "glm must define an effort-tier static model for this regression test");
|
||||
const effort = glmStaticModel.supportedThinkingEfforts?.[0];
|
||||
assert.ok(effort, "glm static model must declare at least one effort tier");
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("glm", connection.id as string, [
|
||||
{ id: "glm-5-synced", name: "GLM 5 Synced", source: "imported" },
|
||||
]);
|
||||
|
||||
const ids = await getCatalogIds();
|
||||
|
||||
assert.equal(ids.has("glm/glm-5-synced"), true);
|
||||
assert.equal(ids.has(`glm/${glmStaticModel.id}`), false);
|
||||
assert.equal(ids.has(`glm/${glmStaticModel.id}-${effort}`), false);
|
||||
});
|
||||
|
||||
test("partial discovery provider preserves uncovered static models when synced", async () => {
|
||||
const connection = await seedConnection("command-code", "command-code-partial-discovery");
|
||||
const uncoveredStaticModel = "deepseek/deepseek-v4-flash";
|
||||
const coveredSyncedModel = "claude-opus-4-7";
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("command-code", connection.id as string, [
|
||||
{ id: coveredSyncedModel, name: "Claude Opus 4.7", source: "imported" },
|
||||
]);
|
||||
|
||||
const ids = await getCatalogIds();
|
||||
|
||||
assert.equal(ids.has(`cmd/${coveredSyncedModel}`), true);
|
||||
assert.equal(ids.has(`cmd/${uncoveredStaticModel}`), true);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -30,7 +31,19 @@ function relativeJsImports(): string[] {
|
||||
if ((err as { status?: number }).status === 1) return [];
|
||||
throw err;
|
||||
}
|
||||
return out.split("\n").filter((line) => line.trim().length > 0);
|
||||
return (
|
||||
out
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
// A `.js` specifier whose target really is a JavaScript file (e.g.
|
||||
// open-sse/lib/deepseek-pow-hash.js, shared with a worker) is correct —
|
||||
// the defect #10674 guards against is a `.js` suffix on a `.ts` source.
|
||||
.filter((line) => {
|
||||
const m = /^([^:]+):\d+:.*from "(\.{1,2}\/[^"]*\.js)"/.exec(line);
|
||||
if (!m) return true;
|
||||
return !fs.existsSync(path.resolve(REPO_ROOT, path.dirname(m[1]), m[2]));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test("no first-party TypeScript module is imported through a .js specifier", () => {
|
||||
|
||||
104
tests/unit/openai-to-claude-trailing-usage-11817.test.ts
Normal file
104
tests/unit/openai-to-claude-trailing-usage-11817.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Regression for #11817 — the streaming OpenAI→Claude translator dropped the
|
||||
* upstream usage block (including prompt-cache accounting) whenever it arrived
|
||||
* on a trailing usage-only chunk shaped `{"choices":[],"usage":{...}}`.
|
||||
*
|
||||
* Many OpenAI-compatible upstreams (confirmed: Fireworks / kimi-k3, also vLLM
|
||||
* and Together with `stream_options.include_usage`) emit usage exactly that
|
||||
* way. `openaiToClaudeResponse()` returned early on `!chunk.choices?.[0]`
|
||||
* BEFORE reading `chunk.usage`, so `state.usage` stayed undefined and every
|
||||
* downstream consumer fell back to OmniRoute's own tokenizer estimate — no
|
||||
* cache_read_input_tokens, no cache_creation_input_tokens, and an input_tokens
|
||||
* figure that disagreed with the provider's own count.
|
||||
*
|
||||
* Impact was silent over-billing: a session served ~75% from prompt cache was
|
||||
* metered at the full uncached rate.
|
||||
*
|
||||
* Runner: node --import tsx/esm --test tests/unit/openai-to-claude-trailing-usage-11817.test.ts
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiToClaudeResponse } =
|
||||
await import("../../open-sse/translator/response/openai-to-claude.ts");
|
||||
|
||||
function newState() {
|
||||
return { toolCalls: new Map(), messageId: "msg_11817", model: "kimi-k3" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
test("#11817 — usage on a trailing choices:[] chunk is harvested, with cache split", () => {
|
||||
const state = newState();
|
||||
|
||||
openaiToClaudeResponse({ choices: [{ index: 0, delta: { content: "ok" } }] }, state);
|
||||
openaiToClaudeResponse({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, state);
|
||||
openaiToClaudeResponse(
|
||||
{
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 6103,
|
||||
completion_tokens: 24,
|
||||
prompt_tokens_details: { cached_tokens: 6102 },
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.deepEqual(state.usage, {
|
||||
input_tokens: 1, // 6103 - 6102 cached
|
||||
output_tokens: 24,
|
||||
cache_read_input_tokens: 6102,
|
||||
});
|
||||
});
|
||||
|
||||
test("#11817 — cache_creation_tokens on a trailing chunk is mapped too", () => {
|
||||
const state = newState();
|
||||
openaiToClaudeResponse(
|
||||
{
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 5,
|
||||
prompt_tokens_details: { cached_tokens: 400, cache_creation_tokens: 100 },
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.deepEqual(state.usage, {
|
||||
input_tokens: 500,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 400,
|
||||
cache_creation_input_tokens: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test("#11817 — a usage-only chunk still emits no Claude events", () => {
|
||||
const state = newState();
|
||||
const out = openaiToClaudeResponse(
|
||||
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1 } },
|
||||
state
|
||||
);
|
||||
assert.equal(out, null);
|
||||
});
|
||||
|
||||
test("#11817 — no regression: usage carried inline on the finish chunk", () => {
|
||||
const state = newState();
|
||||
openaiToClaudeResponse({ choices: [{ index: 0, delta: { content: "hi" } }] }, state);
|
||||
openaiToClaudeResponse(
|
||||
{
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 10 },
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.deepEqual(state.usage, { input_tokens: 100, output_tokens: 10 });
|
||||
});
|
||||
|
||||
test("#11817 — no regression: empty and nullish chunks are still ignored", () => {
|
||||
assert.equal(openaiToClaudeResponse(null, newState()), null);
|
||||
assert.equal(openaiToClaudeResponse({ choices: [] }, newState()), null);
|
||||
assert.equal(openaiToClaudeResponse({}, newState()), null);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts";
|
||||
|
||||
type ClaudeUsage = {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
|
||||
type TranslatorState = Record<string, unknown> & {
|
||||
toolCalls: Map<number, unknown>;
|
||||
usage?: ClaudeUsage;
|
||||
};
|
||||
|
||||
const TRAILING_USAGE = {
|
||||
prompt_tokens: 6103,
|
||||
completion_tokens: 16,
|
||||
total_tokens: 6119,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 6000,
|
||||
cache_creation_tokens: 100,
|
||||
},
|
||||
};
|
||||
|
||||
function createState(): TranslatorState {
|
||||
return { toolCalls: new Map() };
|
||||
}
|
||||
|
||||
function collectEvents(
|
||||
chunks: Array<Record<string, unknown> | null>,
|
||||
state: TranslatorState
|
||||
): Array<Record<string, unknown>> {
|
||||
return chunks.flatMap((chunk) => openaiToClaudeResponse(chunk, state) ?? []);
|
||||
}
|
||||
|
||||
test("usage-only choices-empty chunk updates Claude usage without emitting a content delta", () => {
|
||||
const state = createState();
|
||||
|
||||
const events = openaiToClaudeResponse(
|
||||
{
|
||||
id: "chatcmpl-11817",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [],
|
||||
usage: TRAILING_USAGE,
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(events, null);
|
||||
assert.deepEqual(state.usage, {
|
||||
input_tokens: 3,
|
||||
output_tokens: 16,
|
||||
cache_read_input_tokens: 6000,
|
||||
cache_creation_input_tokens: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test("trailing choices-empty usage completes the stream with real cache accounting", () => {
|
||||
const state = createState();
|
||||
const events = collectEvents(
|
||||
[
|
||||
{
|
||||
id: "chatcmpl-11817",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: null }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-11817",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-11817",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [],
|
||||
usage: TRAILING_USAGE,
|
||||
},
|
||||
],
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(events[0].type, "message_start");
|
||||
assert.equal(events[1].type, "content_block_start");
|
||||
assert.equal(events[2].type, "content_block_delta");
|
||||
assert.equal(events[2].delta?.text, "OK");
|
||||
assert.equal(events[3].type, "content_block_stop");
|
||||
assert.equal(events[4].type, "message_delta");
|
||||
assert.equal(events[4].delta?.stop_reason, "end_turn");
|
||||
assert.deepEqual(events[4].usage, {
|
||||
input_tokens: 3,
|
||||
output_tokens: 16,
|
||||
cache_read_input_tokens: 6000,
|
||||
cache_creation_input_tokens: 100,
|
||||
});
|
||||
assert.equal(events[5].type, "message_stop");
|
||||
assert.equal(events.length, 6);
|
||||
});
|
||||
|
||||
test("stream-end flush still emits terminal events when upstream omits usage", () => {
|
||||
const state = createState();
|
||||
const events = collectEvents(
|
||||
[
|
||||
{
|
||||
id: "chatcmpl-11817-no-usage",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: null }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-11817-no-usage",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
},
|
||||
null,
|
||||
],
|
||||
state
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.type === "message_delta" || event.type === "message_stop"),
|
||||
[
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("trailing chunk without choices property updates usage and flushes finish", () => {
|
||||
const state = createState();
|
||||
const events = collectEvents(
|
||||
[
|
||||
{
|
||||
id: "chatcmpl-11817-no-choices-key",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [{ index: 0, delta: { content: "Done" }, finish_reason: null }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-11817-no-choices-key",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-11817-no-choices-key",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
usage: TRAILING_USAGE,
|
||||
},
|
||||
],
|
||||
state
|
||||
);
|
||||
|
||||
const terminalEvents = events.filter(
|
||||
(event) => event.type === "message_delta" || event.type === "message_stop"
|
||||
);
|
||||
assert.deepEqual(terminalEvents, [
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 16,
|
||||
cache_read_input_tokens: 6000,
|
||||
cache_creation_input_tokens: 100,
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("trailing choices-empty chunk with tool_calls finish_reason preserves tool_use stop_reason and usage", () => {
|
||||
const state = createState();
|
||||
const events = collectEvents(
|
||||
[
|
||||
{
|
||||
id: "chatcmpl-11817-tool",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: { name: "get_weather", arguments: "{\"city\":\"Beijing\"}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-11817-tool",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-11817-tool",
|
||||
model: "accounts/fireworks/models/kimi-k3",
|
||||
choices: [],
|
||||
usage: TRAILING_USAGE,
|
||||
},
|
||||
],
|
||||
state
|
||||
);
|
||||
|
||||
const terminalEvents = events.filter(
|
||||
(event) => event.type === "message_delta" || event.type === "message_stop"
|
||||
);
|
||||
assert.deepEqual(terminalEvents, [
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "tool_use" },
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 16,
|
||||
cache_read_input_tokens: 6000,
|
||||
cache_creation_input_tokens: 100,
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
});
|
||||
@@ -73,10 +73,41 @@ test("simulates persisted combo model steps in order", async () => {
|
||||
body.targets.map(({ status }: Record<string, unknown>) => status),
|
||||
["available", "available"]
|
||||
);
|
||||
assert.ok(body.warnings.includes("Skipped 1 unsupported persisted combo step."));
|
||||
// #11822 follow-up: combo-ref steps now get a specific warning naming the
|
||||
// referenced combo instead of folding into the generic "unsupported step"
|
||||
// count (that count is reserved for genuinely unrecognized step shapes).
|
||||
assert.ok(
|
||||
body.warnings.some((warning: string) => warning.includes('combo "nested combo"'))
|
||||
);
|
||||
assert.ok(body.warnings.every((warning: string) => !warning.includes("not configured")));
|
||||
});
|
||||
|
||||
test("surfaces a provider-wildcard step as an unresolved target with a specific warning", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "combo with wildcard",
|
||||
strategy: "priority",
|
||||
models: [
|
||||
{ kind: "model", model: "cc/claude-opus-5" },
|
||||
{ kind: "provider-wildcard", providerId: "groq", modelPattern: "llama-*" },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await POST(request({ comboId: combo.id, promptTokens: 500 }));
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(
|
||||
body.targets.map(({ provider, model }: Record<string, unknown>) => ({ provider, model })),
|
||||
[
|
||||
{ provider: "cc", model: "claude-opus-5" },
|
||||
{ provider: "groq", model: "llama-*" },
|
||||
]
|
||||
);
|
||||
assert.ok(
|
||||
body.warnings.some((warning: string) => warning.includes("groq/llama-*") && warning.includes("wildcard"))
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 404 for a missing persisted combo", async () => {
|
||||
const response = await POST(request({ comboId: "missing" }));
|
||||
|
||||
|
||||
196
tests/unit/runner-janitor.test.ts
Normal file
196
tests/unit/runner-janitor.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* scripts/ops/runner-janitor.sh runs from cron on the .113 runner box. This
|
||||
* suite pins its safety contract against a fixture tree — never the real /tmp:
|
||||
* every base, the runner dirs, the PSI file and the df path are redirected, the
|
||||
* zombie pattern is set to a name no process has, and the ceilings are lifted
|
||||
* so the outcome does not depend on the box the test happens to run on.
|
||||
*/
|
||||
const ROOT = path.resolve(import.meta.dirname, "..", "..");
|
||||
const SCRIPT = path.join(ROOT, "scripts", "ops", "runner-janitor.sh");
|
||||
const HOUR = 3_600_000;
|
||||
// The sweep needs lsof to PROVE a path is idle (one snapshot of open paths). Hosted CI
|
||||
// images ship both; a bare devbox may not. Each branch below asserts what must
|
||||
// hold in that environment — without the tools the contract is "delete nothing,
|
||||
// say why", which is exactly the behaviour worth pinning.
|
||||
const HAVE_BUSY_TOOLS =
|
||||
spawnSync("bash", ["-c", "command -v lsof"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
function fixture() {
|
||||
const base = mkdtempSync(path.join(os.tmpdir(), "janitor-fixture-"));
|
||||
const old = new Date(Date.now() - 5 * HOUR);
|
||||
const mk = (name: string, dir: boolean, when: Date | null) => {
|
||||
const p = path.join(base, name);
|
||||
if (dir) {
|
||||
mkdirSync(p);
|
||||
writeFileSync(path.join(p, "x"), "x");
|
||||
} else writeFileSync(p, "x");
|
||||
if (when) utimesSync(p, when, when);
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
base,
|
||||
staleTar: mk("e2e-build.tar.gz", false, old), // fixed-name artefact ci.yml/npm-publish leave behind
|
||||
staleBuild: mk("next-build-abc", true, old),
|
||||
staleUpgrade: mk("omniroute-install-upgrade-xyz", true, old),
|
||||
fresh: mk("omniroute-batch-api-fresh", true, null), // in use right now
|
||||
unrelated: mk("somebody-elses.log", false, old), // not ours — never touched
|
||||
};
|
||||
}
|
||||
|
||||
function run(args: string[], base: string, extraEnv: Record<string, string> = {}) {
|
||||
return spawnSync("bash", [SCRIPT, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
JANITOR_TMP_BASES: base,
|
||||
JANITOR_WORK_TEMP_BASES: "",
|
||||
JANITOR_RUNNER_DIRS: path.join(base, "no-runners-here-*"),
|
||||
JANITOR_PSI_FILE: path.join(base, "no-psi"),
|
||||
JANITOR_DF_PATH: base,
|
||||
ZOMBIE_BUILD_COMM: "janitor-test-no-such-process",
|
||||
MAX_ACTIVE_RUNNERS: "9999",
|
||||
DISK_ALERT_PCT: "101",
|
||||
...extraEnv,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("runner-janitor.sh", () => {
|
||||
it("is executable bash with strict mode and prints usage on --help", () => {
|
||||
assert.ok(existsSync(SCRIPT));
|
||||
assert.ok(statSync(SCRIPT).mode & 0o111, "must be chmod +x (cron runs it directly)");
|
||||
const body = readFileSync(SCRIPT, "utf8");
|
||||
assert.ok(body.startsWith("#!/usr/bin/env bash"));
|
||||
assert.ok(body.includes("set -euo pipefail"));
|
||||
const help = run(["--help"], os.tmpdir());
|
||||
assert.equal(help.status, 0, help.stderr);
|
||||
assert.match(help.stdout, /--dry-run/);
|
||||
});
|
||||
|
||||
it("without lsof it cannot prove idle, so it deletes nothing and says why (exit 1)", () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const r = run([], f.base, { JANITOR_LSOF: "/nonexistent/lsof" });
|
||||
assert.equal(r.status, 1, "a janitor that cannot do its job must show up in the cron log");
|
||||
assert.match(r.stdout, /busy-tools=MISSING/);
|
||||
assert.match(
|
||||
r.stdout,
|
||||
/cannot prove idle \(lsof missing — apt install lsof\), kept: .*e2e-build\.tar\.gz/
|
||||
);
|
||||
for (const p of [f.staleTar, f.staleBuild, f.staleUpgrade, f.fresh, f.unrelated]) {
|
||||
assert.ok(existsSync(p), `must not delete ${p} when idleness cannot be proven`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(f.base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("--dry-run names what it WOULD remove and removes nothing", (t) => {
|
||||
if (!HAVE_BUSY_TOOLS) return t.skip("lsof absent on this box — sweep branch covered in CI");
|
||||
const f = fixture();
|
||||
try {
|
||||
const r = run(["--dry-run"], f.base);
|
||||
assert.equal(r.status, 0, r.stderr + r.stdout);
|
||||
assert.match(r.stdout, /busy-tools=ok/);
|
||||
for (const p of [f.staleTar, f.staleBuild, f.staleUpgrade]) {
|
||||
assert.match(
|
||||
r.stdout,
|
||||
new RegExp(`would remove \\(3h\\+\\): ${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
r.stdout,
|
||||
new RegExp(`removed \\(3h\\+\\): ${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
|
||||
"dry-run must never claim it removed something"
|
||||
);
|
||||
assert.ok(existsSync(p), `dry-run must not delete ${p}`);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
r.stdout,
|
||||
/omniroute-batch-api-fresh/,
|
||||
"a fresh dir is never a candidate"
|
||||
);
|
||||
assert.doesNotMatch(r.stdout, /somebody-elses\.log/, "only names our tooling creates");
|
||||
assert.match(r.stdout, /zombie builds: 0/);
|
||||
assert.match(r.stdout, /done status=0/);
|
||||
} finally {
|
||||
rmSync(f.base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("for real: sweeps the three stale artefacts, keeps the fresh one and the stranger", (t) => {
|
||||
if (!HAVE_BUSY_TOOLS) return t.skip("lsof absent on this box — sweep branch covered in CI");
|
||||
const f = fixture();
|
||||
try {
|
||||
const r = run([], f.base);
|
||||
assert.equal(r.status, 0, r.stderr + r.stdout);
|
||||
assert.ok(!existsSync(f.staleTar), "stale e2e-build.tar.gz must go (it is RAM on tmpfs)");
|
||||
assert.ok(!existsSync(f.staleBuild), "stale next-build dir must go");
|
||||
assert.ok(!existsSync(f.staleUpgrade), "stale install-upgrade dir must go");
|
||||
assert.ok(existsSync(f.fresh), "a fresh dir must survive");
|
||||
assert.ok(existsSync(f.unrelated), "files we did not create must survive even when old");
|
||||
} finally {
|
||||
rmSync(f.base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("tmpfs fuse is shorter than the disk fuse (RAM vs disk), both overridable", () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
// With a 6h tmpfs fuse the 5h-old artefacts are NOT stale yet.
|
||||
const r = run(["--dry-run"], f.base, { TMPFS_MAX_AGE_HOURS: "6" });
|
||||
assert.doesNotMatch(
|
||||
r.stdout,
|
||||
/would remove|removed \(|cannot prove idle/,
|
||||
"nothing is stale under a 6h fuse, so no candidate is even examined"
|
||||
);
|
||||
const body = readFileSync(SCRIPT, "utf8");
|
||||
assert.match(body, /TMPFS_MAX_AGE_HOURS:-3\}/, "tmpfs default must stay short — it is RAM");
|
||||
assert.match(body, /WORK_TEMP_MAX_AGE_HOURS:-24\}/);
|
||||
} finally {
|
||||
rmSync(f.base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("alerts (exit 1) on disk and memory pressure thresholds without touching files", () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
writeFileSync(
|
||||
path.join(f.base, "psi"),
|
||||
"some avg10=0.00 avg60=0.00 avg300=0.00 total=1\nfull avg10=0.00 avg60=23.50 avg300=9.00 total=1\n"
|
||||
);
|
||||
const r = run(["--dry-run"], f.base, {
|
||||
JANITOR_PSI_FILE: path.join(f.base, "psi"),
|
||||
DISK_ALERT_PCT: "0",
|
||||
});
|
||||
assert.equal(r.status, 1, "attention needed must be exit 1 for the cron log");
|
||||
assert.match(r.stdout, /MEMORY PRESSURE psi full\/avg60=23\.50%/);
|
||||
assert.ok(existsSync(f.fresh) && existsSync(f.unrelated));
|
||||
assert.match(r.stdout, /ROOT DISK \d+% >= 0%/);
|
||||
assert.ok(existsSync(f.staleTar), "alerting never deletes");
|
||||
} finally {
|
||||
rmSync(f.base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown arguments instead of silently running", () => {
|
||||
const r = run(["--yolo"], os.tmpdir());
|
||||
assert.equal(r.status, 2);
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,7 @@ test("injectSkills renders enabled tools in provider-specific shapes", async ()
|
||||
function: {
|
||||
name: "omr_skill_c2VhcmNoQDEuMC4w", // encodedName("search@1.0.0")
|
||||
description: "search the web",
|
||||
parameters: { type: "object", properties: { query: "string" } },
|
||||
parameters: { type: "object", properties: { query: { type: "string" } } },
|
||||
},
|
||||
});
|
||||
assert.equal(decodeSkillToolName("omr_skill_c2VhcmNoQDEuMC4w"), "search@1.0.0");
|
||||
@@ -90,14 +90,14 @@ test("injectSkills renders enabled tools in provider-specific shapes", async ()
|
||||
{
|
||||
name: "omr_skill_c2VhcmNoQDEuMC4w",
|
||||
description: "search the web",
|
||||
input_schema: { type: "object", properties: { query: "string" } },
|
||||
input_schema: { type: "object", properties: { query: { type: "string" } } },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(geminiTools, [
|
||||
{
|
||||
name: "omr_skill_c2VhcmNoQDEuMC4w",
|
||||
description: "search the web",
|
||||
parameters: { type: "object", properties: { query: "string" } },
|
||||
parameters: { type: "object", properties: { query: { type: "string" } } },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(fallbackTools, [openaiTools[0]]);
|
||||
@@ -219,7 +219,7 @@ test("injectSkills auto mode matches message/context semantics and applies score
|
||||
function: {
|
||||
name: encodedName("issueSearch@1.0.0"),
|
||||
description: "search github issues and pull requests",
|
||||
parameters: { type: "object", properties: { query: "string" } },
|
||||
parameters: { type: "object", properties: { query: { type: "string" } } },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -338,3 +338,60 @@ test("injectSkills auto mode limits selected auto skills and keeps on-mode skill
|
||||
assert.equal(names.includes("alwaysOnUtility@1.0.0"), true);
|
||||
assert.equal(names.filter((name) => name.startsWith("searchSkill")).length, 5);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression for #11856 — injected skill tools carried a malformed JSON Schema.
|
||||
*
|
||||
* Skills may declare their input in shorthand (`{ "content": "string" }`).
|
||||
* normalizeInputSchema() wrapped that bare property map as
|
||||
* `{ type: "object", properties: { content: "string" } }` without expanding the
|
||||
* shorthand values — and `"string"` is not a JSON Schema object. Zhipu GLM
|
||||
* behind the Console Go tier validates tool schemas strictly and rejected the
|
||||
* whole request with `[1210] Invalid API parameter`, giving a 100% failure rate
|
||||
* on that provider regardless of request content or credentials. Most other
|
||||
* providers tolerate the malformed schema, which is why it surfaced late.
|
||||
*
|
||||
* SkillSchema is `z.record(z.string(), z.unknown())`, so shorthand values pass
|
||||
* validation from every skill source — the skills API, the GitHub collector and
|
||||
* the skillssh marketplace alike.
|
||||
*/
|
||||
test("#11856 injectSkills expands shorthand property types into valid JSON Schema", async () => {
|
||||
await skillRegistry.register({
|
||||
name: "generation",
|
||||
version: "1.0.0",
|
||||
description: "generate content",
|
||||
schema: {
|
||||
input: {
|
||||
content: "string",
|
||||
count: "number",
|
||||
// already-expanded entries must survive untouched
|
||||
options: { type: "object", properties: { tone: { type: "string" } } },
|
||||
},
|
||||
output: { result: "string" },
|
||||
},
|
||||
handler: "generation-handler",
|
||||
enabled: true,
|
||||
apiKeyId: "key-11856",
|
||||
});
|
||||
|
||||
const expected = {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string" },
|
||||
count: { type: "number" },
|
||||
options: { type: "object", properties: { tone: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
const openaiTools = injectSkills({ provider: "openai", apiKeyId: "key-11856" });
|
||||
assert.deepEqual(
|
||||
(openaiTools[0] as { function: { parameters: unknown } }).function.parameters,
|
||||
expected
|
||||
);
|
||||
|
||||
const claudeTools = injectSkills({ provider: "anthropic", apiKeyId: "key-11856" });
|
||||
assert.deepEqual((claudeTools[0] as { input_schema: unknown }).input_schema, expected);
|
||||
|
||||
const geminiTools = injectSkills({ provider: "google", apiKeyId: "key-11856" });
|
||||
assert.deepEqual((geminiTools[0] as { parameters: unknown }).parameters, expected);
|
||||
});
|
||||
|
||||
@@ -203,7 +203,10 @@ test("OpenAI to Claude: finish flushes a fully-held boundary before message stop
|
||||
},
|
||||
state
|
||||
);
|
||||
const result = flatten([chunk1, chunk2]);
|
||||
// End-of-stream flush (see dd35750e5f): a finish chunk without usage is deferred
|
||||
// until production's null flush, so mirror it before asserting the terminal events.
|
||||
const chunk3 = openaiToClaudeResponse(null, state);
|
||||
const result = flatten([chunk1, chunk2, chunk3]);
|
||||
|
||||
assert.deepEqual(getTextDeltas(result), ["`"]);
|
||||
assert.equal(state._markdownBuffer, "");
|
||||
@@ -251,7 +254,10 @@ test("OpenAI to Claude: tool call flushes a fully-held boundary before tool use"
|
||||
},
|
||||
state
|
||||
);
|
||||
const result = flatten([chunk1, chunk2]);
|
||||
// End-of-stream flush (see dd35750e5f): a finish chunk without usage is deferred
|
||||
// until production's null flush, so mirror it before asserting the terminal events.
|
||||
const chunk3 = openaiToClaudeResponse(null, state);
|
||||
const result = flatten([chunk1, chunk2, chunk3]);
|
||||
const contentEvents = result.filter((event) =>
|
||||
String((event as Record<string, unknown>).type).startsWith("content_block_")
|
||||
);
|
||||
|
||||
120
tests/unit/strip-store-responses.test.ts
Normal file
120
tests/unit/strip-store-responses.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
import { stripStore } from "../../open-sse/handlers/chatCore/agentRouterProtocol.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
const COMPATIBLE_PROVIDER = "openai-compatible-responses-test";
|
||||
|
||||
test("stripStore forces store=false for stateless OpenAI-compatible Responses requests", () => {
|
||||
for (const initialStore of [undefined, false, true]) {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (initialStore !== undefined) body.store = initialStore;
|
||||
|
||||
stripStore(body, COMPATIBLE_PROVIDER, FORMATS.OPENAI_RESPONSES, {});
|
||||
|
||||
assert.equal(body.store, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("stripStore preserves client store values for opted-in OpenAI-compatible Responses requests", () => {
|
||||
for (const initialStore of [false, true]) {
|
||||
const body: Record<string, unknown> = { store: initialStore };
|
||||
|
||||
stripStore(body, COMPATIBLE_PROVIDER, FORMATS.OPENAI_RESPONSES, {
|
||||
openaiStoreEnabled: true,
|
||||
});
|
||||
|
||||
assert.equal(body.store, initialStore);
|
||||
}
|
||||
|
||||
const omitted: Record<string, unknown> = {};
|
||||
stripStore(omitted, COMPATIBLE_PROVIDER, FORMATS.OPENAI_RESPONSES, {
|
||||
openaiStoreEnabled: true,
|
||||
});
|
||||
assert.equal("store" in omitted, false);
|
||||
});
|
||||
|
||||
test("stripStore keeps existing OpenAI and AgentRouter behavior", () => {
|
||||
const cases = [
|
||||
{ provider: "openai", targetFormat: FORMATS.OPENAI, expected: true },
|
||||
{ provider: "openai", targetFormat: FORMATS.OPENAI_RESPONSES, expected: true },
|
||||
{ provider: "agentrouter", targetFormat: FORMATS.OPENAI_RESPONSES, expected: true },
|
||||
{ provider: "agentrouter", targetFormat: FORMATS.OPENAI, expected: false },
|
||||
];
|
||||
|
||||
for (const { provider, targetFormat, expected } of cases) {
|
||||
const body: Record<string, unknown> = { store: true };
|
||||
stripStore(body, provider, targetFormat, {});
|
||||
assert.equal("store" in body, expected, `${provider}/${targetFormat}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("stripStore removes store outside OpenAI-compatible Responses targets", () => {
|
||||
const cases = [
|
||||
{ provider: COMPATIBLE_PROVIDER, targetFormat: FORMATS.OPENAI },
|
||||
{ provider: "anthropic", targetFormat: FORMATS.CLAUDE },
|
||||
];
|
||||
|
||||
for (const { provider, targetFormat } of cases) {
|
||||
const body: Record<string, unknown> = { store: false };
|
||||
stripStore(body, provider, targetFormat, { openaiStoreEnabled: true });
|
||||
assert.equal("store" in body, false, `${provider}/${targetFormat}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("DefaultExecutor never serializes native passthrough markers upstream", async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
const server = createServer((request, response) => {
|
||||
let rawBody = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
capturedBody = JSON.parse(rawBody);
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ id: "resp_test", object: "response", output: [] }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
|
||||
try {
|
||||
const executor = new DefaultExecutor(COMPATIBLE_PROVIDER);
|
||||
await executor.execute({
|
||||
model: "gpt-5.6-test",
|
||||
body: {
|
||||
model: "gpt-5.6-test",
|
||||
input: "hi",
|
||||
store: false,
|
||||
_nativeOpenAICompatibleResponsesPassthrough: true,
|
||||
_nativeCodexPassthrough: true,
|
||||
_nativeXaiResponsesPassthrough: true,
|
||||
_omnirouteResponsesStore: false,
|
||||
},
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "test-key",
|
||||
providerSpecificData: {
|
||||
apiType: "responses",
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(capturedBody);
|
||||
assert.equal(capturedBody.store, false);
|
||||
assert.equal(capturedBody._nativeOpenAICompatibleResponsesPassthrough, undefined);
|
||||
assert.equal(capturedBody._nativeCodexPassthrough, undefined);
|
||||
assert.equal(capturedBody._nativeXaiResponsesPassthrough, undefined);
|
||||
assert.equal(capturedBody._omnirouteResponsesStore, undefined);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ function baseParams(over: Partial<ProbeParams> = {}): ProbeParams {
|
||||
describe("web-cookie health probe (#11488)", () => {
|
||||
it("candidate detection matches catalogued cookie providers only", () => {
|
||||
assert.equal(isWebCookieHealthProbeCandidate("claude-web"), true);
|
||||
assert.equal(isWebCookieHealthProbeCandidate("chatgpt-web"), true);
|
||||
assert.equal(isWebCookieHealthProbeCandidate("chatgpt-web-codex"), true);
|
||||
assert.equal(isWebCookieHealthProbeCandidate("openai"), false);
|
||||
assert.equal(isWebCookieHealthProbeCandidate(undefined), false);
|
||||
assert.equal(isWebCookieHealthProbeCandidate(""), false);
|
||||
@@ -210,7 +210,7 @@ describe("web-cookie health probe (#11488)", () => {
|
||||
baseParams({
|
||||
conn: {
|
||||
id: "c1",
|
||||
provider: "qwen-web",
|
||||
provider: "grok-web",
|
||||
apiKey: "",
|
||||
providerSpecificData: { cookie: "token=abc" },
|
||||
},
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { applyToolCallShimToBuffer, hasToolCallShim, __test } = await import(
|
||||
"../../open-sse/translator/helpers/toolCallShim.ts"
|
||||
);
|
||||
const { openaiToClaudeResponse } = await import(
|
||||
"../../open-sse/translator/response/openai-to-claude.ts"
|
||||
);
|
||||
const { applyToolCallShimToBuffer, hasToolCallShim, __test } =
|
||||
await import("../../open-sse/translator/helpers/toolCallShim.ts");
|
||||
const { openaiToClaudeResponse } =
|
||||
await import("../../open-sse/translator/response/openai-to-claude.ts");
|
||||
|
||||
const { coerceToArray } = __test as { coerceToArray: (v: unknown) => unknown[] };
|
||||
|
||||
@@ -120,30 +118,21 @@ test("applyToolCallShimToBuffer: Read coerces numeric-string limit/offset", () =
|
||||
|
||||
test("applyToolCallShimToBuffer: Read strips pages for non-PDF files", () => {
|
||||
const out = JSON.parse(
|
||||
applyToolCallShimToBuffer(
|
||||
"Read",
|
||||
JSON.stringify({ file_path: "/etc/hosts", pages: "1-3" })
|
||||
)
|
||||
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/etc/hosts", pages: "1-3" }))
|
||||
);
|
||||
assert.equal("pages" in out, false);
|
||||
});
|
||||
|
||||
test("applyToolCallShimToBuffer: Read strips malformed pages even on PDFs", () => {
|
||||
const out = JSON.parse(
|
||||
applyToolCallShimToBuffer(
|
||||
"Read",
|
||||
JSON.stringify({ file_path: "/tmp/doc.pdf", pages: "abc" })
|
||||
)
|
||||
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/tmp/doc.pdf", pages: "abc" }))
|
||||
);
|
||||
assert.equal("pages" in out, false);
|
||||
});
|
||||
|
||||
test("applyToolCallShimToBuffer: Read accepts a single page on PDFs", () => {
|
||||
const out = JSON.parse(
|
||||
applyToolCallShimToBuffer(
|
||||
"Read",
|
||||
JSON.stringify({ file_path: "/tmp/doc.PDF", pages: "7" })
|
||||
)
|
||||
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/tmp/doc.PDF", pages: "7" }))
|
||||
);
|
||||
assert.equal(out.pages, "7");
|
||||
});
|
||||
@@ -255,6 +244,12 @@ function streamChunks(chunks: any[], state: any): any[] {
|
||||
const out = openaiToClaudeResponse(c, state);
|
||||
if (out) all.push(...out);
|
||||
}
|
||||
// End-of-stream flush: production calls the translator once more with `null`
|
||||
// when the upstream stream closes (open-sse/utils/stream.ts flush →
|
||||
// translateResponse(..., null, state)). Since dd35750e5f a finish chunk that
|
||||
// carries no usage is deferred until that flush, so the driver must mirror it.
|
||||
const flushed = openaiToClaudeResponse(null, state);
|
||||
if (flushed) all.push(...flushed);
|
||||
return all;
|
||||
}
|
||||
|
||||
|
||||
@@ -243,3 +243,24 @@ describe("ProviderIcon — unresolved local asset provenance", () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// #11853 follow-up: getLobeProviderIcon() itself is already guarded by #11880's
|
||||
// Object.hasOwn() checks (see lobe-provider-icons-prototype-collision-11853.test.ts).
|
||||
// This covers the three *other* plain-object lookups ProviderIcon.tsx does on its own
|
||||
// (PROVIDER_ICON_ALIASES, LOCAL_SVG_ALIASES, THEMED_SVGS) — none of which #11880 touched —
|
||||
// which resolved the same inherited-property ids through the prototype chain before
|
||||
// falling through to the thesvg.org unknown-provider CDN path.
|
||||
describe("ProviderIcon — inherited object property ids", () => {
|
||||
it.each(["constructor", "valueOf", "hasOwnProperty", "__proto__"])(
|
||||
"renders provider id %s through the unknown-provider fallback",
|
||||
(providerId) => {
|
||||
const container = renderIcon({ providerId });
|
||||
const img = container.querySelector("img");
|
||||
|
||||
expect(img).not.toBeNull();
|
||||
expect(img?.getAttribute("src")).toBe(
|
||||
`https://thesvg.org/icons/${providerId.toLowerCase()}/default.svg`
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ test("web-session contract preserves representative token and cookie semantics",
|
||||
assert.equal(providers.get("deepseek-web")?.credential.kind, "token");
|
||||
assert.equal(providers.get("zai-web")?.credential.kind, "token");
|
||||
assert.equal(providers.get("gemini-web")?.credential.kind, "cookie");
|
||||
assert.equal(providers.get("qwen-web")?.credential.kind, "cookie");
|
||||
assert.equal(providers.get("perplexity-web")?.credential.kind, "cookie");
|
||||
|
||||
assert.ok(
|
||||
providers
|
||||
|
||||
@@ -60,19 +60,27 @@ test("applyThinking: honors xAI-native reasoning.effort verbatim", () => {
|
||||
assert.equal((out as Record<string, unknown>).foo, 1);
|
||||
});
|
||||
|
||||
test("normalizeXaiReasoningEffort: downgrades max/xhigh to xAI-supported high", () => {
|
||||
test("normalizeXaiReasoningEffort: downgrades max, passes xhigh through (#11816)", () => {
|
||||
// "max" is not an xAI tier -> still folded onto "high".
|
||||
assert.equal(normalizeXaiReasoningEffort("max"), "high");
|
||||
assert.equal(normalizeXaiReasoningEffort("xhigh"), "high");
|
||||
// "xhigh" is a real xAI tier on grok-4.6+; xAI itself degrades it to "high"
|
||||
// on older models, so forwarding it verbatim is always safe.
|
||||
assert.equal(normalizeXaiReasoningEffort("xhigh"), "xhigh");
|
||||
assert.equal(normalizeXaiReasoningEffort("XHIGH"), "xhigh");
|
||||
assert.equal(normalizeXaiReasoningEffort("HIGH"), "high");
|
||||
assert.equal(normalizeXaiReasoningEffort("ultra"), undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: normalizes xAI-native max/xhigh to high", () => {
|
||||
test("applyThinking: folds max to high, keeps xhigh intact (#11816)", () => {
|
||||
const maxOut = applyThinking({ reasoning: { effort: "max", summary: "auto" } });
|
||||
assert.deepStrictEqual(maxOut.reasoning, { effort: "high", summary: "auto" });
|
||||
|
||||
const xhighOut = applyThinking({ reasoning: { effort: "xhigh" } });
|
||||
assert.deepStrictEqual(xhighOut.reasoning, { effort: "high" });
|
||||
assert.deepStrictEqual(xhighOut.reasoning, { effort: "xhigh" });
|
||||
|
||||
const chatOut = applyThinking({ reasoning_effort: "xhigh" });
|
||||
assert.deepStrictEqual(chatOut.reasoning, { effort: "xhigh" });
|
||||
assert.equal(chatOut.reasoning_effort, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: rewrites OpenAI Chat reasoning_effort into reasoning.effort", () => {
|
||||
|
||||
Reference in New Issue
Block a user