From d3f88716bbf7f3cdc2eb5e4ee45698325818d3c5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:54:34 -0300 Subject: [PATCH 01/33] feat(ci): Trunk Flaky Tests upload on the fast-path vitest job (per-PR volume) (#7205) --- .github/workflows/quality.yml | 13 ++++++++++++- changelog.d/maintenance/trunk-upload-fastpath.md | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 changelog.d/maintenance/trunk-upload-fastpath.md diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e16b4b1df6..e54c4ac4c2 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -230,7 +230,18 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - - run: npm run test:vitest + # WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR, + # which is where flaky-detection volume actually comes from (ci.yml's heavy + # jobs only run on the release PR). Advisory upload, own-origin only. + - run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml + - name: Upload test results to Trunk (advisory) + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + continue-on-error: true + uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 + with: + junit-paths: trunk-junit/**/*.xml + org-slug: omniroute + token: ${{ secrets.TRUNK_TOKEN }} fast-unit: name: Unit Tests fast-path (${{ matrix.shard }}/4) diff --git a/changelog.d/maintenance/trunk-upload-fastpath.md b/changelog.d/maintenance/trunk-upload-fastpath.md new file mode 100644 index 0000000000..441f1300fe --- /dev/null +++ b/changelog.d/maintenance/trunk-upload-fastpath.md @@ -0,0 +1 @@ +- **CI**: the fast-path Vitest job (every PR) now also emits JUnit and uploads to Trunk Flaky Tests — the heavy-gate uploads alone (release PR only) would never accumulate flaky-detection volume From d3a9ad557d3f5dd566da28f92a65b1d9506eeced Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:48:42 -0300 Subject: [PATCH 02/33] chore(release): script the 0a.0b PR re-home with a verified read-back (#7312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel-cycle model hands the frozen release/vX to the captain and cuts release/vX+1 for everyone else. Phase 0a.0b step 3 then re-homes every open PR onto the new cycle — today as a hand-run loop of gh pr edit --base. Three things make that loop unreliable at exactly the moment it matters: 1. gh pr edit --base FAILS SILENTLY (v3.8.42). It exits 0 and leaves the base untouched, so every edit needs a gh pr view --json baseRefName read-back. A human mid-release skips that. 2. gh pr list caps at 30 results by default. A loop written without --limit re-homes the first 30 of 148 and reports success. 3. Volume: the v3.8.49 freeze had 148 open PRs — roughly 450 API calls across edit, verify and comment. The script does the read-back on every PR, uses --limit 300, is idempotent (a PR already on the next base is skipped, so a resumed release re-runs safely), refuses to start when the next branch does not exist yet, and exits non-zero listing any PR whose retarget did not take. It also prints the reminder that it cannot solve the other half: PRs opened AFTER it runs. Those need the repo default_branch pointed at the live cycle — contributors open PRs against the default branch, and while that stays on main they never target a release branch at all (6 such PRs on 2026-07-15). classify() is pure and unit-tested: retarget open and draft PRs on the frozen branch; never touch main (the release PR's own lane), an older shipped release, or a PR already re-homed. Refs #7307 --- .../7307-rehome-open-prs-script.md | 1 + scripts/release/rehome-open-prs.mjs | 158 ++++++++++++++++++ tests/unit/rehome-open-prs.test.ts | 56 +++++++ 3 files changed, 215 insertions(+) create mode 100644 changelog.d/maintenance/7307-rehome-open-prs-script.md create mode 100644 scripts/release/rehome-open-prs.mjs create mode 100644 tests/unit/rehome-open-prs.test.ts diff --git a/changelog.d/maintenance/7307-rehome-open-prs-script.md b/changelog.d/maintenance/7307-rehome-open-prs-script.md new file mode 100644 index 0000000000..e20161f4bc --- /dev/null +++ b/changelog.d/maintenance/7307-rehome-open-prs-script.md @@ -0,0 +1 @@ +- **chore(release):** add `scripts/release/rehome-open-prs.mjs` — the Phase 0a.0b PR re-home, scripted with a read-back after every retarget. `gh pr edit --base` exits 0 without applying (v3.8.42), `gh pr list` silently caps at 30, and the v3.8.49 freeze had 148 open PRs to move — none of which a hand-run loop survives reliably. diff --git a/scripts/release/rehome-open-prs.mjs b/scripts/release/rehome-open-prs.mjs new file mode 100644 index 0000000000..934a0987b2 --- /dev/null +++ b/scripts/release/rehome-open-prs.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// scripts/release/rehome-open-prs.mjs +// +// Parallel-cycle PR re-home (generate-release Phase 0a.0b, step 3). +// Retargets every open PR whose base is the FROZEN release/v onto the +// freshly cut release/v, so development keeps flowing while the captain +// owns the frozen branch. Design: _tasks/release-flow/2026-07-04_proposta-ciclo-paralelo-v2.md +// +// Usage: +// node scripts/release/rehome-open-prs.mjs [--dry-run] +// e.g. node scripts/release/rehome-open-prs.mjs 3.8.49 3.8.50 +// +// WHY THIS EXISTS AS A SCRIPT AND NOT A `gh pr edit` LOOP IN THE SKILL: +// +// 1. `gh pr edit --base` FAILS SILENTLY (v3.8.42 lesson). It exits 0 while +// leaving the base untouched — so every edit MUST be read back with +// `gh pr view --json baseRefName`. A hand-run loop skips that under +// fatigue; this does not. +// 2. Volume. At the v3.8.49 freeze there were 148 open PRs on the release +// branch — ~450 API calls between edit, verify and comment. That is not a +// thing a human does reliably at 2am mid-release. +// 3. `gh pr list` defaults to **30 results**. A loop written without +// `--limit` silently re-homes the first 30 and reports success. +// +// Idempotent: a PR already based on release/v is skipped, so a resumed +// release re-runs this safely. +// +// NOT covered here (by design): PRs opened AFTER this runs. Those are handled +// by flipping the repo's default_branch to release/v at 0a.0b — see the +// skill. Contributors open PRs against the default branch; if that still points +// at `main`, they never target a release branch at all. + +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const REPO = "diegosouzapw/OmniRoute"; + +function gh(args, { allowFail = false } = {}) { + try { + return execFileSync("gh", args, { encoding: "utf8" }).trim(); + } catch (err) { + if (allowFail) return null; + throw new Error(`gh ${args.join(" ")} failed: ${err.stderr || err.message}`); + } +} + +/** + * Pure: classify what should happen to a PR given its current base. + * Split out so the decision is unit-testable without touching the network. + */ +export function classify(pr, currentBase, nextBase) { + if (pr.baseRefName === nextBase) return { action: "skip", reason: "already re-homed" }; + if (pr.baseRefName !== currentBase) { + return { action: "skip", reason: `base is ${pr.baseRefName}, not the frozen branch` }; + } + if (pr.isDraft) return { action: "retarget", reason: "draft — retarget anyway, it still needs a home" }; + return { action: "retarget", reason: "open PR on the frozen branch" }; +} + +function main(argv) { + const dryRun = argv.includes("--dry-run"); + const [current, next] = argv.filter((a) => !a.startsWith("--")); + + if (!current || !next) { + console.error("Usage: node scripts/release/rehome-open-prs.mjs [--dry-run]"); + console.error(" e.g. node scripts/release/rehome-open-prs.mjs 3.8.49 3.8.50"); + process.exit(2); + } + + const currentBase = `release/v${current}`; + const nextBase = `release/v${next}`; + + // The next branch MUST exist before we point anything at it, or every edit + // 422s and we have re-homed nothing while reporting progress. + const exists = gh(["api", `repos/${REPO}/branches/${nextBase}`, "--jq", ".name"], { + allowFail: true, + }); + if (!exists) { + console.error(`✖ ${nextBase} does not exist on origin — cut it first (0a.0b step 1).`); + process.exit(1); + } + + // --limit 300: `gh pr list` returns 30 by default. Without this the loop + // silently re-homes a third of the queue and exits 0. + const raw = gh([ + "pr", "list", "--repo", REPO, "--state", "open", "--limit", "300", + "--base", currentBase, "--json", "number,title,isDraft,baseRefName", + ]); + const prs = JSON.parse(raw); + + console.log(`${prs.length} open PR(s) on ${currentBase} → ${nextBase}${dryRun ? " [DRY RUN]" : ""}\n`); + + const failed = []; + let moved = 0; + let skipped = 0; + + for (const pr of prs) { + const { action, reason } = classify(pr, currentBase, nextBase); + if (action === "skip") { + console.log(` · #${pr.number} skipped — ${reason}`); + skipped++; + continue; + } + if (dryRun) { + console.log(` → #${pr.number} would retarget — ${reason}`); + moved++; + continue; + } + + gh(["pr", "edit", String(pr.number), "--repo", REPO, "--base", nextBase], { allowFail: true }); + + // The read-back is the whole point: `gh pr edit --base` exits 0 on failure. + const actual = gh( + ["pr", "view", String(pr.number), "--repo", REPO, "--json", "baseRefName", "--jq", ".baseRefName"], + { allowFail: true } + ); + + if (actual !== nextBase) { + console.error(` ✖ #${pr.number} STILL on ${actual ?? "?"} — retarget did not take`); + failed.push({ number: pr.number, actual }); + continue; + } + + gh([ + "pr", "comment", String(pr.number), "--repo", REPO, + "--body", + `Re-homed to \`${nextBase}\`: v${current} entered its release freeze, so the branch now belongs ` + + `to the release captain and development continues on the next cycle. Nothing is wrong with this ` + + `PR — it just needed a live base. No action needed from you; CI will re-run against the new base.`, + ], { allowFail: true }); + + console.log(` ✔ #${pr.number} → ${nextBase}`); + moved++; + } + + console.log(`\n${moved} re-homed, ${skipped} skipped, ${failed.length} failed`); + + if (failed.length) { + console.error( + `\n✖ ${failed.length} PR(s) did not take the retarget: ${failed.map((f) => `#${f.number}`).join(", ")}\n` + + ` Re-run this script (it is idempotent) or retarget those by hand and verify with\n` + + ` gh pr view --json baseRefName` + ); + process.exit(1); + } + + if (!dryRun && moved > 0) { + console.log( + `\nReminder (0a.0b): flip the repo default_branch so PRs opened from now on are born on the\n` + + `right base — this script cannot reach PRs that do not exist yet:\n` + + ` gh api -X PATCH repos/${REPO} -f default_branch="${nextBase}"` + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} diff --git a/tests/unit/rehome-open-prs.test.ts b/tests/unit/rehome-open-prs.test.ts new file mode 100644 index 0000000000..5b5c7d23df --- /dev/null +++ b/tests/unit/rehome-open-prs.test.ts @@ -0,0 +1,56 @@ +// Guard for scripts/release/rehome-open-prs.mjs — the parallel-cycle PR re-home +// (generate-release Phase 0a.0b step 3). +// +// The script exists because `gh pr edit --base` fails SILENTLY (v3.8.42): it exits 0 +// while leaving the base untouched. The network side of that is verified by a read-back +// in the script itself; what is testable here is the decision — which PRs get retargeted +// and which are left alone. Getting that wrong in either direction is expensive: +// - retargeting a PR that was never on the frozen branch drags unrelated work into the cycle +// - skipping one strands a contributor on a branch the captain has taken over + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { classify } from "../../scripts/release/rehome-open-prs.mjs"; + +const FROZEN = "release/v3.8.49"; +const NEXT = "release/v3.8.50"; + +test("retargets an open PR sitting on the frozen release branch", () => { + const { action } = classify( + { number: 1, baseRefName: FROZEN, isDraft: false }, + FROZEN, + NEXT + ); + assert.equal(action, "retarget"); +}); + +test("retargets a DRAFT on the frozen branch — a draft still needs a live base", () => { + const { action } = classify({ number: 2, baseRefName: FROZEN, isDraft: true }, FROZEN, NEXT); + assert.equal(action, "retarget"); +}); + +test("skips a PR already re-homed — the script must be idempotent for a resumed release", () => { + const { action, reason } = classify( + { number: 3, baseRefName: NEXT, isDraft: false }, + FROZEN, + NEXT + ); + assert.equal(action, "skip"); + assert.match(reason, /already re-homed/); +}); + +test("never touches a PR based on main — that is the release PR's own lane", () => { + const { action, reason } = classify({ number: 4, baseRefName: "main", isDraft: false }, FROZEN, NEXT); + assert.equal(action, "skip"); + assert.match(reason, /not the frozen branch/); +}); + +test("never touches a PR based on an older, already-shipped release", () => { + const { action } = classify( + { number: 5, baseRefName: "release/v3.8.47", isDraft: false }, + FROZEN, + NEXT + ); + assert.equal(action, "skip"); +}); From 83cca4d20f069a669550d940193e3ee671aa21b2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:05:53 -0300 Subject: [PATCH 03/33] =?UTF-8?q?fix(build):=20packed=20tarball=20boot=20c?= =?UTF-8?q?rash=20=E2=80=94=20server-ws=20timeout=20import=20escaped=20the?= =?UTF-8?q?=20package=20(#7065=20class)=20(#7308)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(build): server-ws timeout helper as shipped sibling — ../../src import crashed every packed boot (#7065 class) * test(build): align pack-artifact-policy fixture with the new dist/main-server-timeouts.mjs required path --- .../pack-boot-runtimetimeouts-sibling.md | 1 + scripts/build/assembleStandalone.mjs | 5 ++ scripts/build/pack-artifact-policy.ts | 2 + scripts/dev/main-server-timeouts.mjs | 47 +++++++++++++++++++ scripts/dev/run-next.mjs | 2 +- scripts/dev/standalone-server-ws.mjs | 2 +- .../unit/main-server-timeouts-parity.test.ts | 39 +++++++++++++++ .../pack-artifact-entrypoint-closures.test.ts | 27 +++++++++++ tests/unit/pack-artifact-policy.test.ts | 1 + ...e-server-ws-keepalive-timeout-7003.test.ts | 10 ++-- 10 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md create mode 100644 scripts/dev/main-server-timeouts.mjs create mode 100644 tests/unit/main-server-timeouts-parity.test.ts diff --git a/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md b/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md new file mode 100644 index 0000000000..3fd32f4f02 --- /dev/null +++ b/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md @@ -0,0 +1 @@ +- **Build**: the packed tarball boots again — #7191's `../../src/…runtimeTimeouts.ts` import in `standalone-server-ws.mjs` escaped the package after the dist-root copy (`ERR_MODULE_NOT_FOUND` on every boot, #7065 class, caught live by the new `check:pack-boot` gate); the helper now lives in the shipped sibling `main-server-timeouts.mjs` (parity-tested against the canonical TS implementation) and the closure test bans package-escaping `../` imports in npm-shipped wrappers diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index b0d8892b27..7afe168b29 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -135,6 +135,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "peer-stamp.mjs"], dest: ["peer-stamp.mjs"], }, + { + label: "main-server timeouts (server-ws.mjs dependency, #7003/#7065-class)", + src: ["scripts", "dev", "main-server-timeouts.mjs"], + dest: ["main-server-timeouts.mjs"], + }, { label: "HTTP method guard (server-ws.mjs dependency)", src: ["scripts", "dev", "http-method-guard.cjs"], diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 9adde22c1f..cc60babe71 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -46,6 +46,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "open-sse/services/compression/engines/llmlingua/onnxWorker.js", "package.json", "peer-stamp.mjs", + "main-server-timeouts.mjs", "responses-ws-proxy.mjs", "scripts/dev/sync-env.mjs", "scripts/dev/tls-options.mjs", @@ -152,6 +153,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/server-ws.mjs", "dist/responses-ws-proxy.mjs", "dist/peer-stamp.mjs", + "dist/main-server-timeouts.mjs", "dist/http-method-guard.cjs", // #5452: regression guard — make check:pack-artifact fail loudly if the TLS // opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball. diff --git a/scripts/dev/main-server-timeouts.mjs b/scripts/dev/main-server-timeouts.mjs new file mode 100644 index 0000000000..a0019fca2d --- /dev/null +++ b/scripts/dev/main-server-timeouts.mjs @@ -0,0 +1,47 @@ +// Main-server keepAlive/headers timeouts (#7003) — SIBLING module of +// standalone-server-ws.mjs. The shipped server-ws.mjs may only import +// siblings copied next to it by assembleStandalone (peer-stamp, tls-options, +// the guards): a ../../src/... import resolves OUTSIDE the package after the +// copy to the dist root and crashes boot with ERR_MODULE_NOT_FOUND (caught +// live by check:pack-boot on 2026-07-15 — the #7065 class). +// Parity with src/shared/utils/runtimeTimeouts.ts#getMainServerTimeoutConfig +// is enforced by tests/unit/main-server-timeouts-parity.test.ts. + +export const DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS = 65_000; +export const DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS = 66_000; + +function readTimeoutMs(env, name, defaultValue, { allowZero = false, logger } = {}) { + const raw = env[name]; + if (raw == null || raw.trim() === "") return defaultValue; + const parsed = Number(raw); + const isValid = Number.isFinite(parsed) && (allowZero ? parsed >= 0 : parsed > 0); + if (!isValid) { + logger?.(`Invalid ${name}="${raw}". Using default ${defaultValue}ms.`); + return defaultValue; + } + return Math.floor(parsed); +} + +export function getMainServerTimeoutConfig(env = process.env, logger) { + const keepAliveTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_KEEPALIVE_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS, + { allowZero: true, logger } + ); + const headersTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_HEADERS_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS, + { allowZero: true, logger } + ); + return { + keepAliveTimeoutMs, + // Node requires headersTimeout > keepAliveTimeout; keep both configurable + // but always coherent (mirrors the canonical TS implementation). + headersTimeoutMs: + headersTimeoutMs > 0 && keepAliveTimeoutMs > 0 + ? Math.max(headersTimeoutMs, keepAliveTimeoutMs + 1_000) + : headersTimeoutMs, + }; +} diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index fffdb06cfb..54c33e56df 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -14,7 +14,7 @@ import headResponseGuard from "./head-response-guard.cjs"; import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs"; import { randomUUID } from "node:crypto"; -import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; +import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; const { maybeHandleDisallowedMethod } = methodGuard; const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index ebbca99936..439a9c5171 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -7,7 +7,7 @@ import { maybeHandleWebdav } from "./webdav-handler.mjs"; import methodGuard from "./http-method-guard.cjs"; import headResponseGuard from "./head-response-guard.cjs"; import { resolveTlsOptions, createServerListener } from "./tls-options.mjs"; -import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; +import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); diff --git a/tests/unit/main-server-timeouts-parity.test.ts b/tests/unit/main-server-timeouts-parity.test.ts new file mode 100644 index 0000000000..7a8374c942 --- /dev/null +++ b/tests/unit/main-server-timeouts-parity.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert"; +import { getMainServerTimeoutConfig as mjsImpl } from "../../scripts/dev/main-server-timeouts.mjs"; +import { getMainServerTimeoutConfig as tsImpl } from "../../src/shared/utils/runtimeTimeouts.ts"; + +// The shipped server-ws.mjs uses the SIBLING scripts/dev/main-server-timeouts.mjs +// (a ../../src import escapes the package after the dist copy — 2026-07-15 boot +// crash, #7065 class). This parity matrix is the anti-drift guard between the +// sibling and the canonical src/shared/utils/runtimeTimeouts.ts implementation. +const ENV_MATRIX: Record[] = [ + {}, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "70000" }, + { MAIN_SERVER_HEADERS_TIMEOUT_MS: "80000" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "90000", MAIN_SERVER_HEADERS_TIMEOUT_MS: "10000" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "0", MAIN_SERVER_HEADERS_TIMEOUT_MS: "0" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "abc" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: " " }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "-5" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "1234.9" }, +]; + +test("sibling main-server-timeouts.mjs stays in parity with runtimeTimeouts.ts", () => { + for (const env of ENV_MATRIX) { + assert.deepStrictEqual( + mjsImpl(env), + tsImpl(env), + `divergence for env ${JSON.stringify(env)}` + ); + } +}); + +test("invalid values log through the provided logger in both implementations", () => { + const logsA: string[] = []; + const logsB: string[] = []; + mjsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsA.push(m)); + tsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsB.push(m)); + assert.strictEqual(logsA.length, 1); + assert.deepStrictEqual(logsA, logsB); +}); diff --git a/tests/unit/pack-artifact-entrypoint-closures.test.ts b/tests/unit/pack-artifact-entrypoint-closures.test.ts index 78b4826417..94a037a68c 100644 --- a/tests/unit/pack-artifact-entrypoint-closures.test.ts +++ b/tests/unit/pack-artifact-entrypoint-closures.test.ts @@ -56,6 +56,16 @@ function localImports(filePath: string): string[] { return [...new Set(patterns.flatMap((re) => [...src.matchAll(re)].map((m) => m[1])))]; } +/** Parent-relative specifiers (../) in a wrapper file — ALWAYS a packaging bug. */ +export function parentRelativeImports(src: string): string[] { + const patterns = [ + /from\s+["'](\.\.\/[^"']+)["']/g, + /import\(\s*["'](\.\.\/[^"']+)["']\s*\)/g, + /require\(\s*["'](\.\.\/[^"']+)["']\s*\)/g, + ]; + return [...new Set(patterns.flatMap((re) => [...src.matchAll(re)].map((m) => m[1])))]; +} + // Wrappers that ship in the npm channel are exactly those whose dest survives the prune. // Wrappers intentionally outside the npm tarball (e.g. healthcheck.mjs, Docker-only) are // excluded: their imports live or die with them, consistently. @@ -123,3 +133,20 @@ test("every bin/omniroute.mjs local import is enforced by check:pack-artifact", `add bin/ to PACK_ARTIFACT_REQUIRED_PATHS: ${missing.join(", ")}` ); }); + +test("no npm-shipped wrapper uses a parent-relative (../) import — it escapes the package after the dist-root copy", () => { + // 2026-07-15 live incident: standalone-server-ws.mjs imported + // ../../src/shared/utils/runtimeTimeouts.ts (merged in #7191); copied to the dist + // root, the specifier resolved to node_modules/src/... OUTSIDE the package and + // every boot of the packed tarball crashed with ERR_MODULE_NOT_FOUND (#7065 + // class — caught by check:pack-boot). Wrapper dependencies must be SIBLINGS + // (./x.mjs) with their own EXTRA_MODULE_ENTRIES copy + pack allowlist entry. + for (const wrapper of npmShippedWrappers()) { + const escaping = parentRelativeImports(fs.readFileSync(path.join(ROOT, wrapper.src), "utf8")); + assert.deepEqual( + escaping, + [], + `${wrapper.src} has package-escaping imports: ${escaping.join(", ")} — extract to a sibling module instead` + ); + } +}); diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index ae24d23dea..9738342e7e 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -112,6 +112,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", "bin/nodeRuntimeSupport.mjs", "dist/head-response-guard.cjs", "dist/http-method-guard.cjs", + "dist/main-server-timeouts.mjs", "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", "dist/open-sse/services/compression/rules/en/filler.json", "dist/peer-stamp.mjs", diff --git a/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts index 076e84b1e4..b69784738e 100644 --- a/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts +++ b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts @@ -33,11 +33,15 @@ const source = fs.readFileSync( ); test("standalone-server-ws.mjs imports getMainServerTimeoutConfig", () => { + // The wrapper must import the SIBLING ./main-server-timeouts.mjs — a + // ../../src/... import escapes the package after the dist-root copy and + // crashed every packed boot (2026-07-15, #7065 class). Parity with the + // canonical runtimeTimeouts.ts is guarded by main-server-timeouts-parity.test.ts. assert.match( source, - /import\s*\{\s*getMainServerTimeoutConfig\s*\}\s*from\s*["'][^"']*runtimeTimeouts(?:\.ts)?["']/, - "expected the production server wrapper to import getMainServerTimeoutConfig, " + - "the same helper run-next.mjs uses" + /import\s*\{\s*getMainServerTimeoutConfig\s*\}\s*from\s*["']\.\/main-server-timeouts\.mjs["']/, + "expected the production server wrapper to import getMainServerTimeoutConfig " + + "from its shipped sibling module (./main-server-timeouts.mjs)" ); }); From 13e312b311381ff517cb7dcf9a1cf4c34bc22b25 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:05:56 -0300 Subject: [PATCH 04/33] fix(skills): register cli-skill-collector in the agent-skills catalog (Integration 2/2 base-red) (#7310) * fix(skills): register cli-skill-collector in the agent-skills catalog (#6294 shipped the dir only) * chore(skills): regenerate cli-skill-collector SKILL.md via the generator, preserving the #6294 authored workflow in the custom block * fix(skills): derive coverage totals from the id lists + align remaining count assertions (45 catalog / 21 cli) * fix(skills): SkillCoverage totals are number, not stale literals --- .../fixes/register-cli-skill-collector.md | 1 + skills/cli-skill-collector/SKILL.md | 271 +++++++++++++++++- src/lib/agentSkills/catalog.ts | 7 +- src/lib/agentSkills/types.ts | 9 +- src/shared/constants/agentSkills.ts | 9 + .../integration/agent-skills-content.test.ts | 13 +- .../agent-skills-discovery.test.ts | 14 +- tests/unit/agentSkillTools-mcp.test.ts | 16 +- tests/unit/agentSkills-catalog.test.ts | 26 +- tests/unit/agentSkills-generator.test.ts | 14 +- tests/unit/agentSkills-routes.test.ts | 12 +- tests/unit/listCapabilities-a2a.test.ts | 12 +- 12 files changed, 345 insertions(+), 59 deletions(-) create mode 100644 changelog.d/fixes/register-cli-skill-collector.md diff --git a/changelog.d/fixes/register-cli-skill-collector.md b/changelog.d/fixes/register-cli-skill-collector.md new file mode 100644 index 0000000000..f7ba19ba92 --- /dev/null +++ b/changelog.d/fixes/register-cli-skill-collector.md @@ -0,0 +1 @@ +- **Skills**: register `cli-skill-collector` in the agent-skills catalog (types union, curated entry, CLI id list) — #6294 shipped the `skills/cli-skill-collector/` directory without the catalog registration, so it was unreachable via the API and Integration CI failed on the catalog-integrity test; counts aligned (44 API+CLI, 45 with config) diff --git a/skills/cli-skill-collector/SKILL.md b/skills/cli-skill-collector/SKILL.md index 2e3cec2776..add237ad11 100644 --- a/skills/cli-skill-collector/SKILL.md +++ b/skills/cli-skill-collector/SKILL.md @@ -1,7 +1,275 @@ --- name: cli-skill-collector -description: "Agent workflow: detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.), search GitHub for matching agent skills, and install them to the detected tools. Replaces the standalone Skill Collector Python app." +description: "Detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline and more), search GitHub for matching agent skills, and install them to the detected tools via OmniRoute's built-in APIs." --- + + +## Overview + +Detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline and more), search GitHub for matching agent skills, and install them to the detected tools via OmniRoute's built-in APIs. + +## Quick install + +```bash +npm install -g omniroute # or: npx omniroute +omniroute --version +``` + +## Subcommands + +### `autostart` + +**Example:** + +```bash +omniroute autostart +``` + +### `autostart enable` + +**Example:** + +```bash +omniroute autostart enable +``` + +### `autostart disable` + +**Example:** + +```bash +omniroute autostart disable +``` + +### `autostart toggle` + +**Example:** + +```bash +omniroute autostart toggle +``` + +### `autostart status` + +**Example:** + +```bash +omniroute autostart status +``` + +### `config` + +Show or update CLI tool configuration + +**Example:** + +```bash +omniroute config +``` + +### `config list` + +List all CLI tools and config status + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config list +``` + +### `config get ` + +Show current config for a tool + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config get +``` + +### `config set ` + +Write config for a tool + +**Flags:** + +- `--model ` +- `--non-interactive` +- `--yes` + +**Example:** + +```bash +omniroute config set +``` + +### `config validate ` + +Validate config format without writing + +**Flags:** + +- `--model ` +- `--json` + +**Example:** + +```bash +omniroute config validate +``` + +### `config opencode` + +Generate OpenCode config (alias for + +**Flags:** + +- `--model ` +- `--non-interactive` +- `--yes` + +**Example:** + +```bash +omniroute config opencode +``` + +### `config lang` + +**Example:** + +```bash +omniroute config lang +``` + +### `config get` + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config get +``` + +### `config set ` + +**Flags:** + +- `--force` + +**Example:** + +```bash +omniroute config set +``` + +### `config list` + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute config list +``` + +### `env` + +Show and manage environment variables + +**Example:** + +```bash +omniroute env +``` + +### `env show` + +Show current environment variables + +**Flags:** + +- `--json` + +**Example:** + +```bash +omniroute env show +``` + +### `env get ` + +Get a single environment variable + +**Example:** + +```bash +omniroute env get +``` + +### `env set ` + +Set an environment variable (current session only) + +**Example:** + +```bash +omniroute env set +``` + +### `setup` + +**Flags:** + +- `--password ` +- `--add-provider` +- `--provider ` +- `--provider-name ` +- `--api-key ` +- `--default-model ` +- `--provider-base-url ` +- `--test-provider` +- `--non-interactive` +- `--list` + +**Example:** + +```bash +omniroute setup +``` + +### `update` + +**Flags:** + +- `--check` +- `--apply` +- `--changelog` +- `--dry-run` +- `--no-backup` +- `--yes` + +**Example:** + +```bash +omniroute update +``` + + + # /cli-skill-collector — Agent Skill Collector @@ -150,3 +418,4 @@ fi - OmniRoute must be running locally on port 20128 (default) — see `docs/frameworks/SKILLS.md` for custom-port setups. - The `/api/skills/collect/*` and `/api/github-skills` endpoints require **management-scoped authentication** the same way every other `/api/skills/*` route does: a dashboard session, the loopback CLI token, or an API key with the `manage` scope (`requireManagementAuth()`). Auth is only bypassed when the server has no login/API-key requirement configured at all. - This replaces the standalone Skill Collector Python app — all logic is now inside OmniRoute. + diff --git a/src/lib/agentSkills/catalog.ts b/src/lib/agentSkills/catalog.ts index af1a9be0d1..cefef715bd 100644 --- a/src/lib/agentSkills/catalog.ts +++ b/src/lib/agentSkills/catalog.ts @@ -68,6 +68,7 @@ export const CLI_SKILL_IDS: readonly string[] = [ "cli-eval", "cli-plugins-skills", "cli-setup", + "cli-skill-collector", ] as const; // ── Module-scope cache ────────────────────────────────────────────────────── @@ -148,8 +149,10 @@ export function computeCoverage(): SkillCoverage { const configHave = catalog.filter((s) => s.category === "config" && presentIds.has(s.id)).length; return { - api: { have: apiHave, total: 23 }, - cli: { have: cliHave, total: 20 }, + // Totals derive from the id lists — hardcoded 23/20 went stale the first + // time the catalog grew (cli-skill-collector registration, 2026-07-15). + api: { have: apiHave, total: API_SKILL_IDS.length }, + cli: { have: cliHave, total: CLI_SKILL_IDS.length }, config: { have: configHave, total: configTotal }, totalSkills: apiHave + cliHave + configHave, generatedAt: new Date().toISOString(), diff --git a/src/lib/agentSkills/types.ts b/src/lib/agentSkills/types.ts index 61aa865bae..9f9c3e10a7 100644 --- a/src/lib/agentSkills/types.ts +++ b/src/lib/agentSkills/types.ts @@ -48,7 +48,8 @@ export type SkillArea = | "cli-batches" | "cli-eval" | "cli-plugins-skills" - | "cli-setup"; + | "cli-setup" + | "cli-skill-collector"; export interface AgentSkill { id: string; // canonical id (e.g. "omni-providers", "cli-serve") @@ -66,8 +67,10 @@ export interface AgentSkill { } export interface SkillCoverage { - api: { have: number; total: 23 }; - cli: { have: number; total: 20 }; + // Totals are derived from the catalog id lists (literal types went stale the + // first time the catalog grew — cli-skill-collector, 2026-07-15). + api: { have: number; total: number }; + cli: { have: number; total: number }; config: { have: number; total: number }; totalSkills: number; // sum generatedAt: string; // ISO datetime diff --git a/src/shared/constants/agentSkills.ts b/src/shared/constants/agentSkills.ts index 90fbce6fbe..a69f506cd1 100644 --- a/src/shared/constants/agentSkills.ts +++ b/src/shared/constants/agentSkills.ts @@ -428,6 +428,15 @@ export const CURATED_SKILLS: CuratedSkillEntry[] = [ area: "cli-setup", icon: "build", }, + { + id: "cli-skill-collector", + name: "CLI: Agent Skill Collector", + description: + "Detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline and more), search GitHub for matching agent skills, and install them to the detected tools via OmniRoute's built-in APIs.", + category: "cli", + area: "cli-setup", + icon: "extension", + }, // ── Config Skills ──────────────────────────────────────────────────────────── diff --git a/tests/integration/agent-skills-content.test.ts b/tests/integration/agent-skills-content.test.ts index 9e0ad388ab..74eed7741b 100644 --- a/tests/integration/agent-skills-content.test.ts +++ b/tests/integration/agent-skills-content.test.ts @@ -2,9 +2,9 @@ * Integration tests for Agent Skills content integrity. * * Verifies: - * 1. All 43 skill IDs from catalog have skills/{id}/ folder with SKILL.md. + * 1. All 44 skill IDs from catalog have skills/{id}/ folder with SKILL.md. * 2. Zero omniroute-* folders remain (post-prune: old omniroute-* skill dirs were removed). - * 3. 10 specific IDs have ... blocks: + * 3. 12 specific IDs have ... blocks: * omni-mcp, omni-compression, cli-providers, cli-eval, omni-agents-a2a, * omni-combos-routing, omni-auth, omni-resilience, omni-inference, cli-serve. * @@ -22,6 +22,7 @@ const ALL_IDS = [...API_SKILL_IDS, ...CLI_SKILL_IDS, ...CONFIG_SKILL_IDS] as str // IDs that must have a custom block const CUSTOM_BLOCK_IDS = [ + "cli-skill-collector", "omni-mcp", "omni-compression", "cli-providers", @@ -37,7 +38,7 @@ const CUSTOM_BLOCK_IDS = [ // ── §1: All 42 catalog IDs have skills/{id}/SKILL.md ───────────────────────── -test("all 43 catalog IDs have a skills/{id}/ directory", () => { +test("all 44 catalog IDs have a skills/{id}/ directory", () => { const missing: string[] = []; for (const id of ALL_IDS) { const dirPath = path.join(SKILLS_DIR, id); @@ -48,7 +49,7 @@ test("all 43 catalog IDs have a skills/{id}/ directory", () => { assert.deepEqual(missing, [], `Missing skill directories: ${missing.join(", ")}`); }); -test("all 43 catalog IDs have a skills/{id}/SKILL.md file", () => { +test("all 44 catalog IDs have a skills/{id}/SKILL.md file", () => { const missing: string[] = []; for (const id of ALL_IDS) { const skillPath = path.join(SKILLS_DIR, id, "SKILL.md"); @@ -113,7 +114,7 @@ for (const id of CUSTOM_BLOCK_IDS) { // ── Additional integrity checks ─────────────────────────────────────────────── -test("exactly 11 skills have custom blocks", () => { +test("exactly 12 skills have custom blocks", () => { const withCustomBlocks: string[] = []; for (const id of ALL_IDS) { const skillPath = path.join(SKILLS_DIR, id, "SKILL.md"); @@ -128,7 +129,7 @@ test("exactly 11 skills have custom blocks", () => { assert.deepEqual( withCustomBlocks.sort(), expectedIds, - `Expected exactly these 11 custom-block IDs: ${expectedIds.join(", ")}\nActual: ${withCustomBlocks.join(", ")}`, + `Expected exactly these 12 custom-block IDs: ${expectedIds.join(", ")}\nActual: ${withCustomBlocks.join(", ")}`, ); }); diff --git a/tests/integration/agent-skills-discovery.test.ts b/tests/integration/agent-skills-discovery.test.ts index 9e80ff4d18..6cc9a8dc52 100644 --- a/tests/integration/agent-skills-discovery.test.ts +++ b/tests/integration/agent-skills-discovery.test.ts @@ -69,8 +69,8 @@ test("every CLI skill ID has skills//SKILL.md on disk", () => { assert.deepEqual(missing, [], `Missing CLI SKILL.md files: ${missing.join(", ")}`); }); -test("total skill count is exactly 43 (23 API + 20 CLI)", () => { - assert.equal(API_SKILL_IDS.length + CLI_SKILL_IDS.length, 43); +test("total skill count is exactly 44 (23 API + 21 CLI)", () => { + assert.equal(API_SKILL_IDS.length + CLI_SKILL_IDS.length, 44); }); // ── §2: Frontmatter validation ──────────────────────────────────────────────── @@ -120,11 +120,11 @@ test("each SKILL.md body is at least 100 chars", () => { // ── §3: MCP tool omniroute_agent_skills_list ───────────────────────────────── -test("MCP omniroute_agent_skills_list handler returns count 44 (43 + config)", async () => { +test("MCP omniroute_agent_skills_list handler returns count 45 (44 + config)", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({}); - assert.equal(result.count, 44, `Expected 44 but got ${result.count}`); + assert.equal(result.count, 45, `Expected 45 but got ${result.count}`); assert.ok(Array.isArray(result.skills)); - assert.equal(result.skills.length, 44); + assert.equal(result.skills.length, 45); }); test("MCP omniroute_agent_skills_list result has all 42 IDs", async () => { @@ -157,9 +157,9 @@ test("A2A list-capabilities artifact content contains 42 skill IDs as table rows assert.ok(rows.length >= 42, `Expected at least 42 data rows but got ${rows.length}`); }); -test("A2A list-capabilities metadata.totalSkills === 44 (43 + config)", async () => { +test("A2A list-capabilities metadata.totalSkills === 45 (44 + config)", async () => { const result = await executeListCapabilities(stubTask); - assert.equal(result.metadata.totalSkills, 44); + assert.equal(result.metadata.totalSkills, 45); }); test("A2A list-capabilities artifact contains all 42 skill IDs", async () => { diff --git a/tests/unit/agentSkillTools-mcp.test.ts b/tests/unit/agentSkillTools-mcp.test.ts index a34acea595..e344f14928 100644 --- a/tests/unit/agentSkillTools-mcp.test.ts +++ b/tests/unit/agentSkillTools-mcp.test.ts @@ -58,11 +58,11 @@ test("each agentSkillTool has name, description, inputSchema, and handler", () = // ─── omniroute_agent_skills_list ──────────────────────────────────────────── -test("omniroute_agent_skills_list with no filters returns all 44 skills", async () => { +test("omniroute_agent_skills_list with no filters returns all 45 skills", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({}); - assert.equal(result.count, 44, `Expected 44 but got ${result.count}`); + assert.equal(result.count, 45, `Expected 45 but got ${result.count}`); assert.ok(Array.isArray(result.skills)); - assert.equal(result.skills.length, 44); + assert.equal(result.skills.length, 45); }); test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries", async () => { @@ -71,9 +71,9 @@ test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries", assert.ok(result.skills.every((s: { category: string }) => s.category === "api")); }); -test("omniroute_agent_skills_list({category:'cli'}) returns exactly 20 entries", async () => { +test("omniroute_agent_skills_list({category:'cli'}) returns exactly 21 entries", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({ category: "cli" }); - assert.equal(result.count, 20, `Expected 20 cli skills but got ${result.count}`); + assert.equal(result.count, 21, `Expected 21 cli skills but got ${result.count}`); assert.ok(result.skills.every((s: { category: string }) => s.category === "cli")); }); @@ -83,7 +83,7 @@ test("omniroute_agent_skills_list result includes coverage shape", async () => { assert.ok(typeof result.coverage.api === "object"); assert.ok(typeof result.coverage.cli === "object"); assert.equal(result.coverage.api.total, 23); - assert.equal(result.coverage.cli.total, 20); + assert.equal(result.coverage.cli.total, 21); assert.ok(typeof result.coverage.totalSkills === "number"); assert.ok(typeof result.coverage.generatedAt === "string"); }); @@ -168,11 +168,11 @@ test("omniroute_agent_skills_coverage({}) returns coverage shape", async () => { assert.ok(typeof result.api === "object"); assert.ok(typeof result.cli === "object"); assert.equal(result.api.total, 23); - assert.equal(result.cli.total, 20); + assert.equal(result.cli.total, 21); assert.ok(typeof result.api.have === "number"); assert.ok(typeof result.cli.have === "number"); assert.ok(result.api.have >= 0 && result.api.have <= 23); - assert.ok(result.cli.have >= 0 && result.cli.have <= 20); + assert.ok(result.cli.have >= 0 && result.cli.have <= 21); assert.ok(typeof result.totalSkills === "number"); assert.equal(result.totalSkills, result.api.have + result.cli.have + (result.config?.have ?? 0)); assert.ok(typeof result.generatedAt === "string"); diff --git a/tests/unit/agentSkills-catalog.test.ts b/tests/unit/agentSkills-catalog.test.ts index 9820c627c7..eec31d49ad 100644 --- a/tests/unit/agentSkills-catalog.test.ts +++ b/tests/unit/agentSkills-catalog.test.ts @@ -15,10 +15,10 @@ const agentSkillsConstants = await import("../../src/shared/constants/agentSkill // ─── Counts ─────────────────────────────────────────────────────────────────── -test("getCatalog() returns exactly 44 entries", () => { +test("getCatalog() returns exactly 45 entries", () => { refreshCatalog(); const catalog = getCatalog(); - assert.equal(catalog.length, 44, `Expected 44 but got ${catalog.length}`); + assert.equal(catalog.length, 45, `Expected 45 but got ${catalog.length}`); }); test("API_SKILL_IDS has exactly 23 entries", () => { @@ -26,7 +26,7 @@ test("API_SKILL_IDS has exactly 23 entries", () => { }); test("CLI_SKILL_IDS has exactly 20 entries", () => { - assert.equal(CLI_SKILL_IDS.length, 20); + assert.equal(CLI_SKILL_IDS.length, 21); }); test("getCatalog() contains exactly 22 api skills", () => { @@ -34,9 +34,9 @@ test("getCatalog() contains exactly 22 api skills", () => { assert.equal(apiSkills.length, 23); }); -test("getCatalog() contains exactly 20 cli skills", () => { +test("getCatalog() contains exactly 21 cli skills", () => { const cliSkills = getCatalog().filter((s) => s.category === "cli"); - assert.equal(cliSkills.length, 20); + assert.equal(cliSkills.length, 21); }); // ─── ID format ──────────────────────────────────────────────────────────────── @@ -160,9 +160,9 @@ test("filterCatalog({ category: 'api' }) returns 23 api skills", () => { } }); -test("filterCatalog({ category: 'cli' }) returns 20 cli skills", () => { +test("filterCatalog({ category: 'cli' }) returns 21 cli skills", () => { const skills = filterCatalog({ category: "cli" }); - assert.equal(skills.length, 20); + assert.equal(skills.length, 21); for (const s of skills) { assert.equal(s.category, "cli"); } @@ -185,9 +185,9 @@ test("filterCatalog({ area: 'nonexistent' }) returns empty array", () => { assert.equal(skills.length, 0); }); -test("filterCatalog({}) returns full catalog (44 entries)", () => { +test("filterCatalog({}) returns full catalog (45 entries)", () => { const skills = filterCatalog({}); - assert.equal(skills.length, 44); + assert.equal(skills.length, 45); }); // ─── refreshCatalog ─────────────────────────────────────────────────────────── @@ -214,9 +214,9 @@ test("computeCoverage() returns valid SkillCoverage shape", () => { assert.ok(cov.api.have >= 0 && cov.api.have <= 23); assert.ok(typeof cov.cli === "object"); - assert.equal(cov.cli.total, 20); + assert.equal(cov.cli.total, 21); assert.ok(typeof cov.cli.have === "number"); - assert.ok(cov.cli.have >= 0 && cov.cli.have <= 20); + assert.ok(cov.cli.have >= 0 && cov.cli.have <= 21); assert.equal(cov.totalSkills, cov.api.have + cov.cli.have + (cov.config?.have ?? 0)); @@ -255,6 +255,6 @@ test("CLI_SKILL_IDS first entry is cli-serve", () => { assert.equal(CLI_SKILL_IDS[0], "cli-serve"); }); -test("CLI_SKILL_IDS last entry is cli-setup", () => { - assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-setup"); +test("CLI_SKILL_IDS last entry is cli-skill-collector", () => { + assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-skill-collector"); }); diff --git a/tests/unit/agentSkills-generator.test.ts b/tests/unit/agentSkills-generator.test.ts index db54f73ad2..95475887d9 100644 --- a/tests/unit/agentSkills-generator.test.ts +++ b/tests/unit/agentSkills-generator.test.ts @@ -61,11 +61,11 @@ test("dry-run (default) returns report without writing any files", async () => { outputDir: tmpDir, }); - // All 44 skills should appear as generated (would-write) since dir is empty + // All 45 skills should appear as generated (would-write) since dir is empty assert.equal( report.generated.length + report.unchanged.length, - 44, - `Expected 44 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, + 45, + `Expected 45 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, ); assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`); @@ -81,7 +81,7 @@ test("dry-run (default) returns report without writing any files", async () => { } }); -test("dry-run generates report with 44 total (generated+unchanged)", async () => { +test("dry-run generates report with 45 total (generated+unchanged)", async () => { const tmpDir = mkTmpDir(); try { refreshCatalog(); @@ -91,7 +91,7 @@ test("dry-run generates report with 44 total (generated+unchanged)", async () => outputDir: tmpDir, }); const total = report.generated.length + report.unchanged.length; - assert.equal(total, 44); + assert.equal(total, 45); } finally { rmTmpDir(tmpDir); } @@ -134,7 +134,7 @@ test("apply mode writes SKILL.md with valid frontmatter for omni-providers", asy } }); -test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async () => { +test("apply mode writes all 45 SKILL.md files when no onlyIds filter", async () => { const tmpDir = mkTmpDir(); try { refreshCatalog(); @@ -145,7 +145,7 @@ test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async () }); assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`); - assert.equal(report.generated.length, 44); + assert.equal(report.generated.length, 45); // Verify all dirs exist const catalog = getCatalog(); diff --git a/tests/unit/agentSkills-routes.test.ts b/tests/unit/agentSkills-routes.test.ts index 16265f4a4f..1de6eccf2d 100644 --- a/tests/unit/agentSkills-routes.test.ts +++ b/tests/unit/agentSkills-routes.test.ts @@ -101,15 +101,15 @@ test.after(() => { // GET /api/agent-skills // ═════════════════════════════════════════════════════════════════════════════ -test("GET /api/agent-skills — returns 44 skills with count and coverage", async () => { +test("GET /api/agent-skills — returns 45 skills with count and coverage", async () => { const req = makeRequest("GET", "http://localhost/api/agent-skills"); const res = await listRoute.GET(req); assert.equal(res.status, 200); const body = (await res.json()) as { skills: unknown[]; count: number; coverage: unknown }; - assert.equal(body.count, 44, `Expected 44 skills but got ${body.count}`); + assert.equal(body.count, 45, `Expected 45 skills but got ${body.count}`); assert.equal(Array.isArray(body.skills), true); - assert.equal(body.skills.length, 44); + assert.equal(body.skills.length, 45); assert.ok(body.coverage !== undefined, "coverage should be present"); }); @@ -123,13 +123,13 @@ test("GET /api/agent-skills?category=api — returns 23 api skills", async () => assert.ok(body.skills.every((s) => s.category === "api"), "All skills should be api category"); }); -test("GET /api/agent-skills?category=cli — returns 20 cli skills", async () => { +test("GET /api/agent-skills?category=cli — returns 21 cli skills", async () => { const req = makeRequest("GET", "http://localhost/api/agent-skills?category=cli"); const res = await listRoute.GET(req); assert.equal(res.status, 200); const body = (await res.json()) as { skills: Array<{ category: string }>; count: number }; - assert.equal(body.count, 20); + assert.equal(body.count, 21); assert.ok(body.skills.every((s) => s.category === "cli"), "All skills should be cli category"); }); @@ -268,7 +268,7 @@ test("GET /api/agent-skills/coverage — returns valid SkillCoverage shape", asy }; assert.equal(body.api.total, 23, "api.total must be 23"); - assert.equal(body.cli.total, 20, "cli.total must be 20"); + assert.equal(body.cli.total, 21, "cli.total must be 21"); assert.ok(typeof body.totalSkills === "number", "totalSkills must be a number"); assert.ok(typeof body.generatedAt === "string", "generatedAt must be a string"); // generatedAt must be a valid ISO datetime diff --git a/tests/unit/listCapabilities-a2a.test.ts b/tests/unit/listCapabilities-a2a.test.ts index a51357d712..60e636b163 100644 --- a/tests/unit/listCapabilities-a2a.test.ts +++ b/tests/unit/listCapabilities-a2a.test.ts @@ -3,7 +3,7 @@ * * Verifies: * - Return shape matches §3.7 contract - * - Markdown table contains all 43 skill IDs + * - Markdown table contains all 44 skill IDs * - Coverage bounds are within declared totals * - metadata.source === "agent-skills-catalog" * - metadata.generatedAt is an ISO datetime string @@ -31,7 +31,7 @@ test("executeListCapabilities returns shape matching §3.7 contract", async () = const { metadata } = result; assert.ok(metadata, "metadata exists"); assert.equal(metadata.source, "agent-skills-catalog", "metadata.source matches"); - assert.equal(metadata.totalSkills, 44, "metadata.totalSkills === 44 (43 + config)"); + assert.equal(metadata.totalSkills, 45, "metadata.totalSkills === 45 (44 + config)"); assert.ok(metadata.coverage, "metadata.coverage exists"); assert.ok(metadata.coverage.api, "metadata.coverage.api exists"); assert.ok(metadata.coverage.cli, "metadata.coverage.cli exists"); @@ -39,12 +39,12 @@ test("executeListCapabilities returns shape matching §3.7 contract", async () = assert.equal(metadata.coverage.cli.total, 20, "cli.total === 20"); }); -test("executeListCapabilities markdown table contains all 43 API+CLI skill IDs", async () => { +test("executeListCapabilities markdown table contains all 44 API+CLI skill IDs", async () => { const result = await executeListCapabilities(stubTask); const content = result.artifacts[0].content; const allIds = [...API_SKILL_IDS, ...CLI_SKILL_IDS] as string[]; - assert.equal(allIds.length, 43, "API+CLI catalog declares 43 skill IDs"); + assert.equal(allIds.length, 44, "API+CLI catalog declares 44 skill IDs"); for (const id of allIds) { assert.ok(content.includes(id), `Markdown table missing skill ID: ${id}`); @@ -58,11 +58,11 @@ test("metadata.coverage.api.have is within [0, 23]", async () => { assert.ok(api.have <= 23, "api.have <= 23"); }); -test("metadata.coverage.cli.have is within [0, 20]", async () => { +test("metadata.coverage.cli.have is within [0, 21]", async () => { const result = await executeListCapabilities(stubTask); const { cli } = result.metadata.coverage; assert.ok(cli.have >= 0, "cli.have >= 0"); - assert.ok(cli.have <= 20, "cli.have <= 20"); + assert.ok(cli.have <= 21, "cli.have <= 21"); }); test("metadata.generatedAt is a valid ISO datetime", async () => { From d8edefd1513cb5cd9fb84532caafad657aec3740 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:18:16 -0300 Subject: [PATCH 05/33] chore(ci): make the Electron Windows leg advisory with bash stderr capture (first-run failure diagnosis) (#7340) --- .github/workflows/ci.yml | 8 +++++++- changelog.d/maintenance/electron-win-advisory.md | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 changelog.d/maintenance/electron-win-advisory.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df99b771ae..83201fc900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -670,10 +670,16 @@ jobs: if: runner.os == 'Linux' working-directory: electron run: npm run pack + # ADVISORY while the new Windows leg matures (repo convention, dast-smoke + # precedent): its first-ever real run (2026-07-15, run 29457533565) died in + # 0.7s with the error swallowed by pwsh — bash shell captures stderr and + # continue-on-error keeps the heavy gate green while we harden it (#7336). - name: Prepare Electron standalone (Windows ABI rebuild + spawn path) if: runner.os == 'Windows' working-directory: electron - run: npm run prepare:bundle + continue-on-error: true + shell: bash + run: npm run prepare:bundle 2>&1 - name: Smoke packaged Electron app if: runner.os == 'Linux' env: diff --git a/changelog.d/maintenance/electron-win-advisory.md b/changelog.d/maintenance/electron-win-advisory.md new file mode 100644 index 0000000000..c5f29e8404 --- /dev/null +++ b/changelog.d/maintenance/electron-win-advisory.md @@ -0,0 +1 @@ +- **CI**: the new Electron Windows prepare-bundle leg (WS1.5) is advisory while it matures — its first real run failed with the error swallowed by pwsh; the step now runs under bash (stderr captured) with `continue-on-error`, tracked for promotion once green From 886b906818d4b3f3df1f600b296dc6ffb5e8286e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:57:49 -0300 Subject: [PATCH 06/33] fix(ci): Coverage job timeout 10->20min (lcov reporter pushed it past the old cap) (#7342) --- .github/workflows/ci.yml | 5 ++++- changelog.d/maintenance/coverage-job-timeout.md | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog.d/maintenance/coverage-job-timeout.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83201fc900..917ca860aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -789,7 +789,10 @@ jobs: test-coverage: name: Coverage runs-on: ubuntu-latest - timeout-minutes: 10 + # 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it); + # merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive + # release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16). + timeout-minutes: 20 needs: test-unit if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }} env: diff --git a/changelog.d/maintenance/coverage-job-timeout.md b/changelog.d/maintenance/coverage-job-timeout.md new file mode 100644 index 0000000000..ce36f15f3f --- /dev/null +++ b/changelog.d/maintenance/coverage-job-timeout.md @@ -0,0 +1 @@ +- **CI**: raise the Coverage job timeout 10→20min — the lcov reporter added for Codecov/Sonar (#7114) pushed the 8-shard report merge past the old cap, and three release-tip runs died at exactly 10min as job-timeout "cancelled" From b83fe6f7dc45c07f3bc573187e06cabdbdcd4502 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:49:09 -0300 Subject: [PATCH 07/33] =?UTF-8?q?test(ci):=20make=20#6634=20selfref=20guar?= =?UTF-8?q?d=20hermetic=20=E2=80=94=20read=20file=20from=20disk,=20no=20gi?= =?UTF-8?q?t=20ref=20(#7327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-test-masking-selfref-6634.test.ts did git I/O inside a unit test (`git show origin/main:`), the single most common red across today's babysit sweep — GitHub-hosted runners use shallow/single-ref checkouts with no origin/main, so the show fails with "fatal: invalid object name". The prior hotfix (2e42b8efc, #7174) wrapped it in try/catch + on-demand fetch + t.skip() on failure, but t.skip() itself trips the PR Test Policy weakened-assert gate (confirmed today on #7300), and origin/main was the wrong ref anyway — PRs target release/v3.8.49, not main. Ported the hermetic version proven on PR #7300 (@growab): read the real current source of check-test-masking.test.ts from disk instead of diffing against a git ref, and use an empty-string base (baseTaut/baseExtTaut = 0) instead of the pre-#6404 git snapshot — this maximizes headTaut - baseTaut, the strictest input for the exclusion under test, so the guard is exercised at least as hard as before. No git ref, no skip, no CI-shape dependency. Verified both directions locally: - SELF_TEST_FIXTURE_RE neutralized in check-test-masking.mjs -> test FAILS (10 new bare tautologies + 28 new extended tautologies reported) - restored -> test PASSES, and the full check-test-masking.test.ts suite (55 tests) stays green, confirming the #6634 self-referential-fixture regression this guard exists for is still covered. Co-authored-by: growab --- .../check-test-masking-selfref-6634.test.ts | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/tests/unit/check-test-masking-selfref-6634.test.ts b/tests/unit/check-test-masking-selfref-6634.test.ts index 97171e07ba..b233a008f2 100644 --- a/tests/unit/check-test-masking-selfref-6634.test.ts +++ b/tests/unit/check-test-masking-selfref-6634.test.ts @@ -14,12 +14,14 @@ * (`if (file.endsWith("check-test-masking.test.ts")) continue;` in * scripts/check/check-test-masking.mjs) for precisely this reason — this test * asserts evaluateMasking() now applies the same exclusion for its diff-based - * tautology counters, using the real base(origin/main)/head(HEAD) diff of + * tautology counters, against the REAL current source of * tests/unit/check-test-masking.test.ts. */ import test from "node:test"; import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { countTautologies, @@ -28,29 +30,17 @@ import { } from "../../scripts/check/check-test-masking.mjs"; const FILE = "tests/unit/check-test-masking.test.ts"; +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -function git(args: string[]): string { - return execFileSync("git", args, { encoding: "utf8" }); -} - -test("#6634: check-test-masking.test.ts's own tautology fixtures must not self-flag as weakening", (t) => { - // origin/main predates the #6404 fixtures (countBareTautologies/scanBareTautologies - // tests) that legitimately embed tautology-pattern literals as string fixtures. - // Shallow/single-ref checkouts (GitHub-hosted runners) have no origin/main — - // fetch it on demand; skip (never fail) when the ref is unreachable offline. - let baseSrc: string; - try { - baseSrc = git(["show", "origin/main:" + FILE]); - } catch { - try { - git(["fetch", "--depth=1", "origin", "main"]); - baseSrc = git(["show", "origin/main:" + FILE]); - } catch { - t.skip("origin/main unavailable (shallow checkout, offline) — nothing to compare against"); - return; - } - } - const headSrc = git(["show", "HEAD:" + FILE]); +test("#6634: check-test-masking.test.ts's own tautology fixtures must not self-flag as weakening", () => { + // Read the REAL current source from disk rather than a git ref: the Unit Tests + // job checks out a shallow/single-ref tree with no origin/main, so `git show + // origin/main:` failed the shard before it ever exercised the masking + // behavior under test. An empty base models the file's pre-#6404 state (no + // fixtures), which maximizes headTaut - baseTaut — the strictest input for the + // exclusion this test asserts. + const baseSrc = ""; + const headSrc = fs.readFileSync(path.join(REPO_ROOT, FILE), "utf8"); const perFile = [ { From 635db36de0f20eb3942b0c8f15f3b4d0db2fa74f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:49:16 -0300 Subject: [PATCH 08/33] chore(quality): tighten the coverage ratchet to the CI's real numbers (#7326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quality Ratchet has been red on main, and not for a regression — the report says 'OK (57 métricas, 11 melhoraram)'. It fails the --require-tighten step: ✗ coverage.branches: melhorou de 73 para 78.1 (delta 5.1000 > slack 5) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado The gate was asking for this in plain text. The baseline's own note names the same trigger: 'Apertar via quality:ratchet -- --update a partir do 1o run de coverage mergeada do CI que popule essas chaves.' Values are the CI's, not a local run. The baseline warns that a local test:coverage measures ~68% against the CI's ~76.5% — tightening to local numbers would write the wrong floor. So this reproduces the CI's exact inputs: eslint-results + coverage-report artifacts downloaded from the merged-coverage run on main (29387411665), re-rooted from the runner's paths to the local cwd so extractModuleCoverage can match CRITICAL_MODULE_PATHS, then quality:collect + quality:ratchet --update. Collected output matches the CI's report line for line (branches 78.1, statements/lines 80.8, functions 86.44, chatCore 72.98, combo 85.42, accountFallback 96.78, auth 92.55). Verified: no baseline key added or removed (56 before, 56 after) — only the 12 coverage values moved. The 57-vs-56 metric count between the CI's run and a local one is --allow-missing skipping the metrics only CI collects (mutation scores, CodeQL, bundle size). Worth recording why the improvement appeared now: it is real, but it surfaced because Coverage had been SKIPPED whenever unit shards went red — so the ratchet was passing trivially over ABSENT data. Fixing the shards on #7300 made coverage run and the ratchet finally had something to compare. --- .../maintenance/tighten-coverage-baseline.md | 1 + config/quality/quality-baseline.json | 24 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 changelog.d/maintenance/tighten-coverage-baseline.md diff --git a/changelog.d/maintenance/tighten-coverage-baseline.md b/changelog.d/maintenance/tighten-coverage-baseline.md new file mode 100644 index 0000000000..119eb1c856 --- /dev/null +++ b/changelog.d/maintenance/tighten-coverage-baseline.md @@ -0,0 +1 @@ +- **chore(quality):** tighten the coverage ratchet to the CI's real numbers (branches 73→78.1, statements/lines 76.5→80.8, functions 82→86.44, plus 7 per-module floors). The gate had been asking for this in plain text; the values come from the merged-coverage run on `main`, not a local run (local measures ~68% vs CI's ~80% — the baseline's own note warns about that gap). diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 225dd45f18..3e8cd63fe6 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -27,22 +27,22 @@ "eps": 0 }, "coverage.statements": { - "value": 76.5, + "value": 80.8, "direction": "up", "tightenSlack": 5 }, "coverage.lines": { - "value": 76.5, + "value": 80.8, "direction": "up", "tightenSlack": 5 }, "coverage.functions": { - "value": 82, + "value": 86.44, "direction": "up", "tightenSlack": 5 }, "coverage.branches": { - "value": 73, + "value": 78.1, "direction": "up", "eps": 1.5, "tightenSlack": 5 @@ -54,49 +54,49 @@ "tightenSlack": 10 }, "coverage.combo.lines": { - "value": 80, + "value": 85.42, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.accountFallback.lines": { - "value": 88, + "value": 96.78, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.auth.lines": { - "value": 90, + "value": 92.55, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.routeGuard.lines": { - "value": 94, + "value": 98.73, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.error.lines": { - "value": 88, + "value": 92.13, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.publicCreds.lines": { - "value": 92, + "value": 99.07, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.circuitBreaker.lines": { - "value": 92, + "value": 95.09, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "openapiCoverage.pct": { - "value": 38.0, + "value": 38, "direction": "up", "eps": 0.5, "_tighten_2026_07_04_v3844_release": "36.9 -> 39.3 (aperto exigido pelo --require-tighten no PR de release #5925). A cobertura OpenAPI melhorou no ciclo (9 rotas documentadas em 8fb020676 + as rotas novas de #5939/#5817/#6034/#5998 documentadas junto das features). 39.3 = valor medido pelo CI Quality Ratchet no run 28708141003 (tip 00c55afcb).", From 07e1011d3b8be31edbb7f7faf2620448dd89f05b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:49:22 -0300 Subject: [PATCH 09/33] fix(stream): reconcile encrypted Codex reasoning visibility without mutating upstream item (#7304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stream): reconcile encrypted Codex reasoning visibility without mutating upstream item Resolves the collision between two open PRs on ensureVisibleResponsesReasoningSummary: #7095 (xz-dev) found that chat clients see nothing when Codex exposes reasoning only as encrypted_content, and added a visible placeholder — but did so by mutating item.summary in place. #7176 (JxnLexn) found that same mutation corrupts the forwarded response item, discarding the encrypted_content shape Codex needs for follow-up requests, and removed the mutation — but that also silently dropped the placeholder, so chat clients went back to seeing nothing. The mutation existed only so a later line could read the summary text back off the same item. getVisibleResponsesReasoningSummaryText() computes that text without touching the item, so: - synthetic response.reasoning_summary_text.delta / .part.done events still carry the placeholder for chat clients (#7095's goal), and - the forwarded response.output_item.done payload keeps its original encrypted_content intact with no fabricated summary field (#7176's goal). Applied at both call sites #7095 identified: the native Responses passthrough in stream.ts/passthroughTailProcessor.ts, and the Responses-to-Chat-Completions translator in openai-responses.ts. Closes #7095, closes #7176. Co-authored-by: Xiangzhe Co-authored-by: Jan Leon * test(stream): guard the encrypted-reasoning mutation via the completed backfill path The output_item.done line is echoed verbatim on the wire, so a re-introduced item.summary mutation does NOT surface in that event — verified by re-injecting the mutation, which left the existing assertion green. The mutation does surface in the response.completed snapshot, where the captured reasoning item is re-serialized when upstream sends an empty output (store: false). Adds that case, which fails as expected when the mutation is re-introduced, making the #7176 half of the reconciliation an enforced regression guard rather than an incidental property of the current code path. Co-authored-by: Xiangzhe Co-authored-by: Jan Leon --------- Co-authored-by: Xiangzhe Co-authored-by: Jan Leon --- .../translator/response/openai-responses.ts | 8 +- .../response/openai-responses/pureHelpers.ts | 25 ++ open-sse/utils/passthroughTailProcessor.ts | 5 - open-sse/utils/stream.ts | 56 +--- .../codex-chat-reasoning-http-e2e.test.ts | 302 ++++++++++++++++++ tests/unit/stream-utilities.test.ts | 78 ++++- 6 files changed, 414 insertions(+), 60 deletions(-) create mode 100644 tests/integration/codex-chat-reasoning-http-e2e.test.ts diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 42fca56462..fdd214d06d 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -12,7 +12,7 @@ import { stripEmptyOptionalToolArgs, normalizeOutputIndex, normalizeUpstreamFailure, - extractResponsesReasoningSummaryText, + getVisibleResponsesReasoningSummaryText, } from "./openai-responses/pureHelpers.ts"; import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; @@ -1070,7 +1070,11 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { !(state.reasoningItemsWithDelta instanceof Set && state.reasoningItemsWithDelta.size > 0); if (emittedForItem || emittedWithoutItemId) return null; - const summaryText = extractResponsesReasoningSummaryText(item); + // #7095/#7176 reconciliation: computed WITHOUT mutating `item`, so an + // encrypted-only reasoning item (and its `encrypted_content`) is never + // rewritten with a fabricated `summary` — the placeholder only feeds this + // synthetic client-facing delta chunk. + const summaryText = getVisibleResponsesReasoningSummaryText(item); if (!summaryText) return null; return buildResponsesReasoningDeltaChunk(state, summaryText); } diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 094d7dc58d..50e9cfe6c6 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -163,3 +163,28 @@ export function extractResponsesReasoningSummaryText(item) { ) .join(""); } + +// #7095/#7176 — when Codex exposes a reasoning item only as encrypted private +// reasoning (no plaintext summary), chat clients would otherwise see nothing in +// their thinking panel. Reconciles two goals that used to be in tension: +// - #7095 wants a visible placeholder in the chat client. +// - #7176 wants the upstream response item left untouched, so `encrypted_content` +// (needed by Codex for subsequent requests) is never overwritten by a +// fabricated `summary`. +// This function computes the placeholder text WITHOUT mutating `item` — callers +// use the returned text for synthetic client-facing events only. +const ENCRYPTED_REASONING_PLACEHOLDER = + "Codex is reasoning, but the upstream Responses API exposed this reasoning block only as encrypted private reasoning. OmniRoute cannot recover the plaintext."; + +export function getVisibleResponsesReasoningSummaryText(item) { + const existingSummary = extractResponsesReasoningSummaryText(item); + if (existingSummary) return existingSummary; + + const hasEncryptedReasoning = + item && + item.type === "reasoning" && + typeof item.encrypted_content === "string" && + item.encrypted_content.length > 0; + + return hasEncryptedReasoning ? ENCRYPTED_REASONING_PLACEHOLDER : ""; +} diff --git a/open-sse/utils/passthroughTailProcessor.ts b/open-sse/utils/passthroughTailProcessor.ts index 82b57da8aa..845bc6e352 100644 --- a/open-sse/utils/passthroughTailProcessor.ts +++ b/open-sse/utils/passthroughTailProcessor.ts @@ -37,7 +37,6 @@ export type PassthroughTailProcessorContext = { appendPassthroughReasoning: (value: string) => void; getResponsesReasoningKey: (payload: Record) => string | null; markResponsesReasoningSummarySeen: (key: string) => void; - ensureVisibleResponsesReasoningSummary: (payload: Record) => boolean; emitSyntheticResponsesReasoningSummary: (payload: Record) => void; passthroughResponsesOutputItems: unknown[]; passthroughResponsesPendingFunctionCalls: Map; @@ -136,12 +135,8 @@ function handleResponsesTailPayload( } } if (parsed.type === "response.output_item.done" && parsed.item) { - const reasoningSummaryInjected = context.ensureVisibleResponsesReasoningSummary(parsed); context.emitSyntheticResponsesReasoningSummary(parsed); pushUniqueResponsesOutputItems(context.passthroughResponsesOutputItems, [parsed.item]); - if (reasoningSummaryInjected) { - output = `data: ${JSON.stringify(parsed)}\n\n`; - } const item = asRecord(parsed.item); if (item.type === "function_call") { const pendingKey = getFunctionCallPendingKey(item); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 581f1f6342..974d532e9e 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -52,6 +52,7 @@ import { stripResponsesLifecycleEcho, } from "./responsesStreamHelpers.ts"; import { processBufferedPassthroughLine } from "./passthroughTailProcessor.ts"; +import { getVisibleResponsesReasoningSummaryText } from "../translator/response/openai-responses/pureHelpers.ts"; import { getAnyReasoningValue, getReadableReasoningValue, @@ -1006,49 +1007,6 @@ export function createSSEStream(options: StreamOptions = {}) { return responseId !== null && outputIndex !== null ? `${responseId}:${outputIndex}` : null; }; - const getResponsesReasoningSummaryText = (item: Record): string => { - return Array.isArray(item.summary) - ? item.summary - .map((part) => { - if (!part || typeof part !== "object" || Array.isArray(part)) { - return ""; - } - return typeof (part as Record).text === "string" - ? ((part as Record).text as string) - : ""; - }) - .join("") - : ""; - }; - - const ensureVisibleResponsesReasoningSummary = (payload: Record): boolean => { - const item = - payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) - ? (payload.item as Record) - : null; - if (!item || item.type !== "reasoning") { - return false; - } - - if (getResponsesReasoningSummaryText(item)) { - return false; - } - - const hasEncryptedReasoning = - typeof item.encrypted_content === "string" && item.encrypted_content.length > 0; - if (!hasEncryptedReasoning) { - return false; - } - - item.summary = [ - { - type: "summary_text", - text: "Codex is reasoning, but the upstream Responses API exposed this reasoning block only as encrypted state. OmniRoute cannot recover the private reasoning text.", - }, - ]; - return true; - }; - const emitSyntheticResponsesReasoningSummary = ( controller: TransformStreamDefaultController, payload: Record @@ -1061,8 +1019,10 @@ export function createSSEStream(options: StreamOptions = {}) { return; } - ensureVisibleResponsesReasoningSummary(payload); - const visibleSummary = getResponsesReasoningSummaryText(item); + // #7095/#7176 reconciliation: compute the visible placeholder WITHOUT + // mutating `item` — the encrypted reasoning item (and its `encrypted_content`, + // required by Codex for subsequent requests) is forwarded to the client intact. + const visibleSummary = getVisibleResponsesReasoningSummaryText(item); if (!visibleSummary) { return; @@ -1485,13 +1445,8 @@ export function createSSEStream(options: StreamOptions = {}) { // response.completed snapshot can be backfilled when upstream // returns an empty `output` (happens with store: false). if (parsed.type === "response.output_item.done" && parsed.item) { - const reasoningSummaryInjected = ensureVisibleResponsesReasoningSummary(parsed); emitSyntheticResponsesReasoningSummary(controller, parsed); pushUniqueResponsesOutputItems(passthroughResponsesOutputItems, [parsed.item]); - if (reasoningSummaryInjected) { - output = `data: ${JSON.stringify(parsed)}\n\n`; - injectedUsage = true; - } if (parsed.item?.type === "function_call") { const pendingKey = typeof parsed.item.id === "string" @@ -2181,7 +2136,6 @@ export function createSSEStream(options: StreamOptions = {}) { markResponsesReasoningSummarySeen: (key: string) => { passthroughResponsesReasoningSummarySeen.add(key); }, - ensureVisibleResponsesReasoningSummary, emitSyntheticResponsesReasoningSummary: (payload: Record) => emitSyntheticResponsesReasoningSummary(controller, payload), passthroughResponsesOutputItems, diff --git a/tests/integration/codex-chat-reasoning-http-e2e.test.ts b/tests/integration/codex-chat-reasoning-http-e2e.test.ts new file mode 100644 index 0000000000..02b9fc9aae --- /dev/null +++ b/tests/integration/codex-chat-reasoning-http-e2e.test.ts @@ -0,0 +1,302 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { once } from "node:events"; + +const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; +const ENCRYPTED_CONTENT_SENTINEL = "encrypted-codex-state:" + "A".repeat(910); +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-chat-http-")); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = "codex-chat-http-e2e-secret-123456"; +process.env.REQUIRE_API_KEY = "false"; +process.env.OMNIROUTE_LOG_REQUEST_SHAPE = "0"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); + +const originalFetch = globalThis.fetch; + +type RecordedRequest = { + url: string; + method: string; + body: Record; +}; + +function responsesEvents() { + const response = { + id: "resp_reasoning_http", + object: "response", + status: "in_progress", + model: "gpt-5.6-sol", + output: [], + }; + return [ + { type: "response.created", response }, + { + type: "response.output_item.added", + output_index: 0, + item: { + id: "rs_reasoning_http", + type: "reasoning", + encrypted_content: ENCRYPTED_CONTENT_SENTINEL, + summary: [], + }, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + id: "rs_reasoning_http", + type: "reasoning", + encrypted_content: ENCRYPTED_CONTENT_SENTINEL, + summary: [], + }, + }, + { + type: "response.output_item.added", + output_index: 1, + item: { id: "msg_reasoning_http", type: "message", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + item_id: "msg_reasoning_http", + output_index: 1, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + item_id: "msg_reasoning_http", + output_index: 1, + content_index: 0, + delta: "The answer is 42.", + }, + { + type: "response.output_text.done", + item_id: "msg_reasoning_http", + output_index: 1, + content_index: 0, + text: "The answer is 42.", + }, + { + type: "response.output_item.done", + output_index: 1, + item: { + id: "msg_reasoning_http", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "The answer is 42.", annotations: [] }], + }, + }, + { + type: "response.completed", + response: { + ...response, + status: "completed", + output: [ + { + id: "rs_reasoning_http", + type: "reasoning", + summary: [{ type: "summary_text", text: "I checked the contract. " }], + }, + { + id: "msg_reasoning_http", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "The answer is 42.", annotations: [] }], + }, + ], + usage: { input_tokens: 8, output_tokens: 9, total_tokens: 17 }, + }, + }, + ]; +} + +function mockResponsesSse() { + const nativeFraming = process.env.CODEX_NATIVE_EVENT_FRAMING === "1"; + return responsesEvents() + .map((event) => { + const eventLine = nativeFraming ? `event: ${event.type}\n` : ""; + return `${eventLine}data: ${JSON.stringify(event)}\n\n`; + }) + .join(""); +} + +async function readIncomingBody(request: http.IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks); +} + +async function bridgeRouteResponse(response: Response, outgoing: http.ServerResponse) { + outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries())); + if (!response.body) { + outgoing.end(); + return; + } + + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!outgoing.write(value)) await once(outgoing, "drain"); + } + outgoing.end(); + } finally { + reader.releaseLock(); + } +} + +async function startRouteServer() { + const server = http.createServer(async (incoming, outgoing) => { + try { + if (incoming.method !== "POST" || incoming.url !== "/v1/chat/completions") { + outgoing.writeHead(404).end(); + return; + } + + const body = await readIncomingBody(incoming); + const address = server.address(); + assert(address && typeof address !== "string"); + const headers = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) { + if (Array.isArray(value)) value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, value); + } + const request = new Request(`http://127.0.0.1:${address.port}${incoming.url}`, { + method: incoming.method, + headers, + body, + }); + await bridgeRouteResponse(await chatRoute.POST(request), outgoing); + } catch (error) { + outgoing.writeHead(500, { "content-type": "text/plain" }); + outgoing.end(error instanceof Error ? error.stack : String(error)); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string"); + return { server, url: `http://127.0.0.1:${address.port}/v1/chat/completions` }; +} + +function parseSse(raw: string) { + return raw + .split(/\n\n+/) + .map((block) => + block + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice(6) + ) + .filter((data): data is string => Boolean(data)); +} + +async function closeServer(server: http.Server) { + if (!server.listening) return; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} + +test("chat completions streams Codex Responses reasoning through real route HTTP", async () => { + const recorded: RecordedRequest[] = []; + let routeServer: http.Server | undefined; + + try { + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-http-reasoning", + email: "codex-http@example.test", + accessToken: "mock-codex-access-token", + refreshToken: "mock-codex-refresh-token", + tokenType: "Bearer", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + const routeHarness = await startRouteServer(); + routeServer = routeHarness.server; + + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url !== CODEX_RESPONSES_URL) { + throw new Error(`Unexpected external fetch in Codex HTTP test: ${request.url}`); + } + recorded.push({ + url: request.url, + method: request.method, + body: JSON.parse(await request.text()) as Record, + }); + return new Response(mockResponsesSse(), { + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }); + }; + + const response = await originalFetch(routeHarness.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "codex/gpt-5.6-sol", + stream: true, + reasoning_effort: "high", + messages: [{ role: "user", content: "What is the answer?" }], + }), + }); + const raw = await response.text(); + + assert.equal(response.status, 200, raw); + assert.match(response.headers.get("content-type") ?? "", /^text\/event-stream/); + assert.equal(recorded.length, 1); + assert.equal(recorded[0].url, CODEX_RESPONSES_URL); + assert.equal(recorded[0].method, "POST"); + assert.deepEqual(recorded[0].body.reasoning, { effort: "high", summary: "auto" }); + assert.deepEqual(recorded[0].body.input, [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "What is the answer?" }], + }, + ]); + + const chunks = parseSse(raw); + assert.equal(chunks.at(-1), "[DONE]"); + const payloads = chunks.slice(0, -1).map((chunk) => JSON.parse(chunk)); + const reasoningContentDeltas = payloads + .map((payload) => payload.choices?.[0]?.delta?.reasoning_content) + .filter((content): content is string => Boolean(content)); + assert.equal(reasoningContentDeltas.length, 1); + const reasoningContent = reasoningContentDeltas.join(""); + assert.match(reasoningContent, /encrypted (?:state|private reasoning)/i); + assert(!raw.includes(ENCRYPTED_CONTENT_SENTINEL), raw); + assert(!reasoningContent.includes(ENCRYPTED_CONTENT_SENTINEL), reasoningContent); + assert( + payloads.some((payload) => payload.choices?.[0]?.delta?.content === "The answer is 42.") + ); + assert(!raw.includes("response.reasoning_summary_text.delta"), raw); + assert(!raw.includes('"type":"error"'), raw); + assert(!raw.includes('"error"'), raw); + } finally { + globalThis.fetch = originalFetch; + if (routeServer) await closeServer(routeServer); + core.closeDbInstance({ checkpointMode: null }); + await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/stream-utilities.test.ts b/tests/unit/stream-utilities.test.ts index 0f3404869c..33a1b2bdd7 100644 --- a/tests/unit/stream-utilities.test.ts +++ b/tests/unit/stream-utilities.test.ts @@ -133,7 +133,16 @@ test("createPassthroughStreamWithLogger synthesizes reasoning summary events fro assert.match(result, /event: response\.output_item\.done/); }); -test("createPassthroughStreamWithLogger shows a placeholder for encrypted reasoning items", async () => { +// Reconciles #7095 (xz-dev — chat clients never saw ANY signal that Codex was +// reasoning when the upstream Responses API exposed only encrypted private +// reasoning) with #7176 (JxnLexn — mutating `item.summary` with a fabricated +// placeholder corrupted the response item forwarded downstream, discarding the +// `encrypted_content` shape Codex needs for follow-up requests). Both goals are +// satisfied simultaneously: the client-facing synthetic delta/part events still +// carry the placeholder text, but the forwarded `response.output_item.done` +// payload is untouched — `encrypted_content` survives and no `summary` is +// fabricated onto the wire item. +test("createPassthroughStreamWithLogger shows a placeholder for encrypted reasoning items without mutating the forwarded item", async () => { const transform = createPassthroughStreamWithLogger( "codex", null, @@ -169,12 +178,77 @@ test("createPassthroughStreamWithLogger shows a placeholder for encrypted reason result += decoder.decode(value); } + // #7095: chat clients still see the placeholder via the synthetic events. assert.match(result, /event: response\.reasoning_summary_text\.delta/); assert.match(result, /Codex is reasoning/); - assert.match(result, /"summary":\[\{"type":"summary_text","text":"Codex is reasoning/); + + // #7176: the forwarded response.output_item.done payload is untouched — + // encrypted_content survives intact and no fabricated `summary` is present. + assert.match(result, /"encrypted_content":"enc_opaque_state"/); + assert.doesNotMatch(result, /"summary":/); assert.match(result, /event: response\.output_item\.done/); }); +// Companion guard for the test above. The output_item.done line is echoed +// verbatim, so a re-introduced `item.summary` mutation would NOT surface there. +// It does surface here: the reasoning item is captured into +// passthroughResponsesOutputItems and re-serialized into the response.completed +// snapshot when upstream sends an empty `output` (store: false). This is the +// path that actually fails if the mutation comes back — it is what makes the +// #7176 half of the reconciliation enforceable rather than incidental. +test("createPassthroughStreamWithLogger backfills completed output with encrypted reasoning unmutated", async () => { + const transform = createPassthroughStreamWithLogger( + "codex", + null, + null, + "gpt-5.5-low", + null, + null, + null, + null, + null, + "openai-responses" + ); + + const writer = transform.writable.getWriter(); + await writer.write( + new TextEncoder().encode( + [ + "event: response.output_item.done", + 'data: {"type":"response.output_item.done","response_id":"resp_reasoning_3","output_index":0,"item":{"id":"rs_resp_reasoning_3_0","type":"reasoning","encrypted_content":"enc_opaque_state"}}', + "", + "event: response.completed", + 'data: {"type":"response.completed","response":{"id":"resp_reasoning_3","model":"gpt-5.5-low","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}', + "", + ].join("\n") + ) + ); + await writer.close(); + + const reader = transform.readable.getReader(); + const decoder = new TextDecoder(); + let result = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value); + } + + // The placeholder still reached the client (#7095). + assert.match(result, /event: response\.reasoning_summary_text\.delta/); + assert.match(result, /Codex is reasoning/); + + // The item re-serialized into the completed snapshot carries the original + // encrypted_content and never a fabricated summary (#7176). + const completed = result + .split(/\n\n+/) + .find((block) => block.includes('"type":"response.completed"')); + assert.ok(completed, "expected a response.completed event on the wire"); + assert.match(completed, /"encrypted_content":"enc_opaque_state"/); + assert.doesNotMatch(completed, /"summary":/); +}); + test("createPassthroughStreamWithLogger backfills completed output from function_call arguments events", async () => { const transform = createPassthroughStreamWithLogger( "codex", From 865cfa0e87d74cb59732171f460b229d697bec56 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:49:29 -0300 Subject: [PATCH 10/33] =?UTF-8?q?chore(ci):=20stop=20dependabot=20proposin?= =?UTF-8?q?g=20typescript=20majors=20=E2=80=94=20peer-blocked=20by=20types?= =?UTF-8?q?cript-eslint=20(#7306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typescript-eslint pins a hard upper bound on its typescript peer (8.64.0 → ">=4.8.4 <6.1.0"). A major TS bump violates it, so the failure is not one check — it is the whole toolchain at once. #7068 is the demonstration: dependabot grouped typescript ^6→^7 with six harmless dev bumps (@types/node, eslint, fast-check, knip, prettier, typescript-eslint) and turned Build, Lint, Quality Ratchet, Unit (6/8, 8/8), Integration (1/2, 2/2) and dast-smoke red in a single PR. The six innocuous updates were blocked by the one that could never pass. Ignoring the major lets the rest of the group flow on its own. TS majors are a toolchain migration and deserve their own PR and their own CI run — not a weekly automated attempt that cannot succeed until typescript-eslint widens the peer. Refs #7068 --- .github/dependabot.yml | 9 +++++++++ .../7068-dependabot-ignore-typescript-major.md | 1 + 2 files changed, 10 insertions(+) create mode 100644 changelog.d/maintenance/7068-dependabot-ignore-typescript-major.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3c641330f1..0913d78cd0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,15 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "eslint-config-next" update-types: ["version-update:semver-major"] + # typescript majors are peer-blocked by typescript-eslint, which pins a hard + # upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7 + # bump therefore violates the peer and takes down the whole toolchain at once — + # #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet + # + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking + # the innocuous updates riding along with it. Un-ignore once typescript-eslint + # widens the peer, and migrate TS majors intentionally (own PR, own CI run). + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] # jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API). # scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it # parses jscpd-report.json against a frozen baseline). A v5 major would break diff --git a/changelog.d/maintenance/7068-dependabot-ignore-typescript-major.md b/changelog.d/maintenance/7068-dependabot-ignore-typescript-major.md new file mode 100644 index 0000000000..ae20696c5e --- /dev/null +++ b/changelog.d/maintenance/7068-dependabot-ignore-typescript-major.md @@ -0,0 +1 @@ +- **chore(ci):** stop dependabot from proposing `typescript` majors — `typescript-eslint` pins a hard peer upper bound (`>=4.8.4 <6.1.0`), so a TS 7 bump violates the peer and takes the whole toolchain red at once. #7068 grouped it with 6 harmless dev bumps and blocked all of them. TS majors now migrate intentionally, in their own PR. From 7f9dfd85f28997152e04dae0347126d0598bce94 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:49:36 -0300 Subject: [PATCH 11/33] test(dashboard): dedicated regression guard for #6815 density guarantee (#7291) * test(dashboard): dedicated regression guard for #6815 density guarantee Coverage for the #6815 multi-column density guarantee was only ever asserted incidentally, by two other guards (#7072, #3520) that pinned the literal sm:grid-cols-2 token. That coupling evaporated the coverage when PR #7027 migrated the component to a container-driven auto-fit template and the literal token was removed from both files. Adds a dedicated guard that simulates, from the shipped className, how many columns the per-group card grid renders at a wide container width -- supporting both the breakpoint-ladder and auto-fit mechanisms this component has shipped with -- and asserts >1 column, without asserting any specific Tailwind token. * chore(changelog): fragment for #7291 density guard --- ...7291-quota-card-grid-density-6815-guard.md | 1 + .../unit/quota-card-grid-density-6815.test.ts | 190 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 changelog.d/maintenance/7291-quota-card-grid-density-6815-guard.md create mode 100644 tests/unit/quota-card-grid-density-6815.test.ts diff --git a/changelog.d/maintenance/7291-quota-card-grid-density-6815-guard.md b/changelog.d/maintenance/7291-quota-card-grid-density-6815-guard.md new file mode 100644 index 0000000000..6652969e7d --- /dev/null +++ b/changelog.d/maintenance/7291-quota-card-grid-density-6815-guard.md @@ -0,0 +1 @@ +- **test(dashboard):** restore dedicated regression coverage for #6815's `QuotaCardGrid` multi-column density guarantee, decoupled from the specific Tailwind token so it survives the #7027 auto-fit migration ([#7291](https://github.com/diegosouzapw/OmniRoute/pull/7291)) diff --git a/tests/unit/quota-card-grid-density-6815.test.ts b/tests/unit/quota-card-grid-density-6815.test.ts new file mode 100644 index 0000000000..bafc3d6b3b --- /dev/null +++ b/tests/unit/quota-card-grid-density-6815.test.ts @@ -0,0 +1,190 @@ +// #6815 — Provider Quota page horizontal density. +// +// #6815 changed QuotaCardGrid.tsx's per-group card grid from a +// single-column-only layout (`flex flex-col`) to one that packs multiple +// QuotaCards side by side on wide screens, instead of stacking every card +// vertically no matter how much horizontal space is available. +// +// That guarantee was only ever asserted *incidentally*, by two other guards +// that pinned the literal Tailwind token the #6815 implementation happened +// to use at the time (`sm:grid-cols-2` in +// tests/unit/quota-card-grid-mobile-7072.test.ts and +// tests/unit/quota-card-grid-horizontal-layout.test.ts). When PR #7027 +// migrated the component from a fixed breakpoint ladder +// (`grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4`) to a +// container-driven auto-fit template +// (`grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))]`), that literal +// token disappeared from the source and both guards were edited to stop +// asserting it — silently deleting the only coverage #6815 had. +// +// This guard re-establishes dedicated coverage for the #6815 density +// guarantee itself, decoupled from *how* the component achieves it. Instead +// of matching a specific class-name token, it simulates, from the shipped +// className(s), how many columns the per-group card grid would actually +// render at a wide container width — supporting both mechanisms seen in this +// component's history (a Tailwind breakpoint ladder, and a CSS auto-fit +// `minmax()` template) — and asserts that count is >1. Reverting to a single +// unconditional column (`grid-cols-1` with no responsive/auto-fit variants, +// or dropping the grid entirely for `flex flex-col`) must fail this guard, +// regardless of which mechanism produced the regression. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const COMPONENT_PATH = path.resolve( + import.meta.dirname, + "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" +); + +/** + * Extract the string literal passed to `className={...}` (or `className="..."`) + * for every JSX `
` opening element in the component's source, in source + * order, via the TypeScript compiler API (not a hand-rolled regex — tracks + * the real AST so it can't be fooled by comments/whitespace). + */ +function extractDivClassNames(sourcePath: string): string[] { + const sourceText = fs.readFileSync(sourcePath, "utf8"); + const sourceFile = ts.createSourceFile( + sourcePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const classNames: string[] = []; + + function visit(node: ts.Node) { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const tagName = node.tagName.getText(sourceFile); + if (tagName === "div") { + for (const attr of node.attributes.properties) { + if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") { + const init = attr.initializer; + if (init && ts.isStringLiteral(init)) { + classNames.push(init.text); + } else if ( + init && + ts.isJsxExpression(init) && + init.expression && + ts.isStringLiteral(init.expression) + ) { + classNames.push(init.expression.text); + } + } + } + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return classNames; +} + +// Tailwind's default min-width breakpoints (px). Unprefixed utilities apply +// at every width (breakpoint 0) and later/larger breakpoints win the cascade +// once their min-width is met, mirroring Tailwind's mobile-first source order. +const TAILWIND_BREAKPOINTS: Record = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + "2xl": 1536, +}; + +type ColumnRule = + | { breakpoint: number; kind: "fixed"; columns: number } + | { breakpoint: number; kind: "autofit"; trackPx: number }; + +/** + * Parse every `grid-cols-*` utility (optionally breakpoint-prefixed) found in + * a className string into a column rule, supporting both mechanisms this + * component has shipped with: + * - a fixed count, e.g. `grid-cols-2`, `md:grid-cols-3` + * - a CSS auto-fit template, e.g. + * `grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))]`, from which + * the minimum track width (in px) is extracted. + */ +function parseColumnRules(className: string): ColumnRule[] { + const rules: ColumnRule[] = []; + for (const token of className.split(/\s+/).filter(Boolean)) { + const prefixMatch = token.match(/^(?:([a-zA-Z0-9]+):)?grid-cols-(.+)$/); + if (!prefixMatch) continue; + const [, prefix, rest] = prefixMatch; + const breakpoint = prefix ? (TAILWIND_BREAKPOINTS[prefix] ?? 0) : 0; + + if (/^\d+$/.test(rest)) { + rules.push({ breakpoint, kind: "fixed", columns: parseInt(rest, 10) }); + continue; + } + + const autoFitMatch = rest.match(/^\[repeat\(auto-fit,\s*minmax\((.+),\s*1fr\)\)\]$/); + if (autoFitMatch) { + const trackPxMatches = [...autoFitMatch[1].matchAll(/(\d+)px/g)]; + if (trackPxMatches.length > 0) { + const trackPx = parseInt(trackPxMatches[trackPxMatches.length - 1][1], 10); + rules.push({ breakpoint, kind: "autofit", trackPx }); + } + } + } + return rules; +} + +/** + * Given a className string, estimate how many columns the grid renders at a + * given container/viewport width, by picking the widest matching breakpoint + * rule (Tailwind cascade) and resolving fixed vs. auto-fit tracks. Returns 1 + * (single column) when no `grid-cols-*` rule is present at all — e.g. a + * `flex flex-col` layout. + */ +function estimateColumnsAtWidth(className: string, widthPx: number): number { + const rules = parseColumnRules(className).filter((r) => r.breakpoint <= widthPx); + if (rules.length === 0) return 1; + const active = rules.reduce((best, r) => (r.breakpoint >= best.breakpoint ? r : best)); + if (active.kind === "fixed") return active.columns; + return Math.max(1, Math.floor(widthPx / active.trackPx)); +} + +// --- Self-test of the estimator against known-good and known-bad shapes --- +// (independent of the real component, so the simulation logic itself is +// pinned before it's trusted to judge the shipped source below). + +test("#6815 density estimator — breakpoint ladder resolves to multiple columns on a wide viewport", () => { + const className = "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3"; + assert.equal(estimateColumnsAtWidth(className, 1200), 3); + assert.equal(estimateColumnsAtWidth(className, 375), 1); +}); + +test("#6815 density estimator — auto-fit template resolves to multiple columns on a wide container", () => { + const className = "grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3"; + assert.ok(estimateColumnsAtWidth(className, 1200) >= 2); + assert.equal(estimateColumnsAtWidth(className, 200), 1); +}); + +test("#6815 density estimator — single unconditional column stays at 1 column regardless of width", () => { + assert.equal(estimateColumnsAtWidth("grid grid-cols-1 gap-3", 1920), 1); +}); + +test("#6815 density estimator — flex column stack (no grid-cols) resolves to 1 column", () => { + assert.equal(estimateColumnsAtWidth("flex flex-col gap-3", 1920), 1); +}); + +// --- The actual regression guard, reading the shipped component source --- + +test("QuotaCardGrid (#6815) — per-group card grid renders multiple columns on a wide container", () => { + const classNames = extractDivClassNames(COMPONENT_PATH); + const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)); + assert.ok( + cardGridClassName, + "expected to find a grid-based per-group card grid className (not a single-column flex stack)" + ); + + const columnsOnWideContainer = estimateColumnsAtWidth(cardGridClassName!, 1200); + assert.ok( + columnsOnWideContainer > 1, + `expected the per-group card grid to render more than 1 column at 1200px, got ${columnsOnWideContainer} ` + + `from className="${cardGridClassName}" — this is the #6815 density regression` + ); +}); From 6b187a8939acfa64758a0e6357a369be0a61aa88 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:05:04 -0300 Subject: [PATCH 12/33] test(ci): mock route bridge surfaces error message, not raw stack (#7354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The E2E mock HTTP server's 500 catch sent error.stack straight to the response body, which CodeQL flags as js/stack-trace-exposure (medium). It's test-only localhost code, but the repo-wide CodeQL ratchet counts open alerts across all branches — so this one alert (baseline 0 → 1) turned the Quality Ratchet red on EVERY open PR into both main and release, masking whatever each PR actually changed. Surface error.message instead: clears the alert, keeps a useful signal for a failing mock route, and doesn't log to stderr (node:test native runner corrupts its report stream on console output). The test only asserts status 200, so the 500 body is not checked. Introduced by the #7304 integration test added this cycle. --- tests/integration/codex-chat-reasoning-http-e2e.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/codex-chat-reasoning-http-e2e.test.ts b/tests/integration/codex-chat-reasoning-http-e2e.test.ts index 02b9fc9aae..e5a03749ff 100644 --- a/tests/integration/codex-chat-reasoning-http-e2e.test.ts +++ b/tests/integration/codex-chat-reasoning-http-e2e.test.ts @@ -179,8 +179,9 @@ async function startRouteServer() { }); await bridgeRouteResponse(await chatRoute.POST(request), outgoing); } catch (error) { + // Mock route bridge: surface the message, never the raw stack (js/stack-trace-exposure). outgoing.writeHead(500, { "content-type": "text/plain" }); - outgoing.end(error instanceof Error ? error.stack : String(error)); + outgoing.end(error instanceof Error ? error.message : String(error)); } }); From 712323601262fe0fe630e0a318a546831277dcb9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:43:06 -0300 Subject: [PATCH 13/33] ci(release-green): add a main-green arm to detect when main goes red (#7355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-green workflow already reproduces the release-equivalent gate on release/** and opens a tracking issue on HARD failures — but main had no such watch. Under the parallel-cycle model main only receives merged work at the release squash, so a gate/infra fix that landed only on the release branch leaves main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines) turn EVERY PR into main red on a check unrelated to its diff. v3.8.49 hit this 3× in one night. Adds a dedicated main-green job (push to main + the same 3 crons + dispatch) that checks out main literally (no resolver, no injection surface), runs the same validate-release-green.mjs, and opens/updates a '🔴 main branch not green' issue pointing at the companion-PR fix. Gates the existing release-green job with an if: so a push to main doesn't re-validate release and vice-versa; schedule/dispatch sweep both. Detection backstop for the prevention rule in _shared/merge-gates.md §8. --- .github/workflows/nightly-release-green.yml | 102 +++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml index 9d1d20b565..63d1b4a054 100644 --- a/.github/workflows/nightly-release-green.yml +++ b/.github/workflows/nightly-release-green.yml @@ -22,7 +22,7 @@ name: Release-Green (continuous) on: push: - branches: ["release/v*"] + branches: ["release/v*", "main"] paths: - "src/**" - "open-sse/**" @@ -61,6 +61,9 @@ env: jobs: release-green: name: Validate active release branch + # On a push, only run for release/* pushes — a push to main is handled by the + # main-green job below. Schedule/dispatch always run (they validate the highest release). + if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }} # Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight) # 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. @@ -201,3 +204,100 @@ jobs: release-green.json release-green.log if-no-files-found: ignore + + # Companion arm for `main`. Under the parallel-cycle model, main only receives merged + # work at the release squash — so a gate/infra fix that lands only on release leaves + # main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines) + # turn EVERY PR into main red on a check unrelated to its diff. This detects that and + # opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex + # (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop. + main-green: + name: Validate main branch + # 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' }} + env: + JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-nightly-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + + - uses: ./.github/actions/npm-ci-retry + + - name: Main-green validation + id: validate + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set +e + # push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep. + if [ "$EVENT_NAME" = "push" ]; then + MODE="--quick" + else + MODE="--with-build --full-ci" + fi + echo "[main-green] mode: $MODE (event: $EVENT_NAME)" + # shellcheck disable=SC2086 — MODE is an intentional flag list + node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \ + 1> main-green.json 2> main-green.log + echo "exit=$?" >> "$GITHUB_OUTPUT" + echo "------- report -------" + cat main-green.log + + - name: Open / update tracking issue on HARD failure + if: steps.validate.outputs.exit != '0' + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + TITLE="🔴 main branch not green" + { + echo "The **main-green** validation found HARD failures on \`main\`." + echo "" + echo "Because \`main\` only receives merged work at the release squash, a gate/infra" + echo "fix that landed only on the release branch leaves \`main\` broken for the whole" + echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn" + echo "**every open PR into main** red on a check unrelated to its diff. The fix is a" + echo "companion PR \`--base main\` carrying the release-side fix (see" + echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR." + echo "" + echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})" + echo "" + echo '```' + sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log + echo '```' + echo "" + echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._" + } > issue-body.md + + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md + echo "Updated existing issue #$EXISTING" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md + fi + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: main-green-report + path: | + main-green.json + main-green.log + if-no-files-found: ignore From bc6cd2a8068a8f252e059e0e854d7464c6251872 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:55:21 -0300 Subject: [PATCH 14/33] fix(sse): sanitize non-ok Antigravity streaming error body (port from 9router#2461) (#7106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the STREAMING branch of AntigravityExecutor.executeOnce() had no !response.ok check at all — it unconditionally wrapped the upstream response body in a pass-through TransformStream, unlike the sibling non-streaming branch which already built a sanitized error via buildAntigravityUpstreamError. When Google's 403 error body was binary/non-UTF8 (observed: gzip-magic-byte payloads), those raw bytes were forwarded verbatim, corrupting the client-visible error message ('[ERROR] [403]: '). Fix: add the same !response.ok guard to the streaming branch, routing through buildAntigravityUpstreamError()/buildErrorBody() (hard rule #12) instead of piping unknown bytes through as if they were an SSE stream. Reported-by: Duongkhanhtool (https://github.com/decolua/9router/issues/2461) --- ...461-antigravity-streaming-403-raw-bytes.md | 1 + open-sse/executors/antigravity.ts | 28 +++++++++ ...treaming-error-body-sanitized-2461.test.ts | 63 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 changelog.d/fixes/2461-antigravity-streaming-403-raw-bytes.md create mode 100644 tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts diff --git a/changelog.d/fixes/2461-antigravity-streaming-403-raw-bytes.md b/changelog.d/fixes/2461-antigravity-streaming-403-raw-bytes.md new file mode 100644 index 0000000000..84cf50b3de --- /dev/null +++ b/changelog.d/fixes/2461-antigravity-streaming-403-raw-bytes.md @@ -0,0 +1 @@ +- **fix(sse):** Antigravity streaming requests that hit a non-ok upstream response (e.g. a 403) no longer pipe the raw upstream bytes straight through to the client — a binary/non-UTF8 error body (observed as gzip-magic-byte garbage) is now routed through the same sanitized `buildAntigravityUpstreamError()` path the non-streaming branch already used, instead of corrupting the client-visible error message. Regression guard: `tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts` — thanks @Duongkhanhtool diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 494d750836..49b1d8ff37 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -1591,6 +1591,34 @@ export class AntigravityExecutor extends BaseExecutor { }; } + // #2461: a non-ok upstream response (e.g. 403) must never be piped through the + // streaming pass-through below as if it were an SSE body. Google occasionally + // returns non-UTF8/binary error bodies (observed: gzip-magic-byte payloads) for + // 403s on this endpoint; reading/forwarding those raw bytes corrupts the + // client-visible error message. Mirror the non-streaming branch above and build + // a sanitized JSON error via buildAntigravityUpstreamError (hard rule #12) + // instead of streaming unknown bytes straight through. + if (!response.ok) { + const rawBody = await response + .clone() + .text() + .catch(() => ""); + const errorBody = buildAntigravityUpstreamError( + response.status, + response.statusText, + rawBody + ); + return { + response: new Response(JSON.stringify(errorBody), { + status: response.status, + headers: { "Content-Type": "application/json" }, + }), + url, + headers: finalHeaders, + transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + }; + } + // Streaming path: wrap the response body in a pass-through TransformStream // that extracts remainingCredits from the final SSE chunk(s) without // consuming the stream. The client receives the unmodified SSE data. diff --git a/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts b/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts new file mode 100644 index 0000000000..41d242c1f5 --- /dev/null +++ b/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts @@ -0,0 +1,63 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; +import { + clearAntigravityVersionCache, + seedAntigravityVersionCache, +} from "../../open-sse/services/antigravityVersion.ts"; + +// Ports decolua/9router#2461: a non-ok (e.g. 403) Antigravity upstream response in the +// STREAMING path was piped straight through to the client via a raw pass-through +// TransformStream, with no `response.ok` check at all — unlike the non-streaming path, +// which already builds a sanitized error via buildAntigravityUpstreamError. When the +// upstream 403 body is gzip-compressed (or otherwise binary/non-UTF8), those raw bytes +// end up surfaced verbatim in the client-visible error message, corrupting it (reporters +// saw literal control-byte garbage after "[ERROR] [403]:"). +test.afterEach(() => { + clearAntigravityVersionCache(); +}); + +test("AntigravityExecutor.execute (stream=true) sanitizes a non-ok upstream body instead of piping raw bytes", async () => { + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + seedAntigravityVersionCache("2026.04.17-test"); + + // Simulate a gzip-compressed 403 body (magic bytes 0x1f 0x8b), the exact shape + // reported upstream — reading it as text without decoding produces garbage. + const binaryBody = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x02, 0xff, 0x52, 0x41, 0x4e]); + + globalThis.fetch = async () => + new Response(binaryBody, { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + + try { + const result = await executor.execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [] } }, + stream: true, + credentials: { accessToken: "token", projectId: "project-1" }, + log: { debug() {}, warn() {} }, + }); + + assert.equal(result.response.status, 403); + + const bodyText = await result.response.text(); + + // The raw gzip magic bytes must never reach the client-visible error text. + assert.ok( + !bodyText.includes("\x1f\x8b"), + `expected sanitized error body, got raw bytes leaking through: ${JSON.stringify(bodyText)}` + ); + + // Must be routed through buildErrorBody()/buildAntigravityUpstreamError() — a clean, + // parseable JSON error shape (hard rule #12), not an arbitrary pass-through stream. + const parsed = JSON.parse(bodyText) as { error?: { message?: string } }; + assert.ok(parsed.error?.message, "expected a structured error.message"); + assert.match(parsed.error.message, /Antigravity upstream error \(403\)/); + } finally { + globalThis.fetch = originalFetch; + } +}); From df9808c0e42cde6be2c2bafe9508640c3661d20d Mon Sep 17 00:00:00 2001 From: Rafael Dias Zendron Date: Thu, 16 Jul 2026 14:12:10 -0300 Subject: [PATCH 15/33] fix(6954,6953): preserve system role + strip empty-signature thinking blocks (#6982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(6954,6953): preserve system role + strip empty-signature thinking blocks #6954 — System turns misattributed as assistant (claude-to-openai.ts:352) The ternary `msg.role === 'user' || msg.role === 'tool' ? 'user' : 'assistant'` mapped any non-user/non-tool role (including 'system') to 'assistant'. Mid-conversation system turns (Claude format) lost their role on translation to OpenAI format, causing them to be treated as assistant output. Fix: add explicit 'system' branch to the ternary. #6953 — Empty-signature thinking blocks poison Anthropic leg (openai-to-claude.ts) Non-Anthropic providers (codex/gpt-5.x) synthesize thinking blocks with signature:''\. On replay, the old code fabricated a DEFAULT_THINKING_CLAUDE_SIGNATURE to fill the empty signature — but Anthropic rejects foreign signatures with HTTP 400, permanently degrading combo/blend routes to codex-only. Fix: strip thinking blocks with empty/missing signatures and redacted_thinking blocks with empty/missing data entirely. They carry no replayable value. Tests: 8 new tests (4 per bug), all passing. Existing #5312 and #5945 regression tests still pass — no interference. * fix(6953): strip only signature:"" thinking blocks, preserve undefined signature CI caught a regression: translator-helper-branches test had a Claude-format thinking block without signature field (undefined) that was being stripped by the original fix. The fix was too aggressive — it stripped both signature:"" (non-Anthropic synthesized) and signature: undefined (legitimate Claude-format). Correct behavior: - signature === "" (empty string): strip — hallmark of codex/gpt-5.x block - signature === undefined: preserve with DEFAULT_THINKING_CLAUDE_SIGNATURE fallback - redacted_thinking data === "": strip - redacted_thinking data === undefined: preserve with fallback Added regression test for undefined-signature preservation. --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- .../translator/request/claude-to-openai.ts | 14 +- .../translator/request/openai-to-claude.ts | 19 +- .../claude-to-openai-system-role-6954.test.ts | 103 +++++++++ ...-claude-strip-empty-signature-6953.test.ts | 202 ++++++++++++++++++ 4 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 tests/unit/claude-to-openai-system-role-6954.test.ts create mode 100644 tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index 9b8b70f3c7..ab50607e75 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -349,7 +349,15 @@ function fixMissingToolResponses(messages) { // Convert single Claude message - returns single message or array of messages function convertClaudeMessage(msg, preserveCacheControl = false) { - const role = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; + // Preserve system role for mid-conversation system turns (#6954). + // Previously any role that wasn't "user" or "tool" was mapped to "assistant", + // which misattributed system messages as assistant output. + const role = + msg.role === "user" || msg.role === "tool" + ? "user" + : msg.role === "system" + ? "system" + : "assistant"; // Simple string content if (typeof msg.content === "string") { @@ -411,9 +419,7 @@ function convertClaudeMessage(msg, preserveCacheControl = false) { function: { name: block.name, arguments: - typeof block.input === "string" - ? block.input - : JSON.stringify(block.input || {}), + typeof block.input === "string" ? block.input : JSON.stringify(block.input || {}), }, }); break; diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index ff5dc4b6d5..1cfa99ddff 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -592,7 +592,24 @@ function getContentBlocksFromMessage( if (part.type === "text" && part.text) { blocks.push({ type: "text", text: part.text }); } else if (part.type === "thinking" || part.type === "redacted_thinking") { - // Preserve thinking blocks with signature + // #6953 — thinking blocks with signature:"" (empty string) come from non-Anthropic + // providers (codex/gpt-5.x). Anthropic rejects replayed `thinking` blocks that + // carry a foreign or fabricated signature with HTTP 400. Fabricating a default + // signature (the old behaviour) made the poisoning permanent: once a codex-served + // turn introduced a `signature:""` thinking block, every subsequent Anthropic leg + // attempt 400'd and the router silently fell back to codex forever. + // + // Fix: strip thinking blocks whose signature is the empty string — that explicit + // empty value is the hallmark of a synthesized block from a non-Anthropic provider. + // Thinking blocks with `signature: undefined` (field absent) are legitimate Claude- + // format messages and fall through to the DEFAULT_THINKING_CLAUDE_SIGNATURE fallback + // as before. + if (part.type === "thinking" && part.signature === "") { + continue; // drop — synthesized by non-Anthropic provider, no valid signature + } + if (part.type === "redacted_thinking" && part.data === "") { + continue; // drop — same: empty data from non-Anthropic provider + } blocks.push({ ...part, signature: part.signature || DEFAULT_THINKING_CLAUDE_SIGNATURE, diff --git a/tests/unit/claude-to-openai-system-role-6954.test.ts b/tests/unit/claude-to-openai-system-role-6954.test.ts new file mode 100644 index 0000000000..d9c48ac085 --- /dev/null +++ b/tests/unit/claude-to-openai-system-role-6954.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for #6954 — mid-conversation system turns misattributed as assistant. + * + * `convertClaudeMessage` mapped any role that wasn't "user" or "tool" to + * "assistant", so a Claude message with `role: "system"` (e.g. an injected + * system reminder mid-conversation) was forwarded to OpenAI-format upstreams + * as an assistant turn — polluting the conversation history. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToOpenAIRequest } = + await import("../../open-sse/translator/request/claude-to-openai.ts"); + +// --------------------------------------------------------------------------- +// 1. system message mid-conversation keeps role: "system" +// --------------------------------------------------------------------------- +test("mid-conversation system message preserves role:system (not assistant)", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + { role: "system", content: "Reminder: be concise." }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const roles = result.messages.map((m: { role: string }) => m.role); + assert.deepEqual(roles, ["user", "assistant", "system", "user"]); +}); + +// --------------------------------------------------------------------------- +// 2. system message with array content keeps role: "system" +// --------------------------------------------------------------------------- +test("system message with array content preserves role:system", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "system", + content: [{ type: "text", text: "System reminder text" }], + }, + ], + }, + false + ); + + const sysMsg = result.messages.find((m: { role: string }) => m.role === "system"); + assert.ok(sysMsg, "expected a system message in output"); + // Array content with text blocks is flattened to a string for system role + assert.equal( + typeof sysMsg.content === "string" ? sysMsg.content : JSON.stringify(sysMsg.content), + "System reminder text" + ); +}); + +// --------------------------------------------------------------------------- +// 3. top-level body.system still produces role: "system" (regression check) +// --------------------------------------------------------------------------- +test("body.system still produces role:system at index 0", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + system: "You are helpful.", + messages: [{ role: "user", content: "hi" }], + }, + false + ); + + assert.equal(result.messages[0].role, "system"); + assert.equal(result.messages[1].role, "user"); +}); + +// --------------------------------------------------------------------------- +// 4. assistant with tool_use still maps to assistant (regression check) +// --------------------------------------------------------------------------- +test("assistant role still maps to assistant (no regression)", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [ + { role: "user", content: "use the tool" }, + { + role: "assistant", + content: [ + { type: "text", text: "calling tool" }, + { type: "tool_use", id: "t1", name: "foo", input: {} }, + ], + }, + ], + }, + false + ); + + const roles = result.messages.map((m: { role: string }) => m.role); + assert.ok(roles.includes("assistant"), "assistant role must be preserved"); +}); diff --git a/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts b/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts new file mode 100644 index 0000000000..f129a64f70 --- /dev/null +++ b/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts @@ -0,0 +1,202 @@ +/** + * TDD regression for #6953 — thinking blocks with empty signatures poison the + * Anthropic leg of combo/blend routes. + * + * Non-Anthropic providers (codex/gpt-5.x) synthesize Anthropic-format `thinking` + * blocks with `signature: ""`. When the client replays these in the next + * request's history, the Anthropic leg rejects them with HTTP 400 "Invalid + * signature in thinking block", and the router silently falls back to codex + * permanently. + * + * The old code fabricated a DEFAULT_THINKING_CLAUDE_SIGNATURE to fill the empty + * signature — but that fabricated signature is equally foreign to Anthropic, so + * it also 400'd. + * + * Fix (#6953): strip thinking blocks with empty/missing signatures entirely. + * They carry no replayable cryptographic value. For `redacted_thinking`, strip + * if `data` is empty/missing for the same reason. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = + await import("../../open-sse/translator/request/openai-to-claude.ts"); + +test('#6953: thinking block with signature:"" is stripped, not fabricated', () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "I will help you." }, + { type: "thinking", thinking: "reasoning here", signature: "" }, + { + type: "text", + text: "Let me use a tool.", + }, + ], + }, + { role: "user", content: "ok go ahead" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + + // The thinking block with empty signature must be DROPPED, not preserved + // with a fabricated signature. + const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking"); + assert.equal( + thinkingBlocks.length, + 0, + "thinking block with empty signature must be stripped, not fabricated" + ); + + // Text blocks must survive + const textBlocks = assistant.content.filter((b) => b && b.type === "text"); + assert.ok(textBlocks.length >= 1, "text blocks must be preserved"); +}); + +test("#6953: thinking block with valid signature is preserved verbatim", () => { + const realSig = "EuY2xhdWRlLXNpZ25hdHVyZS0xNzA5..."; + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "real reasoning", signature: realSig }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking"); + assert.equal(thinkingBlocks.length, 1, "valid thinking block must be preserved"); + assert.equal(thinkingBlocks[0].signature, realSig, "valid signature must be preserved verbatim"); +}); + +test("#6953: thinking block with undefined signature (Claude-format) is preserved with fallback", () => { + // Claude-format messages may have thinking blocks without a signature field at all. + // These are legitimate and must NOT be stripped — only signature:"" (empty string) + // indicates a non-Anthropic synthesized block. + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I already have this" }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking"); + assert.equal( + thinkingBlocks.length, + 1, + "thinking block with undefined signature must be preserved" + ); + assert.equal(thinkingBlocks[0].thinking, "I already have this", "thinking content must match"); + assert.ok(thinkingBlocks[0].signature, "fallback signature must be applied"); +}); + +test("#6953: redacted_thinking with empty data is stripped", () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "redacted_thinking", data: "" }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + const redactedBlocks = assistant.content.filter((b) => b && b.type === "redacted_thinking"); + assert.equal(redactedBlocks.length, 0, "redacted_thinking with empty data must be stripped"); +}); + +test("#6953: combo scenario — codex-sourced thinking block does not block Anthropic leg", () => { + // Simulates a combo route: turn 1 served by codex produced a thinking block + // with signature:"". Turn 2 should be able to route to Anthropic without + // the poisoned block causing a 400. + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "write a function" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "**Reviewing the request**\n\nI need to write a function...", + signature: "", // codex-sourced, no real signature + }, + { type: "text", text: "Here's the function:" }, + { + type: "tool_use", + id: "toolu_01abc", + name: "write_file", + input: { path: "main.rs", content: "fn main() {}" }, + }, + ], + }, + { role: "user", content: "looks good, now add tests" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + // No thinking block with empty-string signature should survive + const badThinking = assistant.content.find( + (b) => b && b.type === "thinking" && b.signature === "" + ); + assert.equal( + badThinking, + undefined, + "no thinking block with empty-string signature should survive" + ); + + // Tool use must survive + const toolUse = assistant.content.find((b) => b && b.type === "tool_use"); + assert.ok(toolUse, "tool_use block must be preserved"); +}); From 994f1c78a09f4542c2f88d3ba007103a8fa2531c Mon Sep 17 00:00:00 2001 From: Rafael Dias Zendron Date: Thu, 16 Jul 2026 14:12:18 -0300 Subject: [PATCH 16/33] fix(6980): classify Cloudflare AI neuron exhaustion as quota_exhausted (#6983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare Workers AI free tier (10k Neurons/day, account-wide) returns 429 with body 'you have used up your daily free allocation of 10,000 neurons' which matched no QUOTA_PATTERNS keyword — falling through to rate_limit (~60s cooldown) instead of quota_exhausted. Two layers: 1. Provider-specific rule for 'cloudflare-ai' in providerRuleRegistry (scope: connection — budget is account-wide, not per-model) 2. Defense-in-depth: /daily free allocation/i in classify429 QUOTA_PATTERNS Tests: 11/11 pass (provider rule + classify429 paths covered). Closes #6980 Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/config/providerErrorRules.ts | 30 ++++- src/shared/utils/classify429.ts | 8 ++ ...oudflare-ai-neuron-exhaustion-6980.test.ts | 108 ++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts index d9eb14535b..dde56a53d2 100644 --- a/open-sse/config/providerErrorRules.ts +++ b/open-sse/config/providerErrorRules.ts @@ -130,6 +130,31 @@ function buildMinimaxRules(): ProviderErrorRule[] { ]; } +// ─── Cloudflare Workers AI ───────────────────────────────────────────────────── +// Free tier = 10,000 Neurons/day, shared across the WHOLE account +// (docs/reference/FREE_TIERS.md; official: developers.cloudflare.com/ +// workers-ai/platform/errors/). The exhaustion body doesn't match any +// QUOTA_PATTERNS keyword so it falls through to rate_limit and gets +// retried every ~60s against a budget that only resets at UTC midnight. +// Issue #6980. +function buildCloudflareAiRules(): ProviderErrorRule[] { + return [ + { + id: "cloudflare-ai-daily-neuron-allocation", + match: ({ status, body }) => { + if (status !== 429) return null; + const text = JSON.stringify(body ?? "").toLowerCase(); + // Body: "you have used up your daily free allocation of 10,000 neurons, + // please upgrade to Cloudflare's Workers Paid plan..." + if (!text.includes("daily free allocation")) return null; + // No cooldownMs: recordModelLockoutFailure already sets + // quota_exhausted without one to "next UTC midnight". + return { reason: "quota_exhausted", scope: "connection" }; + }, + }, + ]; +} + /** * Global registry. Provider name → ordered list of rules (first match wins). * Add new providers here; the matcher in classifyError will pick them up @@ -141,6 +166,7 @@ export const providerRuleRegistry = new Map([ ["opencode-cli", buildOpencodeRules()], ["minimax", buildMinimaxRules()], ["minimax-passthrough", buildMinimaxRules()], + ["cloudflare-ai", buildCloudflareAiRules()], ]); /** @@ -194,7 +220,9 @@ export function getProviderErrorRuleMatch( */ export function parseResetCountdownMs(text: string): number | null { if (typeof text !== "string" || text.length === 0) return null; - const match = text.match(/resets?\s+in\s+(\d+)\s+(day|days|hour|hours|minute|minutes|second|seconds)\b/); + const match = text.match( + /resets?\s+in\s+(\d+)\s+(day|days|hour|hours|minute|minutes|second|seconds)\b/ + ); if (!match) return null; const n = Number(match[1]); if (!Number.isFinite(n) || n <= 0) return null; diff --git a/src/shared/utils/classify429.ts b/src/shared/utils/classify429.ts index ff6f21ab31..a824679d3b 100644 --- a/src/shared/utils/classify429.ts +++ b/src/shared/utils/classify429.ts @@ -53,6 +53,14 @@ const QUOTA_PATTERNS: ReadonlyArray = [ /individual quota reached/i, /enable overages/i, /INSUFFICIENT_G1_CREDITS_BALANCE/i, + + // Cloudflare Workers AI daily neuron exhaustion (Issue #6980). + // Body: "you have used up your daily free allocation of 10,000 neurons, + // please upgrade to Cloudflare's Workers Paid plan..." + // No existing pattern matches "daily free allocation" — without this, + // the 429 is misclassified as transient rate_limit and retried every + // ~60s against a budget that only resets at UTC midnight. + /daily free allocation/i, ]; /** diff --git a/tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts b/tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts new file mode 100644 index 0000000000..4df5826c59 --- /dev/null +++ b/tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts @@ -0,0 +1,108 @@ +/** + * Issue #6980 — Cloudflare Workers AI daily neuron exhaustion 429 must be + * classified as quota_exhausted (not transient rate_limit). + * + * Two layers of defense: + * 1. Provider-specific rule in providerErrorRules.ts → getProviderErrorRuleMatch + * 2. Global QUOTA_PATTERNS in classify429.ts → looksLikeQuotaExhausted + * + * Without these, the 429 body "you have used up your daily free allocation of + * 10,000 neurons" matches no keyword, falls through to rate_limit (~60s cooldown), + * and the combo router keeps cycling through every cloudflare model on retry + * against a budget that only resets at UTC midnight. + */ + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + getProviderErrorRuleMatch, + providerRuleRegistry, +} from "../../open-sse/config/providerErrorRules.ts"; +import { classify429, looksLikeQuotaExhausted } from "../../src/shared/utils/classify429.ts"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const CF_NEURON_BODY = + "you have used up your daily free allocation of 10,000 neurons, please upgrade to Cloudflare's Workers Paid plan"; + +const CF_NEURON_BODY_JSON = { + errors: [ + { + code: 4006, + message: + "you have used up your daily free allocation of 10,000 neurons, please upgrade to Cloudflare's Workers Paid plan", + }, + ], +}; + +// ─── Tests: provider-specific rule (primary path) ─────────────────────────── + +describe("#6980 provider rule: cloudflare-ai neuron exhaustion", () => { + test("cloudflare-ai is registered in providerRuleRegistry", () => { + assert.ok(providerRuleRegistry.has("cloudflare-ai")); + }); + + test("429 with plain-string neuron body → quota_exhausted, scope connection", () => { + const result = getProviderErrorRuleMatch("cloudflare-ai", 429, {}, CF_NEURON_BODY); + assert.ok(result, "expected a match"); + assert.equal(result!.reason, "quota_exhausted"); + assert.equal(result!.scope, "connection"); + // No explicit cooldownMs — recordModelLockoutFailure resolves to next UTC midnight. + assert.equal(result!.cooldownMs, undefined); + }); + + test("429 with JSON-structured neuron body → quota_exhausted", () => { + const result = getProviderErrorRuleMatch("cloudflare-ai", 429, {}, CF_NEURON_BODY_JSON); + assert.ok(result); + assert.equal(result!.reason, "quota_exhausted"); + assert.equal(result!.scope, "connection"); + }); + + test("non-429 status does not match even with neuron body", () => { + const result = getProviderErrorRuleMatch("cloudflare-ai", 500, {}, CF_NEURON_BODY); + assert.equal(result, null); + }); + + test("429 with unrelated body does not match", () => { + const result = getProviderErrorRuleMatch( + "cloudflare-ai", + 429, + {}, + { + error: "rate limited, try again later", + } + ); + assert.equal(result, null); + }); + + test("provider name matching is case-insensitive", () => { + const result = getProviderErrorRuleMatch("Cloudflare-AI", 429, {}, CF_NEURON_BODY); + assert.ok(result); + assert.equal(result!.reason, "quota_exhausted"); + }); +}); + +// ─── Tests: classify429 defense-in-depth (fallback path) ──────────────────── + +describe("#6980 classify429: daily free allocation pattern", () => { + test("looksLikeQuotaExhausted matches neuron body string", () => { + assert.ok(looksLikeQuotaExhausted(CF_NEURON_BODY)); + }); + + test("looksLikeQuotaExhausted matches neuron body JSON-stringified", () => { + assert.ok(looksLikeQuotaExhausted(CF_NEURON_BODY_JSON)); + }); + + test("classify429 returns quota_exhausted for neuron body", () => { + assert.equal(classify429({ status: 429, body: CF_NEURON_BODY }), "quota_exhausted"); + }); + + test("classify429 returns quota_exhausted for neuron JSON body", () => { + assert.equal(classify429({ status: 429, body: CF_NEURON_BODY_JSON }), "quota_exhausted"); + }); + + test("classify429 returns rate_limit for generic 429 without quota keywords", () => { + assert.equal(classify429({ status: 429, body: "Too many requests" }), "rate_limit"); + }); +}); From 7724b31c99760273baca771ea49c4ddf6ea5be89 Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:12:25 +0800 Subject: [PATCH 17/33] fix(models): preserve chat-capable image model rows (#7004) Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- src/app/api/v1/models/catalog.ts | 15 ++++-- ...age-model-not-in-chat-catalog-6457.test.ts | 52 +++++++++++++++---- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 380827f11a..f4419da282 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -860,12 +860,17 @@ async function buildUnifiedModelsResponseCore( // #6457: some upstream discovery catalogs (e.g. HuggingFace's live // `/v1/models`) return image/diffusion models with no modality info, // so `endpoints` below would default to ["chat"] and misrepresent - // them as chat-capable. Skip any synced model that is already a - // registered image model for this provider — getAllImageModels() - // below adds the correctly-typed `type: "image"` entry instead. + // them as chat-capable. Skip a registered image model only when its + // synced metadata does not explicitly advertise a chat endpoint. + // Multi-capability models may intentionally share an id between the + // chat and image catalogs; getAllImageModels() adds the image entry. + const explicitlySupportsChat = sm.supportedEndpoints?.some( + (endpoint) => endpoint === "chat" || endpoint === "responses" + ); if ( - isRegisteredImageModel(canonicalProviderId, sm.id) || - isRegisteredImageModel(providerId, sm.id) + !explicitlySupportsChat && + (isRegisteredImageModel(canonicalProviderId, sm.id) || + isRegisteredImageModel(providerId, sm.id)) ) { continue; } diff --git a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts index cbd9b2033e..84187fe2f7 100644 --- a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts +++ b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts @@ -11,10 +11,10 @@ // `type: "image"` by the imageRegistry loop — and catalogDedupe.ts keys on // (id, type, subtype), so the two distinct-`type` entries both survived. // -// Fix: skip a synced model in the chat-catalog loop when it is already a registered -// image model for that exact provider (open-sse/config/imageRegistry.ts -// isRegisteredImageModel()) — the imageRegistry loop still adds the correctly-typed -// `type: "image"` entry. +// Fix: skip an exact-provider registered image model from the chat-catalog loop only +// when synced metadata does not explicitly advertise `chat` or `responses`. The image +// registry loop still adds the correctly typed image entry, while multi-capability +// models keep both entries. import test from "node:test"; import assert from "node:assert/strict"; @@ -38,6 +38,7 @@ async function resetStorage() { } test.beforeEach(async () => { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); await resetStorage(); }); @@ -46,19 +47,19 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -async function seedHuggingFaceConnection() { +async function seedProviderConnection(provider: string) { return providersDb.createProviderConnection({ - provider: "huggingface", + provider, authType: "apikey", - name: `huggingface-${Math.random().toString(16).slice(2, 8)}`, - apiKey: "hf-key", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: `${provider}-key`, isActive: true, testStatus: "active", }); } test("#6457 image/diffusion model discovered via live sync is NOT listed as a chat model", async () => { - const connection = await seedHuggingFaceConnection(); + const connection = await seedProviderConnection("huggingface"); // Simulate what HuggingFace's live `/v1/models` discovery persists for an // image/diffusion model: no supportedEndpoints/modality info at all — the exact @@ -100,3 +101,36 @@ test("#6457 image/diffusion model discovered via live sync is NOT listed as a ch assert.equal(entry.type, undefined, "the real chat model must not carry a non-chat type"); } }); + +test("registered image model with explicit chat endpoints keeps both catalog entries", async () => { + const connection = await seedProviderConnection("codex"); + + await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [ + { + id: "gpt-5.6-sol", + name: "GPT 5.6 Sol", + supportedEndpoints: ["responses"], + }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models?prefix=alias") + ); + assert.equal(response.status, 200); + + const body = (await response.json()) as { + data: Array<{ id: string; type?: string; supported_endpoints?: string[] }>; + }; + const entries = body.data.filter((model) => model.id.endsWith("/gpt-5.6-sol")); + + assert.ok( + entries.some( + (model) => model.type !== "image" && model.supported_endpoints?.includes("responses") + ), + "explicit responses support must keep the synced chat entry" + ); + assert.ok( + entries.some((model) => model.type === "image"), + "the registered image entry must remain available under the same model id" + ); +}); From 4bf859d34dedb9ed51015afdf3f085b8617db916 Mon Sep 17 00:00:00 2001 From: Ronaldo Davi Date: Thu, 16 Jul 2026 14:12:33 -0300 Subject: [PATCH 18/33] fix(sse): register ollama-cloud in USAGE_FETCHER_PROVIDERS (#7026) (#7041) Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/services/usage.ts | 1 + tests/unit/ollama-cloud-usage.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 39f8296082..1083748e9b 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -525,6 +525,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "zai", "glmt", "opencode-go", + "ollama-cloud", "minimax", "minimax-cn", "crof", diff --git a/tests/unit/ollama-cloud-usage.test.ts b/tests/unit/ollama-cloud-usage.test.ts index 9c54789911..2037426daa 100644 --- a/tests/unit/ollama-cloud-usage.test.ts +++ b/tests/unit/ollama-cloud-usage.test.ts @@ -11,6 +11,28 @@ test("USAGE_SUPPORTED_PROVIDERS includes ollama-cloud", () => { ); }); +test("USAGE_FETCHER_PROVIDERS includes ollama-cloud (#7026)", () => { + // getUsageForProvider's switch handles `case "ollama-cloud"`, and the array's doc comment + // requires it to stay in sync with that switch. If it drifts, registerGenericQuotaFetchers + // never registers a preflight quota fetcher for ollama-cloud even though the scraper exists. + assert.ok( + (usage.USAGE_FETCHER_PROVIDERS as readonly string[]).includes("ollama-cloud"), + "ollama-cloud is handled by getUsageForProvider's switch and must be listed in USAGE_FETCHER_PROVIDERS" + ); +}); + +test("registerGenericQuotaFetchers wires a preflight quota fetcher for ollama-cloud (#7026)", async () => { + const { registerGenericQuotaFetchers } = await import( + "../../open-sse/services/genericQuotaFetcher.ts" + ); + const { getQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); + registerGenericQuotaFetchers(); + assert.ok( + getQuotaFetcher("ollama-cloud"), + "a generic quota fetcher must be registered for ollama-cloud after registerGenericQuotaFetchers()" + ); +}); + test("getUsageForProvider returns helpful message when Ollama Cloud has no usage cookie", async () => { const originalCookie = process.env.OLLAMA_USAGE_COOKIE; const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; From 315eefcde4ac5db24172a5f81101455c4a8918b7 Mon Sep 17 00:00:00 2001 From: Ronaldo Davi Date: Thu, 16 Jul 2026 14:12:42 -0300 Subject: [PATCH 19/33] fix(quality): read cognitiveComplexity= machine line in validate-release-green (#7009) (#7042) Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- scripts/quality/validate-release-green.mjs | 9 ++++++++- tests/unit/validate-release-green.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 13ffdb8ff0..a14b80051d 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -126,7 +126,14 @@ export function parseEslintJson(out) { /** Pull the cognitive-complexity violation count from the gate's output. */ export function parseCognitiveCount(out) { - const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); + const s = String(out || ""); + // `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets, with the + // cyclomatic "N violações" summary emitted FIRST — so a bare `\d+ violações` regex would grab + // the cyclomatic count. Prefer the unambiguous machine-readable `cognitiveComplexity=N` line + // (mirrors the cyclomatic `complexity=N` parse used for cycCurrent below). + const machine = s.match(/(?:^|\n)cognitiveComplexity=(\d+)/); + if (machine) return Number(machine[1]); + const m = s.match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); return m ? Number(m[1]) : null; } diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index bf62fd8096..7626975264 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -42,6 +42,20 @@ test("parseCognitiveCount reads the gate's count (en + pt)", () => { assert.equal(parseCognitiveCount("no number"), null); }); +test("parseCognitiveCount ignores the cyclomatic count in the combined ratchets output (#7009)", () => { + // `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets. + // The cyclomatic "N violações" summary is emitted FIRST, so a bare `\\d+ violações` + // regex captured 2056 (cyclomatic) instead of 890 (cognitive) — a phantom drift in + // every pre-flight report. Prefer the unambiguous machine-readable `cognitiveComplexity=N`. + const combined = [ + "complexity=2056", + "cognitiveComplexity=890", + "[complexity] OK — 2056 violações (baseline 2056)", + "[cognitive-complexity] OK — 890 violações (baseline 890)", + ].join("\n"); + assert.equal(parseCognitiveCount(combined), 890); +}); + test("isDrift flags only growth past the committed baseline (down-direction ratchets)", () => { assert.equal(isDrift(3900, 3867), true); // grew → drift assert.equal(isDrift(3867, 3867), false); // equal → ok From ac61e28f44d7c3547e6779cba503ca0978b6d825 Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:12:49 -0700 Subject: [PATCH 20/33] fix(relay): bound Bifrost stream lifetime (#7093) Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- .../relay/chat/completions/bifrost/route.ts | 2 ++ .../api/v1/relay/chat/completions/route.ts | 20 +++++++++++++--- tests/unit/api/v1/bifrost-sidecar.test.ts | 15 +++++++----- .../unit/api/v1/relay-routing-backend.test.ts | 24 +++++++++++++++++++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/app/api/v1/relay/chat/completions/bifrost/route.ts b/src/app/api/v1/relay/chat/completions/bifrost/route.ts index b0df931b0e..31b007959f 100644 --- a/src/app/api/v1/relay/chat/completions/bifrost/route.ts +++ b/src/app/api/v1/relay/chat/completions/bifrost/route.ts @@ -260,6 +260,8 @@ export async function POST(request: Request) { "x-relay-client-ip": clientIp, ...getProviderPluginManifestHeader(new URL(request.url).origin), }; + const requestId = request.headers.get("x-request-id"); + if (requestId) upstreamHeaders["x-request-id"] = requestId; if (BIFROST_API_KEY) { upstreamHeaders["Authorization"] = `Bearer ${BIFROST_API_KEY}`; } diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 8ee4a25a2d..27cf3bf414 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -65,6 +65,7 @@ async function forwardToBifrost( body: unknown, token: RelayToken, config: BifrostRoutingConfig, + backend: ReturnType, startTime: number, clientIp: string, userAgent: string | null @@ -77,6 +78,8 @@ async function forwardToBifrost( "x-relay-client-ip": clientIp, ...getProviderPluginManifestHeader(new URL(request.url).origin), }; + const requestId = request.headers.get("x-request-id"); + if (requestId) upstreamHeaders["x-request-id"] = requestId; if (config.apiKey) { upstreamHeaders.Authorization = `Bearer ${config.apiKey}`; } @@ -95,7 +98,6 @@ async function forwardToBifrost( body: JSON.stringify(body), signal: ac.signal, }); - clearTimeout(tid); const headers = new Headers(upstream.headers); headers.set("X-Routed-By", "bifrost"); @@ -107,14 +109,24 @@ async function forwardToBifrost( if (wantsStream && upstream.body) { const stream = finalizeReadableStream(upstream.body, (error) => { + clearTimeout(tid); + const statusCode = timedOut ? 504 : upstream.status; + if (error && backend === "auto") { + recordBifrostFailure( + config.baseUrl, + timedOut + ? `Bifrost sidecar stream timed out after ${config.timeoutMs}ms` + : "bifrost-stream-error" + ); + } recordUsage( token.id, request, startTime, clientIp, userAgent, - error || upstream.status >= 500 ? "error" : "success", - upstream.status + error || statusCode >= 500 ? "error" : "success", + statusCode ); }); @@ -124,6 +136,7 @@ async function forwardToBifrost( }); } + clearTimeout(tid); recordUsage( token.id, request, @@ -313,6 +326,7 @@ export async function POST(request: Request) { parsedBody, token, bifrostConfig, + backend, startTime, clientIp, userAgent diff --git a/tests/unit/api/v1/bifrost-sidecar.test.ts b/tests/unit/api/v1/bifrost-sidecar.test.ts index 286e8bffcb..8e9f5a3c8f 100644 --- a/tests/unit/api/v1/bifrost-sidecar.test.ts +++ b/tests/unit/api/v1/bifrost-sidecar.test.ts @@ -79,9 +79,8 @@ test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unse delete process.env.BIFROST_STREAMING_ENABLED; // Dynamic import after env is set so the module reads the empty value. - const { POST } = await import( - "../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts" - ); + const { POST } = + await import("../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"); const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", { method: "POST", @@ -191,12 +190,14 @@ test("bifrost route: records relay usage after SSE stream completion", async () delete process.env.BIFROST_STREAMING_ENABLED; const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`); + let forwardedRequestId: string | null = null; - globalThis.fetch = async () => - new Response( + globalThis.fetch = async (_input, init) => { + forwardedRequestId = new Headers(init?.headers).get("x-request-id"); + return new Response( new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n")); + controller.enqueue(new TextEncoder().encode('data: {"delta":"hi"}\n\n')); controller.close(); }, }), @@ -205,6 +206,7 @@ test("bifrost route: records relay usage after SSE stream completion", async () headers: { "content-type": "text/event-stream" }, } ); + }; const { POST } = await import( `../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}` @@ -227,6 +229,7 @@ test("bifrost route: records relay usage after SSE stream completion", async () const res = await POST(req); assert.equal(res.status, 200); assert.equal(res.headers.get("X-Routed-By"), "bifrost"); + assert.equal(forwardedRequestId, "bifrost-sse-lifecycle-test"); assert.equal(getRelayLogs(relayToken.id, 10).length, 0); assert.match(await res.text(), /delta/); diff --git a/tests/unit/api/v1/relay-routing-backend.test.ts b/tests/unit/api/v1/relay-routing-backend.test.ts index 6e8e0e0aad..64cca27097 100644 --- a/tests/unit/api/v1/relay-routing-backend.test.ts +++ b/tests/unit/api/v1/relay-routing-backend.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { getBifrostRoutingConfig, getRoutingFallbackHeader, @@ -152,3 +153,26 @@ test("relay routing backend strict bifrost bypasses manifest eligibility", () => { tryBifrost: true } ); }); + +test("automatic relay keeps the Bifrost timeout active until an SSE stream finalizes", () => { + const routeSource = readFileSync( + new URL("../../../../src/app/api/v1/relay/chat/completions/route.ts", import.meta.url), + "utf8" + ); + const forwardToBifrost = routeSource.slice( + routeSource.indexOf("async function forwardToBifrost"), + routeSource.indexOf("export async function OPTIONS") + ); + const streamBranch = forwardToBifrost.slice( + forwardToBifrost.indexOf("if (wantsStream && upstream.body)"), + forwardToBifrost.indexOf("clearTimeout(tid);\n recordUsage(") + ); + + assert.match( + streamBranch, + /finalizeReadableStream\(upstream\.body, \(error\) => \{\s*clearTimeout\(tid\)/ + ); + assert.match(streamBranch, /const statusCode = timedOut \? 504 : upstream\.status/); + assert.match(streamBranch, /error && backend === "auto"/); + assert.match(streamBranch, /recordBifrostFailure\(/); +}); From f8ef562658e2a8d4dd46c994facef8809ade0fac Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:12:56 -0300 Subject: [PATCH 21/33] fix(sse): recognize xiaomi-tokenplan mimo as a thinking-mode model (#7098) * fix(sse): recognize xiaomi-tokenplan mimo as a thinking-mode model (port from 9router#1321) The reasoning_content injector already handles DeepSeek/Kimi/K2/MiniMax thinking-mode upstreams, echoing a placeholder reasoning_content on assistant turns that lack one. Its THINKING_MODEL_PATTERNS list omitted the xiaomi-tokenplan mimo family, so requests through xiaomi-tokenplan/mimo-v2.5-pro still hit upstream's 400 'reasoning_content in the thinking mode must be passed back to the API', making the model unusable in multi-turn conversations (e.g. Codex CLI). Add a /\bmimo\b/i pattern so mimo models get the same treatment. Reported-by: z.wl (@xxue-z) (https://github.com/decolua/9router/issues/1321) * docs(changelog): add fragment for #7098 mimo thinking-model fix --- ...8-mimo-thinking-model-reasoning-content.md | 1 + open-sse/utils/reasoningContentInjector.ts | 4 +- tests/unit/reasoningContentInjector.test.ts | 44 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md create mode 100644 tests/unit/reasoningContentInjector.test.ts diff --git a/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md b/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md new file mode 100644 index 0000000000..ddd454f252 --- /dev/null +++ b/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md @@ -0,0 +1 @@ +- **fix(sse):** xiaomi-tokenplan `mimo` models (e.g. `mimo-v2.5-pro`) are now recognized as thinking-mode upstreams that require `reasoning_content` echoed back on every assistant turn, fixing a persistent `400 reasoning_content must be passed back` error on multi-turn conversations ([#7098](https://github.com/diegosouzapw/OmniRoute/pull/7098)) — thanks @xxue-z diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 8fd9b13bd4..c2e8318be4 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -1,5 +1,6 @@ /** - * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...) require + * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, xiaomi-tokenplan + * mimo, ...) require * `reasoning_content` to be echoed back on every assistant message in the * conversation history. Standard OpenAI clients do not preserve that field * across turns, so we inject a non-empty placeholder before forwarding. @@ -26,6 +27,7 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [ /\bkimi\b/i, /\bk2\b/i, // moonshot kimi k2 family alias /\bminimax\b/i, + /\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro) ]; export function isThinkingMessageModel(model: string | undefined | null): boolean { diff --git a/tests/unit/reasoningContentInjector.test.ts b/tests/unit/reasoningContentInjector.test.ts new file mode 100644 index 0000000000..134843e774 --- /dev/null +++ b/tests/unit/reasoningContentInjector.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + isThinkingMessageModel, + injectReasoningContentForThinkingModel, +} from "../../open-sse/utils/reasoningContentInjector.ts"; + +describe("reasoningContentInjector — xiaomi-tokenplan mimo family (9router#1321)", () => { + it("recognizes xiaomi-tokenplan/mimo-v2.5-pro as a thinking-mode model", () => { + assert.equal(isThinkingMessageModel("xiaomi-tokenplan/mimo-v2.5-pro"), true); + }); + + it("recognizes bare mimo model ids as thinking-mode models", () => { + assert.equal(isThinkingMessageModel("mimo-v2.5-pro"), true); + }); + + it("still recognizes the existing thinking-mode families (deepseek/kimi/k2/minimax)", () => { + assert.equal(isThinkingMessageModel("deepseek-v4-flash"), true); + assert.equal(isThinkingMessageModel("kimi-k2"), true); + assert.equal(isThinkingMessageModel("minimax-m2"), true); + }); + + it("does not flag unrelated model ids", () => { + assert.equal(isThinkingMessageModel("gpt-4o"), false); + }); + + it("injects a reasoning_content placeholder for assistant messages when routed to mimo", () => { + const body = { + model: "xiaomi-tokenplan/mimo-v2.5-pro", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ], + }; + + // Simulate the executor gate: only inject when the model is a thinking model. + assert.equal(isThinkingMessageModel(body.model), true); + + const result = injectReasoningContentForThinkingModel(body) as typeof body; + const assistantMsg = result.messages[1] as Record; + assert.equal(assistantMsg.reasoning_content, " "); + }); +}); From fdabec6e595b376e311dac7b73fa00f475533d04 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:03 -0300 Subject: [PATCH 22/33] fix(codex): strip regex lookaround from tool schema patterns (#7100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): strip regex lookaround from tool schema patterns (port from 9router#1556) Codex/OpenAI's Responses API rejects JSON Schema pattern fields using regex lookaround (e.g. ^(?=.*@).+$) with a 400 'regex lookaround is not supported' error. The existing numeric-field sanitizer (coerceSchemaNumericFields) was only wired into the translated-request path (openai-to-claude.ts), not the native codex/openai passthrough path (normalizeCodexTools in open-sse/executors/codex/tools.ts), so lookahead/lookbehind patterns reached upstream unmodified and broke tool calls for clients that emit them (e.g. IDE agent harnesses validating an email field). Reported-by: evin (@evinjohnn) (https://github.com/decolua/9router/issues/1556) * chore(changelog): move #1556 entry to changelog.d fragment Consistency with the repo's canonical changelog.d/fixes/ workflow (avoids merge-storm re-conflicts from editing CHANGELOG.md directly). * refactor(codex): table-drive the regex-strip recursion to keep the complexity ratchet at baseline The #1556 lookaround strip walked every sub-schema field with its own copy-pasted if-block (properties / patternProperties / definitions / $defs, then prefixItems / anyOf / oneOf / allOf), pushing stripUnsupportedRegexPatterns past the cyclomatic threshold and check:complexity to 2057 > baseline 2056. Collapse the eight near-identical blocks into two loops over the field-name constants, with the object-map recursion factored into a helper. Same fields, same traversal order, same behavior — complexity is back at baseline 2056 and the #1556 regression tests still pass. --- .../fixes/1556-openai-regex-lookaround.md | 1 + open-sse/executors/codex/tools.ts | 9 ++- open-sse/translator/helpers/schemaCoercion.ts | 81 +++++++++++++++++++ .../unit/codex-tools-regex-lookaround.test.ts | 74 +++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/1556-openai-regex-lookaround.md create mode 100644 tests/unit/codex-tools-regex-lookaround.test.ts diff --git a/changelog.d/fixes/1556-openai-regex-lookaround.md b/changelog.d/fixes/1556-openai-regex-lookaround.md new file mode 100644 index 0000000000..7045d0917d --- /dev/null +++ b/changelog.d/fixes/1556-openai-regex-lookaround.md @@ -0,0 +1 @@ +- **fix(codex):** strip regex `pattern` lookaround (lookahead/lookbehind) from tool JSON Schemas on the Codex/OpenAI native passthrough path — previously only the translated-request path coerced tool schemas, so a `pattern` like `^(?=.*@).+$` reached OpenAI unmodified and was rejected with `regex lookaround is not supported`. (thanks @evinjohnn) (#7100) diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts index 3337000359..52d01e9d87 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -1,6 +1,8 @@ // Codex Responses-API tool normalization (hosted-tool passthrough + free-plan gating). // Extracted verbatim from codex.ts. Self-contained (console.debug only). +import { stripUnsupportedRegexPatterns } from "../../translator/helpers/schemaCoercion.ts"; + // Responses-API hosted tool types that OpenAI/Codex executes server-side. // These arrive shaped as `{ type, ...params }` with no `function` object and no `name` — // e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or @@ -133,6 +135,11 @@ export function normalizeCodexTools( ? functionObject.strict : undefined; + // Codex/OpenAI Responses API rejects `pattern` fields using regex lookaround + // (e.g. `^(?=.*@).+$`) with a 400 "regex lookaround is not supported" error. + // Strip those before the schema reaches upstream (9router#1556). + const sanitizedParameters = stripUnsupportedRegexPatterns(parameters); + // Rewrite in-place to Responses format for (const key of Object.keys(tool)) { delete tool[key]; @@ -140,7 +147,7 @@ export function normalizeCodexTools( tool.type = "function"; tool.name = name.slice(0, 128); if (description) tool.description = description; - tool.parameters = parameters; + tool.parameters = sanitizedParameters; if (strict !== undefined) tool.strict = strict; validToolNames.add(name); diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 9be3d930b6..60ecfa44a7 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -24,6 +24,18 @@ const NUMERIC_SCHEMA_FIELDS = [ "multipleOf", ] as const; +// Fix (9router#1556): OpenAI/Codex's Responses API rejects JSON Schema `pattern` +// values that use regex lookaround (lookahead/lookbehind) with +// "Invalid JSON schema: regex lookaround is not supported.". IDE/SDK agent +// harnesses commonly emit lookahead patterns (e.g. `^(?=.*@).+$`), so any +// `pattern` field containing `(?=`, `(?!`, `(?<=`, or `(? [key, stripUnsupportedRegexPatterns(value)]) + ); +} + +/** + * Strip regex `pattern` constraints that use lookaround (lookahead/lookbehind), + * which OpenAI/Codex's Responses API rejects outright with a 400 + * ("Invalid JSON schema: regex lookaround is not supported."). Walks the same + * JSON Schema shape as `coerceSchemaNumericFields` (properties, items, + * anyOf/oneOf/allOf, $defs/definitions, etc). See 9router#1556. + */ +export function stripUnsupportedRegexPatterns(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => stripUnsupportedRegexPatterns(entry)); + } + if (!isPlainObject(schema)) return schema; + + const result: JsonRecord = { ...schema }; + + if (hasUnsupportedRegexLookaround(result.pattern)) { + delete result.pattern; + } + + for (const field of REGEX_STRIP_OBJECT_MAP_FIELDS) { + if (isPlainObject(result[field])) { + result[field] = stripRegexFromObjectMap(result[field]); + } + } + + for (const field of REGEX_STRIP_ARRAY_MAP_FIELDS) { + if (Array.isArray(result[field])) { + result[field] = (result[field] as unknown[]).map((entry) => + stripUnsupportedRegexPatterns(entry) + ); + } + } + + if (result.items !== undefined) { + result.items = stripUnsupportedRegexPatterns(result.items); + } + if (result.additionalProperties && typeof result.additionalProperties === "object") { + result.additionalProperties = stripUnsupportedRegexPatterns(result.additionalProperties); + } + if (isPlainObject(result.not)) { + result.not = stripUnsupportedRegexPatterns(result.not); + } + + return result; +} + export function sanitizeToolDescription(tool: unknown): unknown { if (!isPlainObject(tool)) return tool; diff --git a/tests/unit/codex-tools-regex-lookaround.test.ts b/tests/unit/codex-tools-regex-lookaround.test.ts new file mode 100644 index 0000000000..4778ec7ac7 --- /dev/null +++ b/tests/unit/codex-tools-regex-lookaround.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeCodexTools } from "../../open-sse/executors/codex/tools.ts"; + +// Port of 9router#1556: OpenAI/Codex Responses API rejects JSON Schema `pattern` +// fields containing regex lookaround (lookahead/lookbehind) with: +// "Invalid JSON schema: regex lookaround is not supported. Found at $.properties.email.pattern." +// Clients (e.g. IDE agent harnesses) commonly emit lookahead patterns such as +// `^(?=.*@).+$` for "must contain an @". These must be stripped before the +// tool schema reaches the Codex/OpenAI Responses API. +test("normalizeCodexTools strips regex lookaround from function tool parameter patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + description: "Send an email", + parameters: { + type: "object", + properties: { + email: { + type: "string", + pattern: "^(?=.*@).+$", + }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const emailSchema = properties.email as Record; + + assert.equal( + emailSchema.pattern, + undefined, + "lookaround pattern must be stripped, not forwarded upstream" + ); +}); + +test("normalizeCodexTools preserves plain (non-lookaround) regex patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + parameters: { + type: "object", + properties: { + zip: { type: "string", pattern: "^[0-9]{5}$" }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const zipSchema = properties.zip as Record; + + assert.equal(zipSchema.pattern, "^[0-9]{5}$"); +}); From fd2aaff9209e966478c6e7b8aa2949a2ee6819ad Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:11 -0300 Subject: [PATCH 23/33] fix(compression): Headroom SmartCrusher skips developer-role messages (port from 9router#2132) (#7102) Root cause: SmartCrusher's system-message guard only excluded role === "system", but Codex CLI (open-sse/executors/codex.ts) sends its instructions/tool-schema turn with role "developer" (the Responses-API equivalent of system used by newer models). Every other system-exclusion guard in this codebase also covers developer (roleNormalizer.ts, contextManager.ts, claudeUpstreamMessages.ts, etc.) except this one, so Headroom happily tabular-compacted JSON arrays embedded in the developer turn (e.g. an update_plan tool schema example), corrupting the instructions the model needs to call the plan tool and breaking Codex CLI plan mode. Fix: extend the guard in crushMessages()/collectCompactableArrays() (smartcrusher.ts) to skip role === "developer" alongside role === "system". Reported-by: SingCJ (https://github.com/decolua/9router/issues/2132) --- .../port-2132-headroom-developer-role.md | 1 + .../engines/headroom/smartcrusher.ts | 10 +- .../headroom-developer-role-2132.test.ts | 91 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/port-2132-headroom-developer-role.md create mode 100644 tests/unit/compression/headroom-developer-role-2132.test.ts diff --git a/changelog.d/fixes/port-2132-headroom-developer-role.md b/changelog.d/fixes/port-2132-headroom-developer-role.md new file mode 100644 index 0000000000..c9064e43c5 --- /dev/null +++ b/changelog.d/fixes/port-2132-headroom-developer-role.md @@ -0,0 +1 @@ +- **fix(compression):** the Headroom SmartCrusher tabular-compaction guard now also skips `role: "developer"` messages, not just `role: "system"` — Codex CLI sends its instructions/tool-schema turn as `developer` (the Responses-API equivalent of `system`), so an embedded JSON array (e.g. an `update_plan` example) could get tabular-compacted, corrupting the model's tool-calling instructions and breaking Codex CLI plan mode. (thanks @SingCJ) diff --git a/open-sse/services/compression/engines/headroom/smartcrusher.ts b/open-sse/services/compression/engines/headroom/smartcrusher.ts index c40ea82928..a14c851b6b 100644 --- a/open-sse/services/compression/engines/headroom/smartcrusher.ts +++ b/open-sse/services/compression/engines/headroom/smartcrusher.ts @@ -160,7 +160,7 @@ export function collectCompactableArrays( while ((m = regex.exec(text)) !== null) pushIfCompactable(m[1].trim()); }; for (const msg of messages) { - if (msg.role === "system") continue; + if (msg.role === "system" || msg.role === "developer") continue; if (typeof msg.content === "string") scanText(msg.content); else if (Array.isArray(msg.content)) { for (const part of msg.content) { @@ -218,8 +218,12 @@ export function crushMessages( let changed = false; const result = messages.map((msg): MessageLike => { - // Guard: never touch system messages - if (msg.role === "system") return { ...msg }; + // Guard: never touch system messages. "developer" is the Responses-API equivalent of + // "system" used by newer models (e.g. Codex CLI, see open-sse/executors/codex.ts) and + // carries the same kind of instructions/tool-schema content — compacting a JSON array + // embedded there (e.g. an update_plan example) can corrupt the model's tool-calling + // instructions (9router#2132: broke Codex CLI plan mode). + if (msg.role === "system" || msg.role === "developer") return { ...msg }; if (typeof msg.content === "string") { const crushed = crushText(msg.content, minRows); diff --git a/tests/unit/compression/headroom-developer-role-2132.test.ts b/tests/unit/compression/headroom-developer-role-2132.test.ts new file mode 100644 index 0000000000..5f797d3f1a --- /dev/null +++ b/tests/unit/compression/headroom-developer-role-2132.test.ts @@ -0,0 +1,91 @@ +/** + * Regression test for upstream 9router#2132 (ported): "Token saver Headroom ruins plan mode + * in Codex CLI". + * + * Root cause: SmartCrusher's system-message guard only checked `role === "system"`. Codex CLI + * (open-sse/executors/codex.ts) sends its instructions/tool-schema turn with role "developer" + * (the Responses-API equivalent of "system" used by newer models). Every other guard in this + * codebase that excludes "system" also excludes "developer" (see roleNormalizer.ts, + * contextManager.ts, claudeUpstreamMessages.ts, etc.) — SmartCrusher was the exception, so it + * happily tabular-compacted JSON arrays (e.g. the update_plan tool schema/examples) embedded in + * the developer-role turn, corrupting the instructions the model needs to call the plan tool. + */ + +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let crushMessages: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").crushMessages; +let collectCompactableArrays: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").collectCompactableArrays; +let headroomEngine: import("../../../open-sse/services/compression/engines/headroom/index.ts").headroomEngine; + +before(async () => { + const mod = await import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts"); + crushMessages = mod.crushMessages; + collectCompactableArrays = mod.collectCompactableArrays; + + const engineMod = await import("../../../open-sse/services/compression/engines/headroom/index.ts"); + headroomEngine = engineMod.headroomEngine; +}); + +/** A homogeneous array big enough (>= default minRows=8) to trigger compaction. */ +function makePlanSchemaExample(): Record[] { + return Array.from({ length: 10 }, (_, i) => ({ + step: `step-${i + 1}`, + status: i === 0 ? "in_progress" : "pending", + })); +} + +describe("headroom SmartCrusher — developer-role guard (9router#2132)", () => { + it("does NOT compact JSON arrays embedded in a developer-role message (crushMessages)", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [ + { + role: "developer", + content: `Use the update_plan tool. Example plan:\n\`\`\`json\n${json}\n\`\`\``, + }, + { role: "user", content: "Refactor the auth module." }, + ]; + + const { messages: result, changed } = crushMessages(messages, 8); + + assert.equal(changed, false, "developer-role content must not be touched"); + assert.equal(result[0].content, messages[0].content); + }); + + it("still compacts the same payload when placed under role: system (control case)", () => { + // Sanity check: this proves the array itself WOULD be compactable — the guard, not the + // shape of the payload, is what must change. + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [{ role: "user", content: `\`\`\`json\n${json}\n\`\`\`` }]; + + const { changed } = crushMessages(messages, 8); + assert.equal(changed, true, "control case: user-role content of the same shape IS compacted"); + }); + + it("collectCompactableArrays does not surface arrays from developer-role messages", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [ + { role: "developer", content: `\`\`\`json\n${json}\n\`\`\`` }, + ]; + const found = collectCompactableArrays(messages, 8); + assert.equal(found.length, 0); + }); + + it("headroomEngine.apply leaves a Codex-CLI-shaped developer turn untouched end-to-end", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const body: Record = { + model: "gpt-5-codex", + messages: [ + { + role: "developer", + content: `Instructions with an embedded schema example:\n\`\`\`json\n${json}\n\`\`\``, + }, + { role: "user", content: "Implement the feature." }, + ], + }; + + const result = headroomEngine.apply(body); + assert.equal(result.compressed, false); + assert.deepEqual(result.body, body); + }); +}); From 39222525ea69e7f219825924c8f4e508126feacf Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:18 -0300 Subject: [PATCH 24/33] fix(providers): surface a warning on 404 model_not_found in OpenAI-compatible Check (port from 9router#2032) (#7103) Root cause: validateOpenAICompatibleProvider's chat-completions probe fallback treated ANY 4xx other than 401/403/429/400 as a silent 'credentials valid' pass with no warning, so a bogus/non-standard model id (e.g. Featherless/OpenRouter vendor/model typos) went undetected at Check time. The first real request then hit the upstream 404 model_not_found and the per-model lockout, holding the model unavailable for the configured reset window with no prior indication anything was wrong. User-visible effect: 'Check' now returns valid:true with an explicit warning (including the upstream error message when parseable) whenever the chat probe answers 404, so a bad model id is caught before it reaches production traffic and the lockout. Reported-by: advane204f (https://github.com/decolua/9router/issues/2032) --- ...032-openai-compatible-check-404-warning.md | 1 + src/lib/providers/validation/openaiFormat.ts | 25 ++++++++++ ...ovider-validation-modelid-fallback.test.ts | 47 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 changelog.d/fixes/2032-openai-compatible-check-404-warning.md diff --git a/changelog.d/fixes/2032-openai-compatible-check-404-warning.md b/changelog.d/fixes/2032-openai-compatible-check-404-warning.md new file mode 100644 index 0000000000..5933bf065f --- /dev/null +++ b/changelog.d/fixes/2032-openai-compatible-check-404-warning.md @@ -0,0 +1 @@ +- **fix(providers):** the OpenAI-compatible "Check" validation flow now surfaces a warning when the chat-completions probe returns `404` (e.g. `model_not_found`) instead of silently passing as `Valid` — a bogus/non-standard model id (Featherless/OpenRouter-style `vendor/model` typos) previously went undetected at Check time and only surfaced once a real request tripped the per-model lockout. (thanks @advane204f) diff --git a/src/lib/providers/validation/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts index c9cc575ef8..cce3931f1d 100644 --- a/src/lib/providers/validation/openaiFormat.ts +++ b/src/lib/providers/validation/openaiFormat.ts @@ -459,6 +459,31 @@ export async function validateOpenAICompatibleProvider({ apiKey, providerSpecifi }; } + // #2032: a 404 on the chat probe commonly means the requested model id + // does not exist at this provider (OpenAI-compatible `model_not_found`, + // e.g. Featherless/OpenRouter-style `vendor/model` typos). Credentials + // are still valid (the endpoint responded), but silently passing this + // hides the bad model id from the user until a real request later trips + // the per-model lockout — surface it as a warning at Check time instead. + if (chatRes.status === 404) { + let modelNotFoundDetail = ""; + try { + const body: any = await chatRes.json(); + const err = body?.error; + if (typeof err?.message === "string" && err.message.trim()) { + modelNotFoundDetail = `: ${err.message.trim()}`; + } + } catch { + // Non-JSON or unreadable body — fall through with the generic warning. + } + return { + valid: true, + error: null, + method: "inference_available", + warning: `Model ID may not exist at this provider (404)${modelNotFoundDetail}`, + }; + } + // 4xx other than auth (e.g. 400 bad model, 422) usually means auth passed if (chatRes.status >= 400 && chatRes.status < 500) { return { diff --git a/tests/unit/t25-provider-validation-modelid-fallback.test.ts b/tests/unit/t25-provider-validation-modelid-fallback.test.ts index 7912d30d96..03b1f68c0b 100644 --- a/tests/unit/t25-provider-validation-modelid-fallback.test.ts +++ b/tests/unit/t25-provider-validation-modelid-fallback.test.ts @@ -114,3 +114,50 @@ test("T25: fallback chat probe treats 429 as valid credentials with warning", as globalThis.fetch = originalFetch; } }); + +// decolua/9router#2032: OpenAI-compatible "Check" silently passed for ANY +// non-empty Model ID because a chat-probe 404 (model_not_found) fell through +// the generic "4xx other than auth" branch with no warning. The user only +// discovered the bad model id after a real request tripped the per-model +// lockout. A 404 must surface a warning at Check time instead of a bare pass. +test("T25 / #2032: fallback chat probe surfaces a warning on 404 model_not_found", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async (url) => { + if (String(url).endsWith("/models")) { + return new Response(JSON.stringify({ error: "Not Found" }), { status: 404 }); + } + return new Response( + JSON.stringify({ + error: { + message: "The model glm-5.2 does not exist.", + type: "invalid_request_error", + param: null, + code: "model_not_found", + }, + }), + { status: 404 } + ); + }; + + try { + const result = await validateProviderApiKey({ + provider: "openai-compatible-chat-t25-model-not-found", + apiKey: "sk-test", + providerSpecificData: { + baseUrl: "https://api.example.com/v1", + validationModelId: "glm-5.2", + }, + }); + + // Credentials themselves are fine (404 is not an auth failure), so this + // still resolves as valid — but MUST carry an actionable warning instead + // of a silent pass, so the user learns about the bad model id at Check + // time rather than after the first real request gets locked out. + assert.equal(result.valid, true); + assert.equal(result.method, "inference_available"); + assert.match(result.warning, /model.*(?:not found|does not exist|glm-5\.2)/i); + } finally { + globalThis.fetch = originalFetch; + } +}); From 60448d4f3143fbb2b6b3183f43febb372a4fe494 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:26 -0300 Subject: [PATCH 25/33] fix(executors): forward X-Session-ID/X-Title agent metadata headers (#7104) * fix(executors): forward X-Session-ID/X-Title agent metadata headers (port from 9router#2413) Custom agent clients (e.g. non-OpenCode providers) commonly send X-Session-ID and X-Title headers for upstream request tracking/attribution, but forwardOpencodeClientHeaders() only forwarded x-opencode-* keys plus User-Agent, silently dropping these for every client. Extends the existing case-insensitive allowlist forwarding path with x-session-id/x-title. Reported-by: Atikur Rahman Chitholian (@chitholian) (https://github.com/decolua/9router/issues/2413) * chore(changelog): move #2413 entry to changelog.d fragment Consistency with the repo's canonical changelog.d/fixes/ workflow (avoids merge-storm re-conflicts from editing CHANGELOG.md directly). --- .../fixes/2413-preserve-agent-headers.md | 1 + open-sse/utils/opencodeHeaders.ts | 19 ++++++++++ tests/unit/refactor-opencodeHeaders.test.ts | 37 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 changelog.d/fixes/2413-preserve-agent-headers.md diff --git a/changelog.d/fixes/2413-preserve-agent-headers.md b/changelog.d/fixes/2413-preserve-agent-headers.md new file mode 100644 index 0000000000..6353f5dd77 --- /dev/null +++ b/changelog.d/fixes/2413-preserve-agent-headers.md @@ -0,0 +1 @@ +- **fix(executors):** forward agent-supplied `X-Session-ID`/`X-Title` metadata headers to upstream providers — previously dropped for every client outside the `x-opencode-*` allowlist. (thanks @chitholian) (#7104) diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 4e1221877c..8569c8512c 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -12,6 +12,15 @@ const OPENCODE_HEADER_KEYS = [ "x-opencode-client", ] as const; +/** + * Common agent-metadata headers used by non-OpenCode clients (custom agents/ + * providers) for upstream request tracking and attribution. Forwarded the same + * way as the x-opencode-* set: case-insensitive lookup, client value wins. + * Added for 9router#2413 — these were previously dropped for every client + * outside the OpenCode allowlist. + */ +const AGENT_METADATA_HEADER_KEYS = ["x-session-id", "x-title"] as const; + /** * Case-insensitive lookup for a header in a headers record. */ @@ -26,6 +35,8 @@ function findHeader(headers: Record, name: string): string | und * 1. Forwards User-Agent from clientHeaders via `setUserAgentHeader()` * 2. Forwards x-opencode-session, x-opencode-request, x-opencode-project, * x-opencode-client headers (case-insensitive match) + * 3. Forwards x-session-id, x-title agent-metadata headers (case-insensitive + * match) — common conventions used by non-OpenCode agent clients (9router#2413) * * @param headers - The outbound headers record to mutate * @param clientHeaders - The client-provided headers to forward from @@ -60,6 +71,14 @@ export function forwardOpencodeClientHeaders( } } + // 2b. Forward agent-metadata headers (x-session-id, x-title) — 9router#2413 + for (const headerName of AGENT_METADATA_HEADER_KEYS) { + const value = findHeader(clientHeaders, headerName); + if (value) { + headers[headerName] = value; + } + } + // 3. OpencodeExecutor-only: synthesize session/request id from fallback headers if (options?.synthesizeRequestId && !headers["x-opencode-session"]) { const sessionAffinity = diff --git a/tests/unit/refactor-opencodeHeaders.test.ts b/tests/unit/refactor-opencodeHeaders.test.ts index b79865b30e..19a5a1cdf6 100644 --- a/tests/unit/refactor-opencodeHeaders.test.ts +++ b/tests/unit/refactor-opencodeHeaders.test.ts @@ -116,6 +116,43 @@ describe("forwardOpencodeClientHeaders – x-opencode-* headers", () => { }); }); +// ── agent metadata headers (X-Session-ID / X-Title) — 9router#2413 ───────── +// Non-OpenCode agent clients (e.g. custom providers) commonly send X-Session-ID +// and X-Title for upstream request tracking/attribution. These were previously +// dropped for every client outside the x-opencode-* allowlist. + +describe("forwardOpencodeClientHeaders – X-Session-ID / X-Title", () => { + it("forwards X-Session-ID from client headers", () => { + const headers = h(); + const clientHeaders = { "X-Session-ID": "sess-xyz" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-session-id"], "sess-xyz"); + }); + + it("forwards X-Title from client headers", () => { + const headers = h(); + const clientHeaders = { "X-Title": "My Agent" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-title"], "My Agent"); + }); + + it("matches X-Session-ID / X-Title case-insensitively", () => { + const headers = h(); + const clientHeaders = { "x-session-id": "sess-lower", "x-title": "lower title" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-session-id"], "sess-lower"); + assert.equal(headers["x-title"], "lower title"); + }); + + it("still does NOT forward unrelated unknown headers", () => { + const headers = h(); + const clientHeaders = { "X-Session-ID": "sess-1", "X-Random-Other": "nope" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-session-id"], "sess-1"); + assert.equal(headers["X-Random-Other"], undefined); + }); +}); + // ── synthesizeRequestId ───────────────────────────────────────────────────── describe("forwardOpencodeClientHeaders – synthesizeRequestId", () => { From a62141210b294d7ee5cf3c3911ba07e927d58bd0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:33 -0300 Subject: [PATCH 26/33] fix(cli): verify better-sqlite3 native binary is actually loadable (#7105) * fix(cli): verify better-sqlite3 native binary is actually loadable (port from 9router#2493) isBetterSqliteBinaryValid() only checked the .node file's magic bytes (ELF/Mach-O/PE header), never whether the binary was built for the ABI (NODE_MODULE_VERSION) of the Node runtime that loads it. A stale or foreign-ABI binary passed the check and then segfaulted the process on the first database call instead of triggering a rebuild via npmInstallRuntime(). The fix adds a real load probe (require() in a throwaway subprocess) after the magic-byte check, so an incompatible binary is now correctly reported as invalid and the runtime self-heal reinstalls it. Reported-by: Manikandan (@mrprohack) (https://github.com/decolua/9router/issues/2493) * chore(changelog): move #2493 entry to changelog.d fragment Consistency with the repo's canonical changelog.d/fixes/ workflow (avoids merge-storm re-conflicts from editing CHANGELOG.md directly). --- bin/cli/runtime/nativeDeps.mjs | 41 +++++++++++++-- .../2493-better-sqlite3-abi-validation.md | 1 + tests/unit/cli-runtime.test.ts | 50 +++++++++++++++++-- 3 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fixes/2493-better-sqlite3-abi-validation.md diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index 2a0787bd04..1dc442270e 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -52,6 +52,31 @@ export function hasModule(name) { return existsSync(join(runtimeModules(), name, "package.json")); } +/** + * Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that + * is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault + * the process instead of throwing) never takes down the caller — only the probe subprocess. + */ +function probeNativeBinaryLoadable(binary) { + try { + const res = spawnSync( + process.execPath, + [ + "-e", + "try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }", + binary, + ], + { timeout: 10_000, stdio: "ignore" } + ); + // status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown + // ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a + // signal, e.g. SIGSEGV) — means the binary is not safe to load. + return res.status === 0; + } catch { + return false; + } +} + export function isBetterSqliteBinaryValid() { const binary = join( runtimeModules(), @@ -68,10 +93,18 @@ export function isBetterSqliteBinaryValid() { closeSync(fd); const magic = buf.toString("hex"); const os = platform(); - if (os === "linux") return magic.startsWith("7f454c46"); // ELF - if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O - if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ - return true; + let formatOk; + if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF + else if (os === "darwin") + formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O + else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ + else formatOk = true; + if (!formatOk) return false; + // File-format magic bytes alone do not guarantee the binary was built for the Node ABI + // (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header + // check and then crashes (segfault) on load instead of triggering a rebuild. Actually + // attempt to load it, isolated in a subprocess. + return probeNativeBinaryLoadable(binary); } catch { return false; } diff --git a/changelog.d/fixes/2493-better-sqlite3-abi-validation.md b/changelog.d/fixes/2493-better-sqlite3-abi-validation.md new file mode 100644 index 0000000000..dba246809d --- /dev/null +++ b/changelog.d/fixes/2493-better-sqlite3-abi-validation.md @@ -0,0 +1 @@ +- **fix(cli):** the runtime self-heal now verifies a cached `better-sqlite3` native binary actually loads for the running Node before trusting it — the old check only inspected the file's magic bytes (ELF/Mach-O/PE header), so a binary built for a different Node ABI passed validation and segfaulted the process on first use instead of triggering a rebuild. (thanks @mrprohack) (#7105) diff --git a/tests/unit/cli-runtime.test.ts b/tests/unit/cli-runtime.test.ts index 46a3e3204c..293f6a3933 100644 --- a/tests/unit/cli-runtime.test.ts +++ b/tests/unit/cli-runtime.test.ts @@ -71,20 +71,60 @@ test("buildEnvWithRuntime preserva NODE_PATH existente", async () => { assert.ok(env.NODE_PATH.includes("/existing/path"), "NODE_PATH original deve ser preservado"); }); -test("isBetterSqliteBinaryValid detecta ELF magic bytes (Linux)", async () => { +test("isBetterSqliteBinaryValid rejeita binário com magic bytes válidos mas ABI incompatível (regressão #2493)", async () => { + // Regression for upstream 9router#2493: a binary that only "looks" native (correct ELF/Mach-O/PE + // header) but was built for a different Node ABI (NODE_MODULE_VERSION) must NOT be reported as + // valid — loading it crashes the process (segfault) instead of triggering a rebuild. const { getRuntimeNodeModules, isBetterSqliteBinaryValid } = await import("../../bin/cli/runtime/nativeDeps.mjs"); const nm = getRuntimeNodeModules(); const buildDir = join(nm, "better-sqlite3", "build", "Release"); mkdirSync(buildDir, { recursive: true }); const binary = join(buildDir, "better_sqlite3.node"); - const buf = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]); + const { platform } = await import("node:os"); + const os = platform(); + // Correct file-format magic bytes for the current OS, but not a real, loadable native addon — + // this is exactly what the old magic-bytes-only check let through. + const magicByPlatform = { + linux: [0x7f, 0x45, 0x4c, 0x46], + darwin: [0xcf, 0xfa, 0xed, 0xfe], + win32: [0x4d, 0x5a], + }; + const magic = magicByPlatform[os] ?? magicByPlatform.linux; + const buf = Buffer.concat([Buffer.from(magic), Buffer.alloc(64, 0)]); writeFileSync(binary, buf); const result = isBetterSqliteBinaryValid(); - const { platform } = await import("node:os"); - if (platform() === "linux") { - assert.equal(result, true, "ELF magic bytes devem ser válidos no Linux"); + assert.equal( + result, + false, + "binário com header válido mas ABI/conteúdo incompatível deve ser inválido" + ); + rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true }); +}); + +test("isBetterSqliteBinaryValid aceita um binário nativo real e carregável", async () => { + const { getRuntimeNodeModules, isBetterSqliteBinaryValid } = + await import("../../bin/cli/runtime/nativeDeps.mjs"); + const { existsSync, copyFileSync } = await import("node:fs"); + const realBinary = join( + process.cwd(), + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" + ); + if (!existsSync(realBinary)) { + // Ambient runtime without a compiled better-sqlite3 binary — nothing to assert here. + return; } + const nm = getRuntimeNodeModules(); + const buildDir = join(nm, "better-sqlite3", "build", "Release"); + mkdirSync(buildDir, { recursive: true }); + const binary = join(buildDir, "better_sqlite3.node"); + copyFileSync(realBinary, binary); + const result = isBetterSqliteBinaryValid(); + assert.equal(result, true, "um binário real, compatível com o Node atual, deve ser válido"); rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true }); }); From 0130a4bbb2f24ed943369d202187f4a630a6d336 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:43 -0300 Subject: [PATCH 27/33] fix(sse): handle space-separated arg name/value in Composer tool calls (port from 9router#1811) (#7116) parseInnerCall only split arg segments on a newline between the arg name and its value. Cursor's live Composer/Auto output has been observed using a single space instead, so those segments were treated as one long (space-containing) arg name with an empty value, silently no-opping Write/tool calls for Composer/Auto models. Fall back to splitting on the first whitespace boundary when no newline is present in the segment. Reported-by: way-art (https://github.com/decolua/9router/issues/1811) --- changelog.d/fixes/1811-composer-space-sep.md | 1 + open-sse/utils/composerToolCalls.ts | 23 +++++++++++++++---- tests/unit/composer-tool-calls.test.ts | 24 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/1811-composer-space-sep.md diff --git a/changelog.d/fixes/1811-composer-space-sep.md b/changelog.d/fixes/1811-composer-space-sep.md new file mode 100644 index 0000000000..856fab9e17 --- /dev/null +++ b/changelog.d/fixes/1811-composer-space-sep.md @@ -0,0 +1 @@ +- **fix(sse):** Cursor Composer/Auto tool calls that separate the arg name and value with a space instead of a newline (e.g. `path /Users/.../test`) no longer produce empty-valued, malformed argument keys, fixing silent no-op Write/tool calls. (thanks @way-art) diff --git a/open-sse/utils/composerToolCalls.ts b/open-sse/utils/composerToolCalls.ts index d688973ce3..916903a3ca 100644 --- a/open-sse/utils/composerToolCalls.ts +++ b/open-sse/utils/composerToolCalls.ts @@ -126,15 +126,28 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul const args: Record = {}; for (const seg of segments) { if (!seg) continue; - // Each segment is `arg_name\nvalue\n...`. The arg name is the first - // line; everything after the first newline is the value (verbatim, - // including additional newlines). + // Each segment is normally `arg_name\nvalue\n...`: the arg name is the + // first line, everything after the first newline is the value + // (verbatim, including additional newlines). Some live Composer/Auto + // captures instead separate the arg name and value with a single space + // on the same line (no newline at all in the segment) — fall back to + // splitting on the first whitespace boundary in that case so the value + // isn't swallowed into an empty-valued, space-containing "arg name". const idxNl = seg.indexOf("\n"); let argName: string; let argValue: string; if (idxNl < 0) { - argName = seg.trim(); - argValue = ""; + const idxSp = seg.search(/\s/); + if (idxSp < 0) { + argName = seg.trim(); + argValue = ""; + } else { + argName = seg.slice(0, idxSp).trim(); + // Unlike the newline-delimited form, a space-delimited value has no + // multi-line content to preserve — trim the trailing whitespace left + // over from the boundary with the next `<|tool▁sep|>` marker. + argValue = seg.slice(idxSp + 1).trim(); + } } else { argName = seg.slice(0, idxNl).trim(); argValue = seg.slice(idxNl + 1); diff --git a/tests/unit/composer-tool-calls.test.ts b/tests/unit/composer-tool-calls.test.ts index c1133f0513..5fba8abd3a 100644 --- a/tests/unit/composer-tool-calls.test.ts +++ b/tests/unit/composer-tool-calls.test.ts @@ -214,3 +214,27 @@ test("feedStreamingChunk: noop after done state", () => { assert.equal(out.safeDelta, ""); assert.equal(out.ready, false); }); + +// ─── Regression: space-separated arg name/value (9router#1811) ─────────────── +// Cursor's real Composer/Auto output has been observed using a single space +// (instead of a newline) between the arg name and its value inside a +// <|tool▁sep|> segment, e.g. "<|tool▁sep|>path /Users/.../test". The parser +// must still extract {path: "/Users/.../test"} rather than treating the whole +// segment as the (empty-valued) arg name. +test("parseComposerToolCalls: parses args separated by a space instead of a newline (Cursor Composer live capture)", () => { + const text = + "<|tool▁calls▁begin|><|tool▁call▁begin|> Write " + + "<|tool▁sep|>path /Users/kabawagang/Desktop/Code/iOS_Review/test " + + "<|tool▁sep|>contents 22\n\n<|tool▁call▁end|><|tool▁calls▁end|>"; + + const result = parseComposerToolCalls(text); + + assert.equal(result.toolCalls.length, 1); + const tc = result.toolCalls[0]; + assert.equal(tc.function.name, "Write"); + const args = JSON.parse(tc.function.arguments); + assert.deepEqual(args, { + path: "/Users/kabawagang/Desktop/Code/iOS_Review/test", + contents: 22, + }); +}); From c48e54604fa17ca7e9071bde8e55bfc0ff9b0971 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:50 -0300 Subject: [PATCH 28/33] fix(cli): remove MITM DNS spoof entries before killing server process (#7117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): remove MITM DNS spoof entries before killing server process (port from 9router#1809) stopMitm() killed the spawned MITM server process first and only removed the /etc/hosts DNS-spoof entries afterward. During that window any client whose DNS still resolved a target host to 127.0.0.1 but whose MITM listener was already dead got connect ECONNREFUSED 127.0.0.1:443 — exactly the community-confirmed workaround (stop DNS before stopping the server) proves. Swap the two steps so DNS is always cleared first, mirroring the ordering already used by repairMitm() and handleExitCleanup(). Reported-by: dionisius95 (https://github.com/decolua/9router/issues/1809) * refactor(mitm): extract repair planning out of manager to respect the file-size cap The #1809 DNS-before-kill ordering fix pushed src/mitm/manager.ts to 813 lines, over the 800-line cap check:file-size enforces for non-frozen files. Move the pure repair-planning pieces (collectManagedHosts, the RepairPlan shape and its filesystem/cert/DNS sweep) into a sibling src/mitm/repair.ts. The in-memory session bookkeeping repairMitm() owns — cached sudo password, orphaned flag, PID file — deliberately stays in manager.ts, so the seam is "plan the repair" vs "own the session". manager.ts is now 731 lines; behavior is unchanged. The DNS-first ordering fix and its regression guard (tests/unit/mitm-stop-dns-before-kill-1809.ts) are untouched and still pass. * fix(mitm): split stopMitm() DNS/kill steps to fix complexity ratchet regression stopMitm()'s new DNS-before-kill ordering (#1809) pushed its cyclomatic complexity to 18 (max 15), regressing the complexity ratchet from 2056 to 2057. Extract the DNS-removal step and the process-kill step (in-memory + PID-file fallback) into two private helpers, mirroring the existing performRepairSteps() extraction pattern in repair.ts. Behavior unchanged; complexity back at 2056 (cognitive-complexity drops to 889, one under baseline). --- .../fixes/1809-mitm-stop-dns-before-kill.md | 1 + src/mitm/manager.ts | 240 ++++++++---------- src/mitm/repair.ts | 115 +++++++++ .../mitm-stop-dns-before-kill-1809.test.ts | 82 ++++++ 4 files changed, 308 insertions(+), 130 deletions(-) create mode 100644 changelog.d/fixes/1809-mitm-stop-dns-before-kill.md create mode 100644 src/mitm/repair.ts create mode 100644 tests/unit/mitm-stop-dns-before-kill-1809.test.ts diff --git a/changelog.d/fixes/1809-mitm-stop-dns-before-kill.md b/changelog.d/fixes/1809-mitm-stop-dns-before-kill.md new file mode 100644 index 0000000000..06e42e461c --- /dev/null +++ b/changelog.d/fixes/1809-mitm-stop-dns-before-kill.md @@ -0,0 +1 @@ +- **fix(cli):** `stopMitm()` now removes /etc/hosts DNS-spoof entries before killing the MITM server process, closing the window where a client's DNS still resolved a target host to `127.0.0.1` while nothing was listening there — the cause of `connect ECONNREFUSED 127.0.0.1:443` right after stopping the MITM proxy (thanks @dionisius95). diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index 47765bf515..94fcf33b69 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -5,15 +5,22 @@ import { resolveMitmDataDir } from "./dataDir.ts"; import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; import { provisionDnsEntries } from "./dns/provision.ts"; import { generateCert } from "./cert/generate.ts"; -import { installCertResult, uninstallCert } from "./cert/install.ts"; +import { installCertResult } from "./cert/install.ts"; import { ALL_TARGETS } from "./targets/index.ts"; import { detectAgent } from "./detection/index.ts"; import type { AgentId, DetectionResult, MitmTarget } from "./types.ts"; import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts"; -import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts"; import { getUserBypassPatterns } from "@/lib/db/agentBridgeBypass.ts"; import { configureUpstreamCa } from "./upstreamTrust.ts"; import { createLogger } from "@/shared/utils/logger.ts"; +import { + buildRepairPlan, + collectManagedHosts, + performRepairSteps, + type RepairPlan, +} from "./repair.ts"; + +export { buildRepairPlan, collectManagedHosts, type RepairPlan }; const log = createLogger("mitm-manager"); @@ -57,6 +64,17 @@ export function interpretMitmStartupError(stderr: string, port: number): string let serverProcess: ChildProcess | null = null; let serverPid: number | null = null; +/** + * Test-only seam: install a fake server process (and pid) so stopMitm() can be + * exercised without spawning a real MITM child. Not part of the public API — + * only intended for unit tests that need to assert stopMitm()'s DNS/kill + * ordering (#1809). No-op in production code paths. + */ +export function __setServerProcessForTest(proc: ChildProcess | null, pid: number | null): void { + serverProcess = proc; + serverPid = pid; +} + // Set while startMitm() is in flight, from the guard check through spawn. // Guards a TOCTOU race: the "already running" check above only trips once // `serverProcess` is assigned by spawn() — ~130 lines and several awaits @@ -219,108 +237,20 @@ function isProcessAlive(pid: number): boolean { } } -/** - * Enumerate every hostname OmniRoute may have written to /etc/hosts during - * startMitm(): the full agent-target registry plus all custom hosts. Removal - * via removeDNSEntries() is idempotent (absent entries are skipped), so this - * set is intentionally over-inclusive — a host that was never spoofed costs - * nothing to "remove", but a host we forget to list leaks machine-wide. - * (Gap 8 — clean-stop DNS leak.) - */ -export function collectManagedHosts(): string[] { - const hosts = new Set(); - for (const target of ALL_TARGETS) { - for (const h of target.hosts) hosts.add(h); - } - try { - for (const ch of listCustomHosts()) hosts.add(ch.host); - } catch (err) { - log.error({ err }, "collectManagedHosts: failed to read custom hosts (continuing)"); - } - return [...hosts]; -} - -export interface RepairPlan { - dnsHostsToRemove: string[]; - removeCert: boolean; - revertSystemProxy: boolean; -} - -/** - * Pure description of what a repair must undo. Separated from repairMitm() so - * the enumeration is unit-testable without touching the OS or requiring sudo. - * (Gap 7.) - */ -export function buildRepairPlan(): RepairPlan { - return { - dnsHostsToRemove: collectManagedHosts(), - removeCert: true, - revertSystemProxy: true, - }; -} - -/** - * Best-effort revert of an applied system proxy. The applied state lives - * in-memory (captureState), so this only succeeds within the same process that - * applied it; after a crash the previousState is gone and this is a no-op. DNS - * + cert teardown are always reversible because they read on-disk state. - */ -async function revertSystemProxyIfApplied(): Promise { - try { - const { getSystemProxyState, clearSystemProxy } = await import("@/lib/inspector/captureState"); - const state = getSystemProxyState(); - if (!state.applied || !state.previousState) return false; - const { revert } = await import("./inspector/systemProxyConfig.ts"); - await revert(state.previousState); - clearSystemProxy(); - return true; - } catch (err) { - log.error({ err }, "revertSystemProxyIfApplied failed (continuing)"); - return false; - } -} - /** * Undo every system mutation startMitm() may have made, WITHOUT requiring the * MITM server to be running. Safe to call when state is already clean (every * step is idempotent). Used by: the /repair route, the CLI cleanup subcommand, * and the stale-PID auto-repair on app startup. (Gap 7 — the application-layer - * analogue of ProxyBridge's destructor + `--cleanup`.) + * analogue of ProxyBridge's destructor + `--cleanup`.) Steps 1-3 (DNS, cert, + * system-proxy) are delegated to `./repair.ts::performRepairSteps()`; the PID + * file + in-memory session cleanup below stays here since it touches this + * module's private state. */ export async function repairMitm(sudoPassword: string): Promise<{ repaired: string[] }> { - const plan = buildRepairPlan(); - const repaired: string[] = []; + const repaired = await performRepairSteps(sudoPassword); - // 1. DNS — remove every host we may have spoofed (idempotent, reads /etc/hosts). - try { - await removeDNSEntry(sudoPassword); - if (plan.dnsHostsToRemove.length > 0) { - await removeDNSEntries(plan.dnsHostsToRemove, sudoPassword); - } - repaired.push("dns"); - } catch (err) { - log.error({ err }, "repairMitm: DNS cleanup failed (continuing)"); - } - - // 2. Certificate — uninstall the MITM root CA from the trust store. - if (plan.removeCert) { - try { - const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); - if (fs.existsSync(certPath)) { - await uninstallCert(sudoPassword, certPath); - repaired.push("cert"); - } - } catch (err) { - log.error({ err }, "repairMitm: cert removal failed (continuing)"); - } - } - - // 3. System proxy — best-effort revert if applied in this process. - if (plan.revertSystemProxy) { - if (await revertSystemProxyIfApplied()) repaired.push("system-proxy"); - } - - // 4. Stale PID file. + // Stale PID file. try { if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); } catch { @@ -709,11 +639,38 @@ async function startMitmInternal( } /** - * Stop MITM proxy - * @param {string} sudoPassword - Sudo password for DNS cleanup + * DNS teardown step of stopMitm() (#1809) — split out purely to keep + * stopMitm()'s own cyclomatic complexity under the repo's ratchet; behavior + * is unchanged from the original inline implementation. */ -export async function stopMitm(sudoPassword: string): Promise<{ running: false; pid: null }> { - // 1. Kill server process (in-memory or from PID file) +async function removeStopDnsEntries( + deps: { + removeDNSEntry: (sudoPassword: string) => Promise; + removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise; + collectManagedHosts: () => string[]; + }, + sudoPassword: string +): Promise { + log.info("Removing DNS entries..."); + await deps.removeDNSEntry(sudoPassword); + try { + const managed = deps.collectManagedHosts(); + if (managed.length > 0) { + await deps.removeDNSEntries(managed, sudoPassword); + } + } catch (err) { + log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)"); + } +} + +/** + * Kill the MITM server process during stop — either the in-memory + * `serverProcess` handle or, if that's gone, the PID recorded in `PID_FILE`. + * Split out of stopMitm() purely to keep that function's complexity under + * the repo's ratchet; behavior is unchanged from the original inline + * implementation. + */ +async function killMitmServerProcessOnStop(): Promise { const proc = serverProcess; if (proc && !proc.killed) { log.info("Stopping MITM server..."); @@ -724,41 +681,64 @@ export async function stopMitm(sudoPassword: string): Promise<{ running: false; } serverProcess = null; serverPid = null; - } else { - // Fallback: kill by PID file - try { - if (fs.existsSync(PID_FILE)) { - const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); - if (savedPid && isProcessAlive(savedPid)) { - log.info({ pid: savedPid }, "Killing MITM server by PID..."); - process.kill(savedPid, "SIGTERM"); - await new Promise((resolve) => setTimeout(resolve, 1000)); - if (isProcessAlive(savedPid)) { - process.kill(savedPid, "SIGKILL"); - } - } - } - } catch { - // Ignore - } - serverProcess = null; - serverPid = null; + return; } - // 2. Remove DNS entries — Antigravity defaults PLUS every agent + custom host - // that startMitm() may have spoofed. removeDNSEntries is idempotent, so - // over-inclusion is safe; under-inclusion leaks /etc/hosts lines that - // hijack resolution machine-wide after stop (Gap 8). - log.info("Removing DNS entries..."); - await removeDNSEntry(sudoPassword); + // Fallback: kill by PID file try { - const managed = collectManagedHosts(); - if (managed.length > 0) { - await removeDNSEntries(managed, sudoPassword); + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + log.info({ pid: savedPid }, "Killing MITM server by PID..."); + process.kill(savedPid, "SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (isProcessAlive(savedPid)) { + process.kill(savedPid, "SIGKILL"); + } + } } - } catch (err) { - log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)"); + } catch { + // Ignore } + serverProcess = null; + serverPid = null; +} + +/** + * Stop MITM proxy + * + * Ordering is deliberate and load-bearing (#1809 — "connect ECONNREFUSED + * 127.0.0.1:443" after stop). DNS entries MUST be removed BEFORE the server + * process is killed: if the process dies first, any client whose DNS still + * resolves the target host to 127.0.0.1 (from startMitm()'s spoof) but whose + * MITM listener is already dead gets ECONNREFUSED against a dead port for the + * whole window between the two steps. Removing DNS first closes that window — + * once /etc/hosts no longer points at 127.0.0.1, clients fall back to real + * resolution regardless of when the listener actually goes away. This mirrors + * the DNS-first ordering already used by repairMitm() and handleExitCleanup(). + * @param {string} sudoPassword - Sudo password for DNS cleanup + * @param _depsOverride - optional dependency override, used in tests for DI. + */ +export async function stopMitm( + sudoPassword: string, + _depsOverride?: { + removeDNSEntry?: (sudoPassword: string) => Promise; + removeDNSEntries?: (hosts: string[], sudoPassword: string) => Promise; + collectManagedHosts?: () => string[]; + } +): Promise<{ running: false; pid: null }> { + const deps = { + removeDNSEntry: _depsOverride?.removeDNSEntry ?? removeDNSEntry, + removeDNSEntries: _depsOverride?.removeDNSEntries ?? removeDNSEntries, + collectManagedHosts: _depsOverride?.collectManagedHosts ?? collectManagedHosts, + }; + + // 1. Remove DNS entries FIRST — see function doc + module doc above for why + // this must happen before the process kill (#1809, Gap 8). + await removeStopDnsEntries(deps, sudoPassword); + + // 2. Kill server process (in-memory or from PID file) + await killMitmServerProcessOnStop(); // 3. Clean up clearCachedPassword(); // Clear password from memory when proxy stops diff --git a/src/mitm/repair.ts b/src/mitm/repair.ts new file mode 100644 index 0000000000..4ee2f160f2 --- /dev/null +++ b/src/mitm/repair.ts @@ -0,0 +1,115 @@ +import path from "path"; +import fs from "fs"; +import { resolveMitmDataDir } from "./dataDir.ts"; +import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; +import { uninstallCert } from "./cert/install.ts"; +import { ALL_TARGETS } from "./targets/index.ts"; +import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts"; +import { createLogger } from "@/shared/utils/logger.ts"; + +const log = createLogger("mitm-repair"); + +/** + * Enumerate every hostname OmniRoute may have written to /etc/hosts during + * startMitm(): the full agent-target registry plus all custom hosts. Removal + * via removeDNSEntries() is idempotent (absent entries are skipped), so this + * set is intentionally over-inclusive — a host that was never spoofed costs + * nothing to "remove", but a host we forget to list leaks machine-wide. + * (Gap 8 — clean-stop DNS leak.) + */ +export function collectManagedHosts(): string[] { + const hosts = new Set(); + for (const target of ALL_TARGETS) { + for (const h of target.hosts) hosts.add(h); + } + try { + for (const ch of listCustomHosts()) hosts.add(ch.host); + } catch (err) { + log.error({ err }, "collectManagedHosts: failed to read custom hosts (continuing)"); + } + return [...hosts]; +} + +export interface RepairPlan { + dnsHostsToRemove: string[]; + removeCert: boolean; + revertSystemProxy: boolean; +} + +/** + * Pure description of what a repair must undo. Separated from repairMitm() so + * the enumeration is unit-testable without touching the OS or requiring sudo. + * (Gap 7.) + */ +export function buildRepairPlan(): RepairPlan { + return { + dnsHostsToRemove: collectManagedHosts(), + removeCert: true, + revertSystemProxy: true, + }; +} + +/** + * Best-effort revert of an applied system proxy. The applied state lives + * in-memory (captureState), so this only succeeds within the same process that + * applied it; after a crash the previousState is gone and this is a no-op. DNS + * + cert teardown are always reversible because they read on-disk state. + */ +async function revertSystemProxyIfApplied(): Promise { + try { + const { getSystemProxyState, clearSystemProxy } = await import("@/lib/inspector/captureState"); + const state = getSystemProxyState(); + if (!state.applied || !state.previousState) return false; + const { revert } = await import("./inspector/systemProxyConfig.ts"); + await revert(state.previousState); + clearSystemProxy(); + return true; + } catch (err) { + log.error({ err }, "revertSystemProxyIfApplied failed (continuing)"); + return false; + } +} + +/** + * Run the DNS/cert/system-proxy teardown steps of a repair, WITHOUT touching + * any of `manager.ts`'s in-memory session state (cached password, orphaned + * flag, PID file) — that bookkeeping stays in `manager.ts::repairMitm()`, + * which calls this as its first step. Split out purely to keep + * `src/mitm/manager.ts` under the repo's file-size cap; behavior is + * unchanged from the original inline implementation. (Gap 7.) + */ +export async function performRepairSteps(sudoPassword: string): Promise { + const plan = buildRepairPlan(); + const repaired: string[] = []; + + // 1. DNS — remove every host we may have spoofed (idempotent, reads /etc/hosts). + try { + await removeDNSEntry(sudoPassword); + if (plan.dnsHostsToRemove.length > 0) { + await removeDNSEntries(plan.dnsHostsToRemove, sudoPassword); + } + repaired.push("dns"); + } catch (err) { + log.error({ err }, "repairMitm: DNS cleanup failed (continuing)"); + } + + // 2. Certificate — uninstall the MITM root CA from the trust store. + if (plan.removeCert) { + try { + const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); + if (fs.existsSync(certPath)) { + await uninstallCert(sudoPassword, certPath); + repaired.push("cert"); + } + } catch (err) { + log.error({ err }, "repairMitm: cert removal failed (continuing)"); + } + } + + // 3. System proxy — best-effort revert if applied in this process. + if (plan.revertSystemProxy) { + if (await revertSystemProxyIfApplied()) repaired.push("system-proxy"); + } + + return repaired; +} diff --git a/tests/unit/mitm-stop-dns-before-kill-1809.test.ts b/tests/unit/mitm-stop-dns-before-kill-1809.test.ts new file mode 100644 index 0000000000..3dd92dcaad --- /dev/null +++ b/tests/unit/mitm-stop-dns-before-kill-1809.test.ts @@ -0,0 +1,82 @@ +/** + * Regression test for upstream issue #1809: "connect ECONNREFUSED 127.0.0.1:443" + * after stopping the MITM proxy. + * + * Root cause: stopMitm() killed the spawned MITM server process FIRST, and only + * removed the /etc/hosts DNS-spoof entries AFTER. During that window any client + * whose DNS still resolved the target host to 127.0.0.1 (from startMitm's spoof) + * but whose MITM listener was already dead got ECONNREFUSED — exactly the + * community-confirmed workaround ("stop DNS before stopping the server") proves. + * + * This test drives stopMitm() with real DI: a fake serverProcess standing in for + * the spawned MITM child, and dependency-injected DNS-removal functions that + * record the order in which they are invoked relative to the process kill. The + * fix must remove DNS entries before killing the server process so no window + * exists where DNS points at 127.0.0.1 with nothing listening there. + * + * Uses the project's DATA_DIR-tmp + resetDbInstance pattern so the Node native + * test runner does not hang on open SQLite handles (CLAUDE.md PII learning #3). + */ +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 { EventEmitter } from "node:events"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mitm-stop-order-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const manager = await import("../../src/mitm/manager.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("stopMitm removes DNS entries before killing the MITM server process (#1809)", async () => { + const events: string[] = []; + + // Fake child process standing in for the spawned MITM server. + const fakeProc = new EventEmitter() as EventEmitter & { + killed: boolean; + kill: (signal?: string) => boolean; + }; + fakeProc.killed = false; + fakeProc.kill = (signal?: string) => { + events.push(`kill:${signal}`); + fakeProc.killed = true; + return true; + }; + + manager.__setServerProcessForTest(fakeProc as unknown as import("child_process").ChildProcess, 4242); + + const removeDNSEntry = async () => { + events.push("removeDNSEntry"); + }; + const removeDNSEntries = async () => { + events.push("removeDNSEntries"); + }; + const collectManagedHosts = () => ["fake.example.test"]; + + await manager.stopMitm("fake-sudo-password", { + removeDNSEntry, + removeDNSEntries, + collectManagedHosts, + }); + + const firstKillIndex = events.findIndex((e) => e.startsWith("kill:")); + const firstDnsIndex = events.findIndex( + (e) => e === "removeDNSEntry" || e === "removeDNSEntries" + ); + + assert.ok(firstKillIndex !== -1, "server process kill was never invoked"); + assert.ok(firstDnsIndex !== -1, "DNS removal was never invoked"); + assert.ok( + firstDnsIndex < firstKillIndex, + `DNS entries must be removed BEFORE the MITM server process is killed ` + + `(got order: ${JSON.stringify(events)}) — otherwise a client whose DNS still ` + + `points at 127.0.0.1 hits a dead listener and gets ECONNREFUSED (#1809)` + ); +}); From 86b293d3a36a824cd5a1e7c174377d9ee25b7761 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:13:57 -0300 Subject: [PATCH 29/33] fix(api): check Vercel SSO-protection PATCH response on relay deploy (#7119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): check Vercel SSO-protection PATCH response on relay deploy (port from 9router#1037) The Vercel relay deploy route disabled Deployment Protection (SSO) by firing a PATCH request with .catch(() => {}) and never checking res.ok. When Vercel rejects or no-ops the PATCH (plan doesn't allow disabling protection, an under-scoped token, etc.), the relay was still saved and activated as a healthy proxy pool, and later requests routed through it failed with an undiagnosed 403 Access denied from Vercel's own deployment protection — indistinguishable from an upstream-provider rejection (e.g. Codex/ChatGPT edge-IP blocking). Extract disableSsoProtection() to check the PATCH response and surface an ssoProtectionWarning in the deploy response when it fails, so the failure source can be diagnosed instead of silently masked. Reported-by: Rico Aditya (@ricatix) (https://github.com/decolua/9router/issues/1037) * refactor(api): extract vercel-deploy POST helpers to keep the cognitive-complexity ratchet at baseline The SSO-protection check added to POST pushed its cognitive complexity from 15 to 21, regressing the cognitive-complexity ratchet (891 > baseline 890). Extract two pure helpers with identical behavior: - buildDeployErrorResponse(): the sanitized non-ok Vercel deploy response - resolveSsoProtectionWarning(): the SSO PATCH check + warning string POST now reads as a flat sequence of guards. No behavior change. --- .../1037-vercel-relay-sso-protection-check.md | 1 + .../api/settings/proxy/vercel-deploy/route.ts | 133 +++++++++++++----- ...vercel-deploy-sso-protection-check.test.ts | 95 +++++++++++++ 3 files changed, 197 insertions(+), 32 deletions(-) create mode 100644 changelog.d/fixes/1037-vercel-relay-sso-protection-check.md create mode 100644 tests/unit/vercel-deploy-sso-protection-check.test.ts diff --git a/changelog.d/fixes/1037-vercel-relay-sso-protection-check.md b/changelog.d/fixes/1037-vercel-relay-sso-protection-check.md new file mode 100644 index 0000000000..e03d00dd02 --- /dev/null +++ b/changelog.d/fixes/1037-vercel-relay-sso-protection-check.md @@ -0,0 +1 @@ +- **fix(api):** Vercel Relay deploy now checks the Deployment Protection (SSO) PATCH response and surfaces `ssoProtectionWarning` when Vercel rejects it, instead of silently activating a relay that later returns an undiagnosed `403 Access denied`. (thanks @ricatix) diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index 42f432f2c6..6d6b859396 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -98,6 +98,92 @@ export default async function handler(req) { */ export const __buildRelayFunctionForTest = buildRelayFunction; +/** + * Disable Vercel project SSO/Deployment Protection so the relay is publicly + * reachable. The PATCH response was previously fired-and-forgotten + * (`.catch(() => {})`, no `res.ok` check) — if Vercel rejects or no-ops the + * request (plan does not allow disabling protection, an under-scoped token, + * etc.), the relay still got saved and activated as a healthy proxy pool, + * and later requests through it failed with an undiagnosed + * `403 Access denied` from Vercel's own deployment protection. Callers must + * now check `.ok` and surface the failure instead of assuming success. + */ +async function disableSsoProtection( + vercelApiBase: string, + projectId: string, + token: string +): Promise<{ ok: boolean; status?: number }> { + try { + const res = await fetch(`${vercelApiBase}/v9/projects/${projectId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ssoProtection: null }), + }); + return { ok: res.ok, status: res.status }; + } catch { + return { ok: false }; + } +} + +/** + * Test-only hook exposing `disableSsoProtection` so the regression test can + * assert the PATCH response is checked instead of silently swallowed. Not + * part of the route contract. + */ +export const __disableSsoProtectionForTest = disableSsoProtection; + +/** + * Builds the sanitized error response for a rejected Vercel deployment + * request. Extracted from POST to keep the handler's cognitive complexity + * within the ratchet — parses the canonical `{ error: { message } } }` shape + * and never forwards raw upstream error text (may contain project IDs, team + * slugs, deployment hashes or internal Vercel error strings). + */ +async function buildDeployErrorResponse(deployRes: Response) { + let upstreamMessage = "Vercel API rejected the deployment"; + try { + const parsed = (await deployRes.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + const candidate = parsed?.error?.message; + if (typeof candidate === "string" && candidate.trim()) { + upstreamMessage = candidate.trim().slice(0, 200); + } + } catch { + /* fall through to generic message */ + } + return createErrorResponse({ + status: deployRes.status, + message: `Vercel deployment failed: ${upstreamMessage}`, + type: "upstream_error", + }); +} + +/** + * Disables Vercel SSO/Deployment Protection for the deployed project and + * returns a caller-facing warning when it could not be disabled. Extracted + * from POST to keep the handler's cognitive complexity within the ratchet. + * See `disableSsoProtection` doc comment for the bug this guards against. + */ +async function resolveSsoProtectionWarning( + projectId: string | undefined, + vercelApiBase: string, + token: string +): Promise { + if (!projectId) return undefined; + const ssoResult = await disableSsoProtection(vercelApiBase, projectId, token); + if (ssoResult.ok) return undefined; + return ( + "Could not disable Vercel Deployment Protection (SSO) for this project" + + (ssoResult.status ? ` (status ${ssoResult.status})` : "") + + ". Requests through this relay may fail with a 403 Access denied from " + + "Vercel until protection is disabled manually in the Vercel dashboard." + ); +} + async function pollDeployment(deploymentApiUrl: string, token: string): Promise<"READY" | "ERROR"> { for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); @@ -171,27 +257,9 @@ export async function POST(request: Request) { }); if (!deployRes.ok) { - // Avoid forwarding 200 bytes of raw Vercel error text — it may contain - // project IDs, team slugs, deployment hashes or internal Vercel error - // strings. Parse the canonical { error: { message } } shape and surface - // only the human-readable message (or a generic fallback). - let upstreamMessage = "Vercel API rejected the deployment"; - try { - const parsed = (await deployRes.json().catch(() => null)) as { - error?: { message?: string }; - } | null; - const candidate = parsed?.error?.message; - if (typeof candidate === "string" && candidate.trim()) { - upstreamMessage = candidate.trim().slice(0, 200); - } - } catch { - /* fall through to generic message */ - } - return createErrorResponse({ - status: deployRes.status, - message: `Vercel deployment failed: ${upstreamMessage}`, - type: "upstream_error", - }); + // Avoid forwarding raw Vercel error text — it may contain project IDs, + // team slugs, deployment hashes or internal Vercel error strings. + return buildDeployErrorResponse(deployRes); } const deployment = (await deployRes.json()) as { @@ -208,17 +276,17 @@ export async function POST(request: Request) { }); } - // Disable Vercel SSO protection so the relay is publicly accessible - if (deployment.projectId) { - await fetch(`${VERCEL_API_BASE}/v9/projects/${deployment.projectId}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ ssoProtection: null }), - }).catch(() => {}); - } + // Disable Vercel SSO protection so the relay is publicly accessible. + // The PATCH response is checked — if Vercel rejects/no-ops it (plan + // doesn't allow disabling protection, under-scoped token, etc.) the + // relay is still deployed and saved, but the caller is warned so a + // later `403 Access denied` can be diagnosed as Vercel-side deployment + // protection rather than an upstream provider rejection. + const ssoProtectionWarning = await resolveSsoProtectionWarning( + deployment.projectId, + VERCEL_API_BASE, + token + ); // Poll until READY const deploymentApiUrl = `${VERCEL_API_BASE}/v13/deployments/${deployment.id}`; @@ -254,6 +322,7 @@ export async function POST(request: Request) { success: true, relayUrl: `https://${deployment.url}`, poolProxyId: poolProxy?.id, + ...(ssoProtectionWarning ? { ssoProtectionWarning } : {}), }); } catch (error) { return createErrorResponseFromUnknown(error, "Vercel deploy failed"); diff --git a/tests/unit/vercel-deploy-sso-protection-check.test.ts b/tests/unit/vercel-deploy-sso-protection-check.test.ts new file mode 100644 index 0000000000..d3fb945e96 --- /dev/null +++ b/tests/unit/vercel-deploy-sso-protection-check.test.ts @@ -0,0 +1,95 @@ +// Regression guard for upstream report: "Vercel Relay with Codex returns 403 +// Access denied and lacks source diagnostics". +// +// Root cause: the Vercel deploy route disables project SSO/Deployment +// Protection via a PATCH request, but fired it with `.catch(() => {})` and +// never inspected `res.ok`. If Vercel rejects or no-ops the PATCH (plan +// doesn't allow disabling protection, stale/under-scoped token, etc.), the +// relay is still saved and activated as a healthy proxy pool — later +// requests routed through it fail with an undiagnosed `403 Access denied` +// from Vercel's own deployment protection, indistinguishable from an +// upstream-provider 403. +// +// Fix: check the PATCH response and surface the failure back to the caller +// (`ssoProtectionWarning` in the JSON response) instead of silently +// swallowing it, so the UI/API consumer can diagnose the 403 source. +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { __disableSsoProtectionForTest } from "../../src/app/api/settings/proxy/vercel-deploy/route"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const ROUTE_PATH = join( + ROOT, + "src/app/api/settings/proxy/vercel-deploy/route.ts" +); + +describe("disableSsoProtection — checks the Vercel PATCH response instead of swallowing it", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("reports failure when Vercel rejects the PATCH (e.g. plan does not allow disabling protection)", async () => { + global.fetch = (async () => + new Response(JSON.stringify({ error: { message: "Forbidden" } }), { + status: 403, + })) as typeof fetch; + + const result = await __disableSsoProtectionForTest( + "https://api.vercel.com", + "proj_123", + "test-token" + ); + + assert.equal(result.ok, false, "must report ok:false on a non-2xx PATCH response"); + assert.equal(result.status, 403); + }); + + it("reports success when Vercel accepts the PATCH", async () => { + global.fetch = (async () => new Response(null, { status: 200 })) as typeof fetch; + + const result = await __disableSsoProtectionForTest( + "https://api.vercel.com", + "proj_123", + "test-token" + ); + + assert.equal(result.ok, true); + }); + + it("reports failure (not a thrown exception) when the PATCH request itself fails", async () => { + global.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + + const result = await __disableSsoProtectionForTest( + "https://api.vercel.com", + "proj_123", + "test-token" + ); + + assert.equal(result.ok, false); + }); +}); + +describe("vercel-deploy route — wires the SSO-protection check into the response", () => { + const src = readFileSync(ROUTE_PATH, "utf8"); + + it("no longer fires the PATCH with a silent `.catch(() => {})`", () => { + assert.ok( + !/ssoProtection:\s*null[\s\S]*?\.catch\(\s*\(\)\s*=>\s*\{\s*\}\s*\)/.test(src), + "the ssoProtection PATCH must not be silently swallowed with .catch(() => {})" + ); + }); + + it("surfaces a warning in the JSON response when disabling SSO protection failed", () => { + assert.ok( + src.includes("ssoProtectionWarning"), + "POST handler must surface ssoProtectionWarning in the response payload when the PATCH failed" + ); + }); +}); From db5ee5995b316fd6befccb240f4d5c0b716393ed Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:14:05 -0300 Subject: [PATCH 30/33] fix(combos): reject oversized fusion panels before fan-out (port from 9router#1905) (#7120) A fusion combo fans every panel model out in parallel and buffers each model's full response text in memory simultaneously. With the runtime heap capped by Dockerfile's OMNIROUTE_MEMORY_MB (default 1024MB), a large panel (reported: ~73 models via an 'auto' combo with strategy: fusion) with sizable concurrent responses can exceed the heap ceiling and OOM-crash the whole container instead of failing one request. handleFusionChat now rejects panels above a configurable hard cap (FUSION_DEFAULTS.maxPanel = 40, overridable per-combo via fusionTuning.maxPanel) with a clean 400 before fan-out begins. Reported-by: Phong Vu (@fontvu) (https://github.com/decolua/9router/issues/1905) --- Dockerfile | 6 ++ changelog.d/fixes/1905-fusion-panel-oom.md | 1 + open-sse/services/fusion.ts | 23 ++++++ src/shared/validation/schemas/combo.ts | 5 ++ tests/unit/fusion-panel-size-cap-1905.test.ts | 79 +++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 changelog.d/fixes/1905-fusion-panel-oom.md create mode 100644 tests/unit/fusion-panel-size-cap-1905.test.ts diff --git a/Dockerfile b/Dockerfile index adf3cf5e91..98e0a3215b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,6 +114,12 @@ LABEL org.opencontainers.image.title="omniroute" \ ENV NODE_ENV=production ENV PORT=20128 ENV HOSTNAME=0.0.0.0 +# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight +# for large fusion-combo panels (many models fanned out in parallel, each +# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS +# .maxPanel, issue #1905). Override at `docker run` time with +# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel +# above the default cap. ENV OMNIROUTE_MEMORY_MB=1024 ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}" diff --git a/changelog.d/fixes/1905-fusion-panel-oom.md b/changelog.d/fixes/1905-fusion-panel-oom.md new file mode 100644 index 0000000000..68bc6dbfbc --- /dev/null +++ b/changelog.d/fixes/1905-fusion-panel-oom.md @@ -0,0 +1 @@ +- **fix(combos):** fusion combos now reject an oversized panel (>40 models by default, tunable via `fusionTuning.maxPanel`) with a clean 400 before fanning out, instead of buffering dozens of concurrent full responses in memory and OOM-crashing the whole container. (thanks @fontvu) diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index 000f5a21c5..d6e5f4fa1f 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -27,12 +27,20 @@ export const FUSION_DEFAULTS = { minPanel: 2, // answers needed before stragglers get a grace window stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever + // Hard cap on panel size (issue #1905). Every panel member is fanned out in + // parallel and its full response text buffered in memory simultaneously — + // with the runtime heap capped (Dockerfile OMNIROUTE_MEMORY_MB, default + // 1024MB), a large panel (reported: ~73 models) with sizable concurrent + // responses can exceed the heap ceiling and OOM-crash the whole process. + // Reject oversized panels up front with a clean 400 instead. + maxPanel: 40, } as const; export type FusionTuning = { minPanel?: number; stragglerGraceMs?: number; panelHardTimeoutMs?: number; + maxPanel?: number; }; type Body = Record; @@ -246,6 +254,21 @@ export async function handleFusionChat({ return handleSingleModel(body, panel[0]); } + // Reject an oversized panel BEFORE fan-out (issue #1905): fanning out N + // parallel calls and buffering N full response bodies at once is what + // drives the process into an OOM crash, not any one call in isolation. + const maxPanel = tuning?.maxPanel ?? FUSION_DEFAULTS.maxPanel; + if (panel.length > maxPanel) { + log.warn( + "FUSION", + `Combo "${comboName ?? ""}" panel=${panel.length} exceeds maxPanel=${maxPanel} — rejecting before fan-out (#1905)` + ); + return errorResponse( + 400, + `Fusion panel too large (${panel.length} models, max ${maxPanel}) — reduce the combo's target count or raise fusionTuning.maxPanel` + ); + } + const cfg = { minPanel: tuning?.minPanel ?? FUSION_DEFAULTS.minPanel, stragglerGraceMs: tuning?.stragglerGraceMs ?? FUSION_DEFAULTS.stragglerGraceMs, diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 5c00a1be08..fbe95188cd 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -227,6 +227,11 @@ export const comboRuntimeConfigSchema = z minPanel: z.coerce.number().int().min(1).max(50).optional(), stragglerGraceMs: z.coerce.number().int().min(0).max(120_000).optional(), panelHardTimeoutMs: z.coerce.number().int().min(1000).max(600_000).optional(), + // Hard cap on panel size (issue #1905) — see FUSION_DEFAULTS.maxPanel in + // open-sse/services/fusion.ts. Bounds how many models can be fanned out + // and buffered in memory concurrently before the container's heap ceiling + // is at risk. + maxPanel: z.coerce.number().int().min(1).max(200).optional(), }) .strict() .optional(), diff --git a/tests/unit/fusion-panel-size-cap-1905.test.ts b/tests/unit/fusion-panel-size-cap-1905.test.ts new file mode 100644 index 0000000000..6b9ac1b935 --- /dev/null +++ b/tests/unit/fusion-panel-size-cap-1905.test.ts @@ -0,0 +1,79 @@ +/** + * Regression test for upstream issue decolua/9router#1905. + * + * Reported symptom: a fusion combo populated with ~70+ panel models fans every + * member out in parallel (`open-sse/services/fusion.ts::handleFusionChat` → + * `Promise.all`-style fan-out via `collectPanel`), buffering each model's full + * response text in memory at once. With the runtime heap capped at 1024MB + * (Dockerfile `OMNIROUTE_MEMORY_MB`), a large panel with sizable concurrent + * responses can exceed the heap ceiling and crash the whole container with + * "FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript heap + * out of memory" instead of failing one request gracefully. + * + * Fix: `handleFusionChat` now rejects panels above a configurable hard cap + * (`FUSION_DEFAULTS.maxPanel`, overridable via `fusionTuning.maxPanel`) with a + * clean 400 *before* fan-out, rather than let an unbounded panel size drive + * the process into an OOM crash. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { handleFusionChat, FUSION_DEFAULTS } from "../../open-sse/services/fusion.ts"; + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +type Body = Record; + +test("fusion #1905: an oversized panel (73 models) is rejected before fan-out instead of OOM-crashing", async () => { + let calls = 0; + const handleSingleModel = (_b: Body, _m: string) => { + calls++; + const body = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "x".repeat(1000) } }], + }); + return Promise.resolve( + new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }) + ); + }; + + const panel = Array.from({ length: 73 }, (_, i) => `provider/model-${i}`); + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: panel, + handleSingleModel, + log, + comboName: "auto", + }); + + assert.equal(res.status, 400); + // Must reject BEFORE fan-out — no per-model calls should have happened. + assert.equal(calls, 0, "panel fan-out must not start once the size cap is exceeded"); + + const json = (await res.json()) as { error?: { message?: string } }; + assert.match(json.error?.message ?? "", /panel/i); +}); + +test("fusion #1905: a panel at or under the cap still fans out normally", async () => { + const handleSingleModel = (_b: Body, _m: string) => { + const body = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }); + return Promise.resolve( + new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }) + ); + }; + + const panel = Array.from({ length: FUSION_DEFAULTS.maxPanel }, (_, i) => `provider/model-${i}`); + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: panel, + handleSingleModel, + log, + comboName: "auto", + }); + + assert.equal(res.status, 200); +}); From dedf68023146bfa62c26efe77d32ba00c26491a1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:14:12 -0300 Subject: [PATCH 31/33] fix(combo): detect empty content_block in streaming SSE peek (#7121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(combo): detect empty content_block in streaming SSE peek (port from 9router#1382) The bounded SSE peek in validateResponseQuality() treated ANY content_block_start/delta/stop event as proof of real output and stopped buffering immediately, without checking whether the block actually carried text/tool_use content. Some upstreams (reported: DeepSeek, GLM via claude→openai translation) can open and close a text content_block with empty text and no tool_use on tool-heavy requests — the gateway logged success and forwarded a client-visible empty completion, and combo routing never failed over to the next model. Track real content separately from 'a content_block_* event was seen': a tool_use/redacted_thinking block start is self-evidently real signal, a text/thinking block start is not (real content only confirmed via a subsequent delta carrying non-empty text/thinking, or an input_json_delta streaming tool arguments). A completed lifecycle (message_start + message_delta/stop) that never produced real content now fails validateResponseQuality(), matching the existing content_filter empty-stream detection path (#3685). Reported-by: heishen6 (https://github.com/decolua/9router/issues/1382) * refactor(combo): extract SSE lifecycle applier to keep the complexity ratchets at baseline The #1382 empty-content_block peek added a branchy switch inline in parseAccumulatedSse, pushing check:complexity to 2057 > baseline 2056. Move the switch to a module-level applySseLifecycleEvent() and hold the four lifecycle booleans in a single SseLifecycleFlags object threaded through it, so the closure no longer copies flags in and out per event. The per-event predicates (content_block_start / content_block_delta / message_delta) are split into small guard helpers, which keeps the applier flat — cognitive complexity punishes nesting, and an earlier switch-only extraction traded the cyclomatic ratchet for a cognitive regression at 891 > 890. Logic is unchanged; both ratchets are now green (complexity 2055, cognitive-complexity 890) and the #1382 regression tests still pass. --- .../1382-streaming-empty-content-block.md | 1 + open-sse/services/combo/validateQuality.ts | 151 ++++++++++++++---- ...streaming-empty-content-block-1382.test.ts | 138 ++++++++++++++++ 3 files changed, 255 insertions(+), 35 deletions(-) create mode 100644 changelog.d/fixes/1382-streaming-empty-content-block.md create mode 100644 tests/unit/streaming-empty-content-block-1382.test.ts diff --git a/changelog.d/fixes/1382-streaming-empty-content-block.md b/changelog.d/fixes/1382-streaming-empty-content-block.md new file mode 100644 index 0000000000..c224cd2991 --- /dev/null +++ b/changelog.d/fixes/1382-streaming-empty-content-block.md @@ -0,0 +1 @@ +- **fix(combo):** streaming Claude responses whose content block opens (`content_block_start`) and closes with no usable text/tool_use — a shape some upstreams return for tool-heavy requests on HTTP 200 — are now detected by `validateResponseQuality`'s SSE peek and trigger combo failover instead of being forwarded to the client as a silent empty completion (thanks @heishen6). diff --git a/open-sse/services/combo/validateQuality.ts b/open-sse/services/combo/validateQuality.ts index 2b56146479..7f1a32b38a 100644 --- a/open-sse/services/combo/validateQuality.ts +++ b/open-sse/services/combo/validateQuality.ts @@ -54,6 +54,91 @@ function extractEnvelopeErrorText(json: Record): string | null return parts.length > 0 ? parts.join(" ") : null; } +/** Mutable lifecycle flags threaded through {@link applySseLifecycleEvent}. */ +interface SseLifecycleFlags { + hasMessageStart: boolean; + hasContentBlock: boolean; + hasRealContent: boolean; + hasLifecycleEnd: boolean; +} + +/** Read `parsed.` as a nested object bag, or null when absent/not an object. */ +function asObject(parsed: Record, key: string): Record | null { + const value = parsed[key]; + return value && typeof value === "object" ? (value as Record) : null; +} + +/** + * A content_block_start is real signal only for tool_use / redacted_thinking — + * a tool call is meaningful even before its input_json_delta arrives. text and + * thinking blocks routinely open empty; keep peeking for a delta instead. + */ +function contentBlockStartIsRealSignal(parsed: Record): boolean { + const blockType = asObject(parsed, "content_block")?.type; + return blockType === "tool_use" || blockType === "redacted_thinking"; +} + +/** + * A content_block_delta is real signal when it carries non-empty text/thinking, + * or any input_json_delta fragment — even an empty-string first chunk proves a + * tool_use block is actively streaming its arguments. + */ +function contentBlockDeltaIsRealSignal(parsed: Record): boolean { + const delta = asObject(parsed, "delta"); + if (!delta) return false; + const deltaType = typeof delta.type === "string" ? delta.type : ""; + if (deltaType === "input_json_delta") return true; + if (deltaType !== "text_delta" && deltaType !== "thinking_delta") return false; + const text = delta.text ?? delta.thinking; + return typeof text === "string" && text.length > 0; +} + +/** A message_delta closes the lifecycle once it carries a stop_reason. */ +function messageDeltaEndsLifecycle(parsed: Record): boolean { + return asObject(parsed, "delta")?.stop_reason != null; +} + +/** + * Apply a single parsed Claude SSE event to the peeked lifecycle `flags` + * (mutated in place). Extracted from `parseAccumulatedSse`'s inline switch to + * keep that function under the complexity/line ratchets — logic unchanged. + * + * Returns true once REAL content (not just an empty content_block_start) is + * detected — the caller should stop peeking and treat the stream as non-empty. + */ +function applySseLifecycleEvent( + eventType: string, + parsed: Record, + flags: SseLifecycleFlags +): boolean { + switch (eventType) { + case "message_start": + flags.hasMessageStart = true; + return false; + case "content_block_start": + flags.hasContentBlock = true; + if (!contentBlockStartIsRealSignal(parsed)) return false; + flags.hasRealContent = true; + return true; + case "content_block_delta": + flags.hasContentBlock = true; + if (!contentBlockDeltaIsRealSignal(parsed)) return false; + flags.hasRealContent = true; + return true; + case "content_block_stop": + flags.hasContentBlock = true; + return false; + case "message_stop": + flags.hasLifecycleEnd = true; + return false; + case "message_delta": + if (messageDeltaEndsLifecycle(parsed)) flags.hasLifecycleEnd = true; + return false; + default: + return false; + } +} + function responsesApiOutputHasContent(output: unknown): boolean { return ( Array.isArray(output) && @@ -125,9 +210,22 @@ export async function validateResponseQuality( let decodedSoFar = ""; // SSE lifecycle state. - let hasMessageStart = false; - let hasContentBlock = false; - let hasLifecycleEnd = false; + // + // #1382: hasContentBlock only means "a content_block_* event was observed" + // — it does NOT mean the block carried usable content. A content_block_start + // for a text/thinking block routinely opens with empty text (real content + // arrives via subsequent content_block_delta events); some upstreams + // (reported: DeepSeek/GLM via claude→openai translation on tool-heavy + // requests) open and close such a block without ever emitting a delta. + // hasRealContent tracks whether we've actually seen usable output: a + // tool_use/redacted_thinking block start (self-evidently real, even before + // any delta), or a delta carrying non-empty text/thinking/tool-input. + const sse: SseLifecycleFlags = { + hasMessageStart: false, + hasContentBlock: false, + hasRealContent: false, + hasLifecycleEnd: false, + }; let anyContentFound = false; let sawAnyBytes = false; const sseLineNormalizer = createSSEDataLineNormalizer(); @@ -138,8 +236,9 @@ export async function validateResponseQuality( * flags in the closure. The last (potentially incomplete) line is kept in * `decodedSoFar` for the next iteration. * - * Returns true when a content_block_* event is detected — the caller - * should stop peeking and treat the stream as non-empty. + * Returns true once REAL content (not just an empty content_block_start) + * is detected — the caller should stop peeking and treat the stream as + * non-empty. */ function parseAccumulatedSse(): boolean { const lines = decodedSoFar.split(/\r?\n/); @@ -177,32 +276,8 @@ export async function validateResponseQuality( return true; } - switch (eventType) { - case "message_start": - hasMessageStart = true; - break; - case "content_block_start": - case "content_block_delta": - case "content_block_stop": - hasContentBlock = true; - // Signal caller to stop buffering immediately. - return true; - case "message_stop": - hasLifecycleEnd = true; - break; - case "message_delta": { - const delta = parsed.delta; - if ( - delta && - typeof delta === "object" && - (delta as Record).stop_reason != null - ) { - hasLifecycleEnd = true; - } - break; - } - default: - break; + if (applySseLifecycleEvent(eventType, parsed, sse)) { + return true; } } return false; @@ -258,11 +333,17 @@ export async function validateResponseQuality( if (decodedSoFar.trim()) decodedSoFar += "\n\n"; parseAccumulatedSse(); - if (hasMessageStart && hasLifecycleEnd && !hasContentBlock) { - // Complete Claude lifecycle with zero content blocks → failover. + if (sse.hasMessageStart && sse.hasLifecycleEnd && !sse.hasRealContent) { + // Complete Claude lifecycle with zero content blocks, or with + // content_block_start/stop pairs that never carried real text/ + // thinking/tool_use content (#1382 — tool-heavy claude→openai + // requests against upstreams like DeepSeek/GLM can "complete" a + // lifecycle around an empty block) → failover. log.warn?.( "COMBO", - "Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover" + sse.hasContentBlock + ? "Streaming Claude response has complete lifecycle but its content block(s) carried no usable text/tool_use — marking as invalid for combo failover" + : "Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover" ); return { valid: false, reason: "streaming empty content block" }; } @@ -273,7 +354,7 @@ export async function validateResponseQuality( // (an explicit `data: [DONE]`, ping/metadata events, an incomplete // Claude lifecycle) keep the pass-through contract (#3399/#3685): // those are handled by the stream-readiness timeout, not failover. - if (!anyContentFound && !hasContentBlock && !sawAnyBytes) { + if (!anyContentFound && !sse.hasContentBlock && !sawAnyBytes) { log.warn?.( "COMBO", "Streaming response ended with no recognized content — marking as invalid for combo failover" diff --git a/tests/unit/streaming-empty-content-block-1382.test.ts b/tests/unit/streaming-empty-content-block-1382.test.ts new file mode 100644 index 0000000000..750d7d673a --- /dev/null +++ b/tests/unit/streaming-empty-content-block-1382.test.ts @@ -0,0 +1,138 @@ +/** + * Issue #1382 (upstream decolua/9router) — a streaming Claude response that + * opens a `content_block_start` (type "text", initial text "") and then + * immediately `content_block_stop`s WITHOUT ever emitting a + * `content_block_delta` carrying real text/tool_use content must be treated + * as an empty/malformed response, not a valid completion. + * + * Before this fix, `validateResponseQuality`'s bounded SSE peek stopped + * buffering (and reported `valid: true`) as soon as ANY content_block_* + * event was observed — including a content_block_start whose block never + * carries usable text. Tool-heavy requests against backends that mishandle + * tool definitions (reported: DeepSeek, GLM via claude→openai translation) + * can emit exactly this shape: a lifecycle that "completes" successfully at + * the transport layer while the client receives no usable content. The + * combo loop never saw this as a failure, so no failover to the next model + * in the combo ever happened. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { validateResponseQuality } = await import("../../open-sse/services/combo.ts"); + +const encoder = new TextEncoder(); +const silentLog = { warn: () => {} }; + +function claudeSseStream(events: string[]): ReadableStream { + const body = events.join("\n") + "\n"; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); +} + +/** + * Build a mock Claude 200 streaming response with a content_block_start/stop + * pair carrying EMPTY text and no tool_use block — the shape reported in + * #1382 for tool-heavy claude→openai requests against DeepSeek/GLM. + */ +function makeEmptyTextBlockStream(): Response { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_1382", + type: "message", + role: "assistant", + model: "deepseek-v4-pro-max", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 19882, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 25 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + return new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +test("#1382 streaming Claude response with empty content_block (no text, no tool_use) is marked invalid", async () => { + const res = makeEmptyTextBlockStream(); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal( + out.valid, + false, + `expected invalid for empty content_block stream, got valid=true (reason: ${out.reason})` + ); + assert.match(out.reason ?? "", /empty/i, `reason should mention 'empty', got: "${out.reason}"`); +}); + +test("#1382 streaming Claude response with a real tool_use content_block_start remains valid", async () => { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_1382_tool", + type: "message", + role: "assistant", + model: "deepseek-v4-pro-max", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 100, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_1", name: "Bash", input: {} }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "tool_use", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 12 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + const res = new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal(out.valid, true, `expected valid for tool_use stream, got invalid: ${out.reason}`); + assert.ok(out.clonedResponse, "clonedResponse must be present for valid streaming response"); +}); From 8e9cff3145dec1303a560f05ca9f12e7cde688c7 Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:14:20 -0700 Subject: [PATCH 32/33] fix(auto): use p95 fallback in speed factors (#7128) Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- .../autoCombo/__tests__/speedRanking.test.ts | 29 +++++++++++++++---- open-sse/services/autoCombo/speedRanking.ts | 10 +++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/open-sse/services/autoCombo/__tests__/speedRanking.test.ts b/open-sse/services/autoCombo/__tests__/speedRanking.test.ts index 1599e36b82..e3a4ad85f2 100644 --- a/open-sse/services/autoCombo/__tests__/speedRanking.test.ts +++ b/open-sse/services/autoCombo/__tests__/speedRanking.test.ts @@ -165,15 +165,34 @@ describe("rankBySpeed — factor breakdown", () => { }); it("falls back to 0.5 per missing metric so new providers are not crushed", () => { - const ranked = rankBySpeed([candidate({ provider: "fresh", model: "m" })]); + const ranked = rankBySpeed([ + candidate({ + provider: "fresh", + model: "m", + p95LatencyMs: undefined, + latencyStdDev: undefined, + }), + ]); expect(ranked).toHaveLength(1); // No telemetry at all → weighted sum lands near 0.5 with reliability multiplier 1 expect(ranked[0].factors.reliability).toBe(1); expect(ranked[0].factors.health).toBe(1); expect(ranked[0].factors.ttft).toBe(0.5); - expect(ranked[0].factors.tps).toBe(0.5); - }); -}); + expect(ranked[0].factors.tps).toBe(0.5); + }); + + it("uses p95 latency when TTFT and E2E telemetry are unavailable", () => { + const ranked = rankBySpeed([ + candidate({ provider: "slow-tail", model: "m", p95LatencyMs: 4000 }), + candidate({ provider: "fast-tail", model: "m", p95LatencyMs: 1000 }), + ]); + const fast = ranked.find((entry) => entry.provider === "fast-tail"); + const slow = ranked.find((entry) => entry.provider === "slow-tail"); + + expect(fast?.factors.ttft).toBeGreaterThan(slow?.factors.ttft ?? 1); + expect(fast?.factors.e2e).toBeGreaterThan(slow?.factors.e2e ?? 1); + }); +}); describe("rankBySpeed — weight overrides", () => { it("respects caller weight overrides (e.g. heavy TTFT bias)", () => { @@ -223,4 +242,4 @@ describe("pickFastest", () => { const winner = pickFastest([slow, fast]); expect(winner?.provider).toBe("fast"); }); -}); \ No newline at end of file +}); diff --git a/open-sse/services/autoCombo/speedRanking.ts b/open-sse/services/autoCombo/speedRanking.ts index 514420a5a0..a3d7d81027 100644 --- a/open-sse/services/autoCombo/speedRanking.ts +++ b/open-sse/services/autoCombo/speedRanking.ts @@ -211,9 +211,15 @@ function speedFactorsFor( failureRate: number ): SpeedFactors { return { - ttft: lowerIsBetter(positiveFinite(candidate.avgTtftMs), maxima.ttft), + ttft: lowerIsBetter( + positiveFinite(candidate.avgTtftMs) ?? positiveFinite(candidate.p95LatencyMs), + maxima.ttft + ), tps: higherIsBetter(positiveFinite(candidate.avgTokensPerSecond), maxima.tps), - e2e: lowerIsBetter(positiveFinite(candidate.avgE2ELatencyMs), maxima.e2e), + e2e: lowerIsBetter( + positiveFinite(candidate.avgE2ELatencyMs) ?? positiveFinite(candidate.p95LatencyMs), + maxima.e2e + ), p95: lowerIsBetter(positiveFinite(candidate.p95LatencyMs), maxima.p95), health: healthScoreFor(candidate.circuitBreakerState), reliability: clamp01(1 - failureRate), From fd468b5ef190fdf81739313adfb599f71fc331f4 Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:14:27 -0700 Subject: [PATCH 33/33] Use OpenAI chunks for early chat keepalives (#7136) * Use OpenAI chunks for early chat keepalives * Update keepalive assertion to match chat completion chunk format --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/utils/earlyStreamKeepalive.ts | 6 ++++ src/app/api/v1/chat/completions/route.ts | 6 +++- tests/unit/chat-combo-live-test.test.ts | 5 +++- tests/unit/early-stream-keepalive.test.ts | 34 ++++++++++++++++++++++- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index c70180f927..729049c9a3 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -28,6 +28,12 @@ const ENCODER = new TextEncoder(); const KEEPALIVE_FRAME = ENCODER.encode(": omniroute-keepalive\n\n"); +// OpenAI-compatible keepalive: a syntactically valid empty streaming chunk. +// Some OpenAI-compatible clients parse every non-empty SSE line as JSON and +// reject legal SSE comments before their first provider chunk arrives. +export const OPENAI_KEEPALIVE_FRAME = ENCODER.encode( + 'data: {"id":"omniroute-keepalive","object":"chat.completion.chunk","created":0,"model":"omniroute","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' +); // Anthropic Messages-format keepalive: a REAL `ping` SSE event, not a comment. // Anthropic clients (Claude Code, the Anthropic SDK) reset their stream/first-token // watchdog on real SSE events but ignore SSE comments (`: ...`), so on a slow first diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index bddf765d0f..2915d4730b 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -5,7 +5,10 @@ import { generateRequestId } from "@/shared/utils/requestId"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat.ts"; -import { withEarlyStreamKeepalive } from "@omniroute/open-sse/utils/earlyStreamKeepalive"; +import { + OPENAI_KEEPALIVE_FRAME, + withEarlyStreamKeepalive, +} from "@omniroute/open-sse/utils/earlyStreamKeepalive"; import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold"; import { checkChatAdmission } from "@/shared/middleware/chatBodyAdmission"; import { @@ -132,6 +135,7 @@ export async function POST(request) { { signal: request.signal, thresholdMs: resolveKeepaliveThreshold(parsedBody?.model), + keepaliveFrame: OPENAI_KEEPALIVE_FRAME, extraHeaders: { "X-Correlation-Id": reqId }, } ); diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts index a734b02bc4..79f8a35a32 100644 --- a/tests/unit/chat-combo-live-test.test.ts +++ b/tests/unit/chat-combo-live-test.test.ts @@ -274,7 +274,10 @@ test("chat completions route emits early keepalive while waiting for stream read assert.match(response.headers.get("content-type") || "", /text\/event-stream/); const body = await readAll(response); - assert.match(body, /: omniroute-keepalive/); + assert.match( + body, + /data: \{"id":"omniroute-keepalive","object":"chat\.completion\.chunk"/ + ); assert.match(body, /OK/); assert.match(body, /\[DONE\]/); }); diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts index a75c4c2764..5cd04ca192 100644 --- a/tests/unit/early-stream-keepalive.test.ts +++ b/tests/unit/early-stream-keepalive.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { withEarlyStreamKeepalive, ANTHROPIC_PING_FRAME, + OPENAI_KEEPALIVE_FRAME, } from "../../open-sse/utils/earlyStreamKeepalive.ts"; async function readAll(response: Response): Promise { @@ -68,9 +69,40 @@ test("ANTHROPIC_PING_FRAME is a real Anthropic ping event (not a comment)", () = assert.doesNotMatch(decoded, /^:/, "must not be an SSE comment"); }); +test("OPENAI_KEEPALIVE_FRAME is a JSON-parseable OpenAI streaming chunk", () => { + const decoded = new TextDecoder().decode(OPENAI_KEEPALIVE_FRAME); + assert.match(decoded, /^data: /); + assert.doesNotMatch(decoded, /^:/, "must not be an SSE comment"); + + const payload = JSON.parse(decoded.slice("data: ".length).trim()); + assert.equal(payload.object, "chat.completion.chunk"); + assert.deepEqual(payload.choices, [{ index: 0, delta: {}, finish_reason: null }]); +}); + +test("slow handler emits the custom OpenAI keepalive chunk before the body", async () => { + const slow = new Promise((resolve) => { + setTimeout(() => resolve(sseResponse("data: [DONE]\n\n")), 120); + }); + + const result = await withEarlyStreamKeepalive(slow, { + thresholdMs: 25, + intervalMs: 20, + keepaliveFrame: OPENAI_KEEPALIVE_FRAME, + }); + + const body = await readAll(result); + assert.doesNotMatch(body, /: omniroute-keepalive/); + const firstFrame = body.split("\n\n")[0]; + assert.doesNotThrow(() => JSON.parse(firstFrame.slice("data: ".length))); + assert.match(body, /data: \[DONE\]/); +}); + test("slow handler emits the custom keepaliveFrame (Anthropic ping) before the body", async () => { const slow = new Promise((resolve) => { - setTimeout(() => resolve(sseResponse("event: message_start\ndata: {}\n\ndata: [DONE]\n\n")), 120); + setTimeout( + () => resolve(sseResponse("event: message_start\ndata: {}\n\ndata: [DONE]\n\n")), + 120 + ); }); const result = await withEarlyStreamKeepalive(slow, {