Compare commits

...

28 Commits

Author SHA1 Message Date
diegosouzapw
777d9d1629 test(translator): fix the relative imports of the relocated deferred-finish test
#11940 moved tests/unit/translator/openai-to-claude-trailing-usage.test.ts one level
up so a collector would run it, but kept the ../../../ import path from the old
directory, so the file failed to load and painted Unit Tests fast-path (3/4) red on
every PR since a94fe23e89. The path now matches its new location (5/5 pass).
2026-08-28 19:23:37 -03:00
diegosouzapw
9661611e31 Merge remote-tracking branch 'origin/main' into chore/sync-main-into-3851-20260828c 2026-08-28 19:03:24 -03:00
Diego Rodrigues de Sa e Souza
24c0643a94 test(check): escape the runs-on fixture with JSON.stringify, not a quote-only replace (#11942)
CodeQL js/incomplete-sanitization (alert #888 on #11929): the hand-rolled replace
only escaped double quotes, so a backslash in the fixture would have produced a
malformed YAML scalar. JSON.stringify covers every escape the double-quoted YAML
scalar needs. Test-only change (7/7 pass).
2026-08-28 19:01:54 -03:00
Diego Rodrigues de Sa e Souza
a94fe23e89 fix(release): drain the twelve reds every PR against release/v3.8.51 was born with (#11940)
* fix(release): drain the twelve reds every PR against release/v3.8.51 was born with

Measured on the cycle tip: fifteen unit files were red on every PR. Two came
from the v3.8.50 sync-back (fixed in #11929); the other thirteen predate it and
are the branch's own drift. This sweep clears all of them but the ESLint debt
(#11924), each with the smallest change that keeps the guard honest:

- .env.example + ENVIRONMENT.md: NEXT_PUBLIC_SW_BUILD_ID / OMNIROUTE_SW_BUILD_ID /
  SOURCE_VERSION (#11779 service-worker cache busting) documented — the env/docs
  contract gate was failing on every PR.
- stryker.conf.json: the six tests the mutation gate found covering mutated modules
  (four retirement runtime-block suites, combo connection-aware expansion, tunnel
  error sanitization) registered in tap.testFiles.
- dependency-allowlist: eslint-plugin-react-hooks 7.0.1 approved; its findings are
  tracked in #11924.
- i18n: the six combo.sort.* strings (d5dfcfff58) translated for vi (strict parity)
  and pt-BR.
- docs/providers/CHATGPT_WEB.md: the retirement test is migration-168, not 163.
- g4f gateways: authHint now says member key, which the discontinued-providers
  guard asserts.
- tests realigned to the catalog the branch actually ships: qwen-web (#11713) and
  chatgpt-web (#11720) are retired, so web-session-contract and
  token-health-check-webcookie use perplexity-web, grok-web and chatgpt-web-codex.
- db-core-init: the two minimal legacy fixtures gained the columns migrations 164-168
  UPDATE (error_code, last_error*, test_status) — they exist on every real legacy DB
  (base CREATE TABLE); the fixtures simply never declared them.
- no-js-extension guard: a .js specifier whose target is a genuine JavaScript file
  (open-sse/lib/deepseek-pow-hash.js, shared with a worker) is not the #10674
  defect; the test now skips targets that exist as .js.

All twelve files pass locally; docs-sync, docs-counts, env-doc-sync, the tap
drift gate and the fabricated-docs gates are green on the tree.

* test(release): move the deferred-finish translator test into a collected path

tests/unit/translator/ is not one of the unit collectors (package.json test:unit,
merge-train.sh, build-test-impact-map, check-test-discovery), so the suite that
dd35750e5f added there never ran — check:test-discovery flagged it as a new orphan
on every PR. Relocated next to its sibling openai-to-claude-trailing-usage-11817
under tests/unit/, where the root glob collects it (5/5 pass).

* fix(dashboard): type the four sort-method sites #11812 left red on the dashboard typecheck ratchet

d5dfcfff58 added the combo model sort and raised combos/page.tsx from 23 to 27
scoped TypeScript errors (TS2339 +1, TS2345 +2, TS2322 +1), which fails
check:dashboard-typecheck on every PR against release/v3.8.51:

- initialSortMethod: sanitizeComboRuntimeConfig() is untyped, so config.modelSort is
  unknown; narrow it before reading .method (normalizeSortMethod takes unknown anyway).
- handleAddModels: the batch path passes ComboBuilderDraftModelStep[] to the ComboStep[]
  sort helpers without the cast handleSortChange already uses; mirror it.
- ComboSortSelect expects a translate-with-fallback (k, f) => string, but received
  next-intl's Translator whose second argument is a values object. Pass the page's
  getI18nOrFallback adapter instead of the raw translator — that is also what makes
  the `has()` check and the fallback text actually work at runtime.

Baseline untouched (no widening). Scoped tsc: 0 new/regressed errors.
2026-08-28 18:18:33 -03:00
Diego Rodrigues de Sa e Souza
8dfdd95187 test(release): align five suites with the contracts #11933, #11919 and #11876 shipped on release/v3.8.51 (#11944)
Eleven PRs landed on release/v3.8.51 while the branch carried fifteen base reds, and
nine more red tests hid among them. None is a defect in the shipped code; each test
still encoded the contract that the merged PR deliberately replaced:

- openai-to-claude finish deferral (dd35750e5f, #11933): a finish chunk that carries no
  usage is now held until the end-of-stream flush that production performs
  (open-sse/utils/stream.ts flush -> translateResponse(..., null, state)). The drivers in
  stream-markdown-token-boundary, translator-tool-call-shim and
  gemini-malformed-function-call-finish-reason-2462 fed the finish chunk and asserted
  the terminal events immediately; they now mirror the flush. Assertions unchanged.
- authoritative live catalog (3d2832b836, #11919 fixes #11829): a synced catalog replaces
  the static registry, so model-lifecycle-integration no longer expects the static-only
  gpt-5.6-sol row to survive a sync. The #8627 contract the file guards (stale chat rows
  suppressed, typed media retained) is untouched.
- provider asset provenance (#11876): the unit shards check out with depth 1. The fixture
  pinned a historical commit as auditedCommit (absent on a shallow clone), the
  "binds auditedCommit" case relied on the repository root commit (the grafted HEAD on
  a shallow clone, which matches the physical snapshot), and the real-manifest case
  needs the audited commit fetched. The fixture now audits HEAD, the mismatch case
  builds a dangling empty-tree commit (no ref written), and the real-manifest case
  skips only on a shallow checkout that lacks the commit - the gate itself keeps
  running on both fetch-depth-0 rails, which the next test asserts.

All five files pass locally (30, 11, 38, 3 and 18 tests); lint with the frozen
suppressions is clean.
2026-08-28 18:10:56 -03:00
Diego Rodrigues de Sa e Souza
226538fa27 feat(ci): publish to npm through Trusted Publishing (OIDC) by default (#11931)
* feat(ci): publish to npm through Trusted Publishing (OIDC) by default

npm rejects provenance from self-hosted runners and is retiring tokens that
bypass 2FA; v3.8.49 answered with staged publishing (WS1.3) so a leaked token
could never publish alone — at the price of a manual `npm stage approve` per
release. Trusted Publishing gives the same guarantee with no token at all: the
github-hosted stage-npm job exchanges GitHub's id-token for a credential scoped
to that run, provenance included, and the flow is automatic again as it was up
to v3.8.48.

publish_mode gains `auto` (the default, also the path for the release event);
`staged` now runs only when asked for; `direct` stays as the emergency token
fallback. Until the owner registers the Trusted Publisher on npmjs.com
(diegosouzapw/OmniRoute, workflow npm-publish.yml) the automatic step fails
with ENEEDAUTH and either other mode can be dispatched — documented in
docs/ops/RELEASE_CHECKLIST.md.

* docs(release): date the checklist for the Trusted Publishing change and drop the env-var claim

check-deprecated-versions flags a touched doc whose header still says
2026-06-28 / v3.8.40; the fabricated-docs gate read the backticked NPM_TOKEN as
an environment variable the code never reads (it is a repository secret).
2026-08-28 18:02:51 -03:00
diegosouzapw
fb7445eaa3 test(check): escape the runs-on fixture with JSON.stringify, not a quote-only replace
CodeQL js/incomplete-sanitization (#888): the hand-rolled replace only escaped
double quotes, so a backslash in the fixture would have produced a malformed YAML
scalar. JSON.stringify covers every escape the double-quoted YAML scalar needs.
2026-08-28 17:29:04 -03:00
diegosouzapw
9968e1ce6e Merge remote-tracking branch 'origin/release/v3.8.51' into chore/sync-main-into-3851-20260828b 2026-08-28 17:27:31 -03:00
Diego Rodrigues de Sa e Souza
f907b5ea8e fix(ci): cap heavy builds at two runners with the omni-build label (#11932)
The .113 box (31 GB) holds one next-build (14–16 GB RSS) comfortably and two
at the edge; on 2026-08-28 the kernel killed main's build twice while PR
builds ran beside it. Labels are the runner-side cap: only omniroute-113-5
and omniroute-113-6 carry omni-build (added through the runners API, no
re-registration), and every job that runs a next build — ci.yml build,
npm-publish.yml publish, both nightly-release-green validations — now asks
for that label. A third heavy job queues on GitHub instead of racing for
memory. The six other runners keep omni-release and no longer take builds.
Pairs with the heavy-build-* concurrency lanes (#11901); documented in
docs/ops/RUNNER_BOX.md.
2026-08-28 17:19:45 -03:00
Diego Rodrigues de Sa e Souza
5b38ec717d fix(ci): keep the next-build artefact on disk, not on the runner's tmpfs (#11896)
* fix(ci): keep the next-build artefact on disk, not on the runner's tmpfs

On the .113 pool /tmp is a 12 GB tmpfs — it is RAM. The 1.3 GB next-build
artefact was parked there four times over: the Build job tar'd it to
/tmp/e2e-build.tar.gz (6 min), three E2E jobs downloaded it to /tmp/ and
extracted from there, and npm-publish.yml pulled it with gh run download into
/tmp/next-build. Measured on the v3.8.50 publish runs: that download step took
27 min (9th attempt) and 32 min (10th) — 42% of a 76-minute job — while the
very same bytes upload from disk in 2 min and the box pulls from GitHub at
7.3 MB/s (1.3 GB ≈ 3 min). Network was never the bottleneck; a tmpfs at 75%
under memory pressure was.

Every site now uses $RUNNER_TEMP / ${{ runner.temp }}: per-runner, on disk
(_work/_temp under the runner dir on the pool, /home/runner/work/_temp on
hosted images), and cleaned by the runner between jobs.

It also removes a latent race: e2e-build.tar.gz is a FIXED name under a /tmp
shared by every runner on the box, so two E2E shards on different runners could
overwrite each other's download mid-extraction. RUNNER_TEMP is per runner.

The supply-chain guard in tests/unit/npm-publish-artifact-provenance.test.ts
pins the candidate-run selection and the --name, not the directory; it stays
green. check:workflows --ratchet: zizmor unchanged at the baseline.

* fix(ci): download the next-build artefact to a workspace-relative dir (pwsh has no $RUNNER_TEMP)

The Electron Package Smoke matrix runs on windows-latest, whose default shell
is pwsh: $RUNNER_TEMP is empty there (pwsh spells it $env:RUNNER_TEMP), so the
first cut's tar -xzf "$RUNNER_TEMP/e2e-build.tar.gz" tried to open
'/e2e-build.tar.gz' and failed. A path relative to the workspace works in bash
and pwsh alike, and hosted workspaces are ephemeral. The producer (Build, Linux,
bash) and npm-publish keep $RUNNER_TEMP.
2026-08-28 17:08:58 -03:00
diegosouzapw
5ade9e0851 fix(sync): repair the two regressions the v3.8.50 sync-back left on release/v3.8.51
Fifteen unit files were red on this branch's PRs; running them on the pre-sync
tip (d5dfcfff58) and on the synced one showed thirteen already failed before
the sync — the cycle's own drift — and exactly two regressed:

- open-sse/services/tokenExtractionConfig.ts: git kept BOTH sides' identical
  volcengine-console config (23 entries instead of 22). The duplicate is gone.
- src/lib/usage/providerLimits.ts: the sync took release/v3.8.50's cooldown
  release helper, which is looser than this branch's #11277 contract (it frees
  an extra_usage block when the policy is off and a window with no reset
  evidence). tests/unit/provider-limits-recovery.test.ts pins the contract;
  the pre-sync call site is restored and the unused helper and its imports
  dropped. 20/20 again, siblings unchanged.
2026-08-28 16:41:42 -03:00
Diego Rodrigues de Sa e Souza
33763f06cc chore(changelog): add missing fragments for #11919/#11918/#11916 (#11938)
Adds the 3 missing changelog fragments.
2026-08-28 16:26:32 -03:00
Bob.Hou
6b259812a7 fix(sse): preserve store parameter semantics for openai-compatible responses (#11826) (#11916)
stripStore() now forces store=false for stateless OpenAI-compatible Responses-API targets unless the connection explicitly opts 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. Closes #11826. Thanks!
2026-08-28 16:25:19 -03:00
Bob.Hou
dc75a02ca7 fix(models): expose custom node models in canonical prefix mode (#11832) (#11918)
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. Closes #11832. Thanks!
2026-08-28 16:25:05 -03:00
Bob.Hou
3d2832b836 fix(models): suppress static registry models when live catalog is synced (#11829) (#11919)
Suppresses stale static registry models (including effort-tier variants) for any provider whose active connection has an authoritative live synced catalog, not just providers using exclusive-synced-listing — closing a gap where a connection with providerUsesAuthoritativeLiveCatalog kept serving both the live-synced models and the stale static rows side by side. Closes #11829. 4/4 focused tests passing. Thanks!
2026-08-28 16:24:53 -03:00
Diego Rodrigues de Sa e Souza
cea1baa797 fix(ui): guard remaining ProviderIcon lookups against prototype collisions (#11920 port) (#11935)
Ports the 3 still-needed guards from #11920 that #11880 didn't cover. 90/90 + 4/4 focused tests passing.
2026-08-28 16:19:07 -03:00
Diego Rodrigues de Sa e Souza
dd35750e5f fix(sse): defer OpenAI-to-Claude finish emission until real usage arrives (#11915 follow-up on #11883) (#11933)
Merges #11883's already-merged usage-harvesting extraction with #11915's finish-deferral mechanism, verified to fix a real remaining bug: the client-visible message_delta carried stale/zero usage when finish_reason arrived before the trailing usage chunk. 86/86 tests passing across 16 translator regression files.
2026-08-28 16:11:38 -03:00
Diego Rodrigues de Sa e Souza
c661e1c811 port(playground): specific step warnings from #11882, keep #11862's string-step handling (#11930)
Ports the specific-warning improvement from #11882 (combo-ref/provider-wildcard steps get their own message instead of a generic count) onto #11862's already-merged crash fix. 4/4 focused tests passing.
2026-08-28 15:51:37 -03:00
diegosouzapw
529e4415c5 chore(release): sync main into release/v3.8.51 — the five post-release pipeline fixes
Brings e4683cd22d (#11867 Alibaba allowlist time bomb), 09de69edc7 (#11891
config expiry detector), e71be03398 (#11893 runner janitor), 9dc8eab70e
(#11895 provenance × self-hosted lint) and f564b64f7d (#11901 heavy-build
lanes). main is already an ancestor of this branch (v3.8.50 sync-back), so the
merge is exactly these five commits.

# Conflicts:
#	tests/unit/alibaba-free-tier-allowlist.test.ts
2026-08-28 15:49:53 -03:00
NoxzRCW
f08f35d6f0 fix(providers): pass xAI reasoning_effort xhigh through to grok-4.6+ (#11879)
normalizeXaiReasoningEffort() folded xhigh onto high before the request reached xAI, so anyone picking xhigh on grok-4.6 silently got high instead. xhigh is a real xAI tier (grok-4.6+); xAI already degrades it itself on unsupported models, so forwarding verbatim is safe everywhere. Closes #11816. Measured against live grok-4.6: reasoning_tokens 830 (high) vs 1052 (xhigh) — previously indistinguishable. Thanks!
2026-08-28 15:49:42 -03:00
NoxzRCW
d846692c30 fix(dashboard): guard provider icon lookups against prototype collisions (#11880)
getLobeProviderIcon() indexed two plain-object maps with no own-property check — a provider id that lowercases to an Object.prototype member (e.g. constructor) resolved through the prototype chain and threw on the follow-up .color/.mono lookup, surfacing as the misleading 'Failed to load providers, check your connection' error boundary card with a healthy server and clean logs. Thanks for the precise root-cause trace!
2026-08-28 15:49:32 -03:00
NoxzRCW
c5ebbb733c fix(skills): expand shorthand property types in injected tool schemas (#11881)
Every request through a strictly-validating provider (reproduced on opencode-go/glm-5.3-flash) failed with a 400: normalizeInputSchema() wrapped a skill's shorthand property map without expanding string values, so every injected omr_skill_* tool carried an invalid JSON Schema. Closes #11856. Thanks for the root-cause!
2026-08-28 15:49:22 -03:00
NoxzRCW
b8c7ee599d fix(translator): keep upstream usage from trailing empty-choices chunks (#11883)
openaiToClaudeResponse() returned early on !chunk.choices?.[0], dropping the trailing usage-only chunk many OpenAI-compatible upstreams send when stream_options.include_usage is set (confirmed on Fireworks kimi-k3) — state.usage stayed undefined and billing fell back to an uncached token estimate. 154/154 focused assertions across the fix + regression suite. Thanks for tracking down the billing impact!
2026-08-28 15:49:14 -03:00
Diego Rodrigues de Sa e Souza
f564b64f7d fix(ci): give main's build its own lane on the self-hosted pool (#11901)
The .113 box has 31 GB and a single next-build peaks at 14–16 GB RSS: one
build fits with room, two sit at the edge, three take the box down. On
2026-08-28 13:50Z the kernel OOM-killed main's next-build (15.7 GB) while a PR
build ran beside it — five Build jobs had been queued by a burst of PRs — and
the publish lost its artefact, which sends it into the 40-minute rebuild that
OOMs on its own (attempt 5 of this release).

Job-level concurrency on `build`, two lanes:

  heavy-build-main   pushes to main — never contended, never behind PR traffic
  heavy-build-pr     pull requests — serialize among themselves

cancel-in-progress stays false: a running build is never killed by a newer
one. GitHub's own rule for a group is one running + one pending, older pendings
cancelled — so under a burst the third PR build shows "cancelled" and needs a
re-run. That is the trade-off, stated: a cancelled PR check is re-runnable; a
dead main build costs a release.

The proper fix remains a label split (omni-build on two runners, omni-light on
the rest) so the queue lives on the runner side without cancellations — an
operator decision recorded in docs/ops/RUNNER_BOX.md.
2026-08-28 15:44:50 -03:00
Diego Rodrigues de Sa e Souza
9dc8eab70e feat(quality): fail check:workflows on --provenance from a self-hosted runner (#11895)
npm rejects provenance-signed uploads from self-hosted runners:

  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 learned that at minute 76 of its 10th publish attempt, after the tag,
the GitHub Release and the Docker images were already out. USE_VPS_RUNNER had
routed the job to the .113 pool on 2026-08-02; no release ran between 07-30 and
08-28, so the pairing sat latent for four weeks.

It is pure text — a job whose runs-on resolves to self-hosted and a step whose
run contains --provenance — so the workflow lint now checks it as a hard rule:
reported in plain mode, blocking under --strict and --ratchet (the CI mode),
emitted as provenanceRunnerFindings=<n> next to the other counters.

Against origin/main the rule finds the two real offenders (the staged upload
AND the DIRECT emergency fallback in npm-publish.yml); against the #11877 split
it finds none. --provenance-file is deliberately not matched (different flag,
pre-built bundle) and an opaque runs-on expression with no literal self-hosted
is classified unknown and skipped — the check never guesses.

The unit suite's last case walks the real .github/workflows and asserts zero
findings, so it is red on main until #11877 lands and green after; that is the
regression guard working, not a flake.
2026-08-28 15:44:40 -03:00
Diego Rodrigues de Sa e Souza
e71be03398 chore(ops): make the runner janitor act on what it can prove, not advise (#11893)
* chore(ops): make the runner janitor act on what it can prove, not advise

The .113 janitor already knew the rules and had been shouting them into a log
nobody reads: on 2026-08-28 12:00Z it reported "10 listeners > ceiling 8" and
"disk 85%" — for hours — while 6.7 GB of dead-run leftovers sat on the 12 GB
tmpfs (RAM) because its patterns matched neither e2e-build.tar.gz nor
next-build/, its 24 h fuse is a day too long for memory, and its _work/_temp
base (/home/*/actions-runner*) does not exist on this box (runners live under
/opt). Measured while draining the v3.8.50 npm publish (postmortem, Parte III).

What changes:

- idle is PROVEN before removal, with ONE lsof snapshot filtered to the swept
  bases (lsof +D per path walked whole trees and took minutes; 460 candidates
  grepping a re-printed 83k-line string was the other half). 20 s on the box.
  Without lsof the janitor removes nothing and says why (exit 1).
- tmpfs leftovers go after 3 h, disk _work/_temp after 24 h; both overridable.
  Patterns gain next-build* and e2e-build.tar.gz; /opt/actions-runner* is swept.
- zombie builds: a next-build older than 75 min has no job (a real Build step is
  ~26 min). On 2026-08-27 one ran 70 min after GitHub had declared its job lost,
  holding 3.6 GB. KillMode=mixed on the units covers systemctl stop/restart;
  this covers the lost-connection path.
- prunes 48 h-old checkouts under _work of runners whose unit is STOPPED — an
  active runner is never touched.
- alerts on memory PSI (full/avg60) and reports the listener ceiling with an
  omniroute/other breakdown (the box also hosts OmniHeuris and OmniMind).
  Enforcing the ceiling stays an operator decision (label split), not cron's.
- --dry-run prints exactly what it would do and touches nothing; unknown
  arguments are rejected.

Dry-run on the real box: 460 stale omniroute-* test fixtures (930 MB of RAM) it
would reclaim, 0 busy, 0 false "removed" lines, 20 s. The unit suite drives the
script against a fixture tree with every base redirected; the sweep branch runs
where lsof exists (hosted CI images) and the without-lsof contract everywhere.

docs/ops/RUNNER_BOX.md reconciled to the measured box: 31 GB (it said 16), ten
listeners, the 14 GB next-build ceiling, the KillMode drop-in, and the rule that
nothing is cleaned by hand while a runner is busy.

* docs(ops): restore the frontmatter fumadocs requires on RUNNER_BOX.md

Rewriting the page whole dropped its `title:` frontmatter, and docs/ is
compiled into the Next build by fumadocs-mdx — so Build, Fast Production Build
and dast-smoke all died with "[MDX] invalid frontmatter in
docs/ops/RUNNER_BOX.md". Same block as before, verbatim.
2026-08-28 15:44:29 -03:00
Diego Rodrigues de Sa e Souza
09de69edc7 test(config): fail seven days before a dated config pack lapses (#11891)
config/alibaba-free-tier-allowlist.json carried "validUntil": "2026-08-27".
On the 28th the loader started rejecting it — correctly, that is the design —
and a test that asserted "the shipped pack loads" turned every PR and main red
with no commit involved (#11866). A time bomb: the one class of defect a diff
review can never catch, because there is no diff.

scripts/check/lib/configExpiry.mjs walks config/**/*.json for validUntil /
validTo / expiresAt / expiry / expires (and snake_case forms), parses the dates,
and classifies each as expired / expiring (< 7 days) / ok / unparseable.

The repo-wide test fails on expired or expiring packs unless the file is in a
small allowlist keyed to the issue that owns the renewal — and fails the OTHER
way when an allowlisted pack is no longer expiring, so entries cannot go stale.
A positive anchor requires at least one dated pack to be found, so a renamed
key cannot silently turn the suite into a no-op.

The Alibaba pack is allowlisted against #11866: whether the curated free-tier
list still matches reality is an operator data decision, not a test fix.
Removing that entry makes the suite fail as intended (verified).
2026-08-28 15:44:18 -03:00
Diego Rodrigues de Sa e Souza
e4683cd22d fix(test): stop the Alibaba allowlist test from expiring with the catalog (#11867)
`Unit Tests (1/8)` went red on 2026-08-28 across every PR and on main, with
nothing changed — the clock had moved past the shipped catalog's expiry:

  config/alibaba-free-tier-allowlist.json → "validUntil": "2026-08-27"

isAlibabaFreeTierAllowlistPackValid() compares that against Date.now(), so from
28/08 loadAlibabaFreeTierAllowlistPack() returns null and the old
assert.ok(pack) could never pass again. Refreshing the date would only reschedule
the same break.

Production was never affected: resolveActiveAllowlistPack() falls back to the
embedded list when a pack expires, which is the intended design. The defect was
the test asserting the shipped catalog is currently fresh — a data property, not
a behavioral contract.

The test now writes its own packs to a temp dir with dates it controls, and
pins both halves of the contract:

  - inside the validity window, the pack REPLACES the embedded list (anchored on
    a model that exists nowhere else, so loading alone cannot satisfy it);
  - once expired, the pack is ignored and the embedded list serves.

That second path is what production has been running since 27/08 and had no
coverage at all, which is why the expiry surfaced as a red test rather than as
understood behavior. A third case pins the comparison against an injected
instant, including the no-expiry pack that never goes stale.

Whether the curated free-tier catalog still matches reality — and so deserves a
freshly dated pack — is a data question left to the operator in #11866.

Closes #11866
2026-08-28 15:43:53 -03:00
75 changed files with 2389 additions and 338 deletions

View File

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

View File

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

View File

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

View File

@@ -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 2732 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 }}

View 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`.

View File

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

View 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

View File

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

View 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

View 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).

View File

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

View File

@@ -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).

View File

@@ -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).

View 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).

View File

@@ -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).

View File

@@ -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).

View 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.

View File

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

View File

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

View File

@@ -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).

View File

@@ -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 2732 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.

View 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.

View 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.

View File

@@ -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",

View File

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

View File

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

View File

@@ -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 58 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 ≈ 1416 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 2732
minutes to land on the tmpfs and 2 minutes to upload from disk.
- The `.15` VPS is homologation-only — never runs CI runners.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -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",

View File

@@ -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);

View File

@@ -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).`);

View 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("/"));
});
}

View 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.`;
}

View File

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

View File

@@ -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 ? (

View File

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

View File

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

View File

@@ -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",

View File

@@ -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."
}
}
}

View File

@@ -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í."
}
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

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

View File

@@ -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>>({});

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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 ~200500 mutants; est. 3090 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."
}

View File

@@ -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
);
});

View File

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

View 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)}`
);
});

View 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"));
});

View File

@@ -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
);

View File

@@ -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 };
}

View File

@@ -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);
});

View File

@@ -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");
});

View 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"));
});

View 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);
});

View File

@@ -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", () => {

View 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);
});

View File

@@ -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" },
]);
});

View File

@@ -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" }));

View 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);
});
});

View File

@@ -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);
});

View File

@@ -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_")
);

View 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);
});

View File

@@ -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" },
},

View File

@@ -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;
}

View File

@@ -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`
);
}
);
});

View File

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

View File

@@ -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", () => {