Merge branch 'release/v3.8.49' into feat/port-pr-2371-provider-quota-visibility

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-16 15:48:58 -03:00
committed by GitHub
107 changed files with 3702 additions and 420 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, ProviderErrorRule[]>([
["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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,91 @@ function extractEnvelopeErrorText(json: Record<string, unknown>): 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.<key>` as a nested object bag, or null when absent/not an object. */
function asObject(parsed: Record<string, unknown>, key: string): Record<string, unknown> | null {
const value = parsed[key];
return value && typeof value === "object" ? (value as Record<string, unknown>) : 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>,
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<string, unknown>).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"

View File

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

View File

@@ -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<string, unknown>;
@@ -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,

View File

@@ -525,6 +525,7 @@ export const USAGE_FETCHER_PROVIDERS = [
"zai",
"glmt",
"opencode-go",
"ollama-cloud",
"minimax",
"minimax-cn",
"crof",

View File

@@ -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 `(?<!` must be dropped
// before the schema reaches the Codex/OpenAI upstream.
const REGEX_LOOKAROUND_PATTERN = /\(\?<?[=!]/;
function hasUnsupportedRegexLookaround(pattern: unknown): boolean {
return typeof pattern === "string" && REGEX_LOOKAROUND_PATTERN.test(pattern);
}
function isPlainObject(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -81,6 +93,11 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
delete result.default;
}
// Fix (9router#1556): drop unsupported regex lookaround from `pattern`.
if (hasUnsupportedRegexLookaround(result.pattern)) {
delete result.pattern;
}
for (const field of NUMERIC_SCHEMA_FIELDS) {
if (field in result) {
result[field] = coerceNumericString(result[field]);
@@ -142,6 +159,70 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
return result;
}
// Sub-schema maps keyed by property name (each value is itself walked recursively).
const REGEX_STRIP_OBJECT_MAP_FIELDS = [
"properties",
"patternProperties",
"definitions",
"$defs",
] as const;
// Sub-schema lists (each entry is itself walked recursively).
const REGEX_STRIP_ARRAY_MAP_FIELDS = ["prefixItems", "anyOf", "oneOf", "allOf"] as const;
/** Recursively strips unsupported regex lookaround from every value of an object map field. */
function stripRegexFromObjectMap(record: JsonRecord): JsonRecord {
return Object.fromEntries(
Object.entries(record).map(([key, value]) => [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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,15 +126,28 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul
const args: Record<string, unknown> = {};
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);

View File

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

View File

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

View File

@@ -37,7 +37,6 @@ export type PassthroughTailProcessorContext = {
appendPassthroughReasoning: (value: string) => void;
getResponsesReasoningKey: (payload: Record<string, unknown>) => string | null;
markResponsesReasoningSummarySeen: (key: string) => void;
ensureVisibleResponsesReasoningSummary: (payload: Record<string, unknown>) => boolean;
emitSyntheticResponsesReasoningSummary: (payload: Record<string, unknown>) => void;
passthroughResponsesOutputItems: unknown[];
passthroughResponsesPendingFunctionCalls: Map<string, JsonRecord>;
@@ -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);

View File

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

View File

@@ -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, unknown>): string => {
return Array.isArray(item.summary)
? item.summary
.map((part) => {
if (!part || typeof part !== "object" || Array.isArray(part)) {
return "";
}
return typeof (part as Record<string, unknown>).text === "string"
? ((part as Record<string, unknown>).text as string)
: "";
})
.join("")
: "";
};
const ensureVisibleResponsesReasoningSummary = (payload: Record<string, unknown>): boolean => {
const item =
payload.item && typeof payload.item === "object" && !Array.isArray(payload.item)
? (payload.item as Record<string, unknown>)
: 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<string, unknown>
@@ -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<string, unknown>) =>
emitSyntheticResponsesReasoningSummary(controller, payload),
passthroughResponsesOutputItems,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CURRENT> onto the
// freshly cut release/v<NEXT>, 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 <current> <next> [--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<next> 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<next> 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 <current> <next> [--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 <N> --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));
}

View File

@@ -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."
---
<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->
## 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 <tool>`
Show current config for a tool
**Flags:**
- `--json`
**Example:**
```bash
omniroute config get <tool>
```
### `config set <tool>`
Write config for a tool
**Flags:**
- `--model <model>`
- `--non-interactive`
- `--yes`
**Example:**
```bash
omniroute config set <tool>
```
### `config validate <tool>`
Validate config format without writing
**Flags:**
- `--model <model>`
- `--json`
**Example:**
```bash
omniroute config validate <tool>
```
### `config opencode`
Generate OpenCode config (alias for
**Flags:**
- `--model <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 <code>`
**Flags:**
- `--force`
**Example:**
```bash
omniroute config set <code>
```
### `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 <key>`
Get a single environment variable
**Example:**
```bash
omniroute env get <key>
```
### `env set <key> <value>`
Set an environment variable (current session only)
**Example:**
```bash
omniroute env set <key> <value>
```
### `setup`
**Flags:**
- `--password <value>`
- `--add-provider`
- `--provider <id>`
- `--provider-name <name>`
- `--api-key <value>`
- `--default-model <model>`
- `--provider-base-url <url>`
- `--test-provider`
- `--non-interactive`
- `--list`
**Example:**
```bash
omniroute setup
```
### `update`
**Flags:**
- `--check`
- `--apply`
- `--changelog`
- `--dry-run`
- `--no-backup`
- `--yes`
**Example:**
```bash
omniroute update
```
<!-- skill:custom-start -->
<!-- Preserved curated content from #6294 (skills/cli-skill-collector authored workflow) -->
# /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.
<!-- skill:custom-end -->

View File

@@ -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<string | undefined> {
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");

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,7 @@ async function forwardToBifrost(
body: unknown,
token: RelayToken,
config: BifrostRoutingConfig,
backend: ReturnType<typeof resolveRelayRoutingBackend>,
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

View File

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

View File

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

View File

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

View File

@@ -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<string>();
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<boolean> {
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<void>;
removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise<void>;
collectManagedHosts: () => string[];
},
sudoPassword: string
): Promise<void> {
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<void> {
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<void>;
removeDNSEntries?: (hosts: string[], sudoPassword: string) => Promise<void>;
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

115
src/mitm/repair.ts Normal file
View File

@@ -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<string>();
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<boolean> {
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<string[]> {
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;
}

View File

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

View File

@@ -53,6 +53,14 @@ const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
/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,
];
/**

View File

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

View File

@@ -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 <!-- skill:custom-start --> ... <!-- skill:custom-end --> blocks:
* 3. 12 specific IDs have <!-- skill:custom-start --> ... <!-- skill:custom-end --> 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(", ")}`,
);
});

View File

@@ -69,8 +69,8 @@ test("every CLI skill ID has skills/<id>/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 () => {

View File

@@ -0,0 +1,303 @@
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<string, unknown>;
};
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) {
// 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.message : String(error));
}
});
await new Promise<void>((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<void>((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<string, unknown>,
});
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 });
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:<file>` 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 = [
{

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> = {
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<Record<string, unknown>>;
const parameters = tools[0].parameters as Record<string, unknown>;
const properties = parameters.properties as Record<string, unknown>;
const emailSchema = properties.email as Record<string, unknown>;
assert.equal(
emailSchema.pattern,
undefined,
"lookaround pattern must be stripped, not forwarded upstream"
);
});
test("normalizeCodexTools preserves plain (non-lookaround) regex patterns", () => {
const body: Record<string, unknown> = {
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<Record<string, unknown>>;
const parameters = tools[0].parameters as Record<string, unknown>;
const properties = parameters.properties as Record<string, unknown>;
const zipSchema = properties.zip as Record<string, unknown>;
assert.equal(zipSchema.pattern, "^[0-9]{5}$");
});

View File

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

View File

@@ -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<string, unknown>[] {
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<string, unknown> = {
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);
});
});

View File

@@ -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<string> {
@@ -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<Response>((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<Response>((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, {

View File

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

View File

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

View File

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

View File

@@ -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<string, string | undefined>[] = [
{},
{ 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);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 `<div>` 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<string, number> = {
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`
);
});

View File

@@ -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<string, unknown>;
assert.equal(assistantMsg.reasoning_content, " ");
});
});

View File

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

Some files were not shown because too many files have changed in this diff Show More