From dfbc89f97b6c27ea34fcdb220d0cac6aa630ecb6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:49:21 -0300 Subject: [PATCH 01/21] test(security): parse Kimi Web URL host instead of substring match (CodeQL #689) (#5928) Alert js/incomplete-url-substring-sanitization: the Kimi Web executor test asserted result.url.includes("www.kimi.com"), which a hostile host like www.kimi.com.evil.net would also satisfy. Parse the URL and assert on the exact hostname (new URL(result.url).hostname === "www.kimi.com"), which is both a stronger check and clears the CodeQL warning. --- tests/unit/web-cookie-providers-new.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/web-cookie-providers-new.test.ts b/tests/unit/web-cookie-providers-new.test.ts index 1a9d340484..2cc59bcd23 100644 --- a/tests/unit/web-cookie-providers-new.test.ts +++ b/tests/unit/web-cookie-providers-new.test.ts @@ -679,8 +679,13 @@ test("Kimi Web: targets www.kimi.com (international)", async () => { credentials: { apiKey: "kimi-auth=eyJ.eyJzdWI.signature" }, }); assert.ok(result.response instanceof Response); - assert.ok(result.url.includes("www.kimi.com"), `got ${result.url}`); - assert.ok(!result.url.includes("moonshot.cn")); + // Parse the URL and assert on the exact hostname rather than a substring + // match — `includes("www.kimi.com")` would also accept a hostile host like + // `www.kimi.com.evil.net` or `evil.net/?x=www.kimi.com` (CodeQL + // js/incomplete-url-substring-sanitization). + const host = new URL(result.url).hostname; + assert.equal(host, "www.kimi.com", `got ${result.url}`); + assert.notEqual(host, "www.moonshot.cn", `got ${result.url}`); } finally { restore.restore(); } From 2e75ed28a4825fdeaee3c547452d95ab4b5efac1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:15:39 -0300 Subject: [PATCH 02/21] refactor(translator): extract thinking-budget fitting from openai-to-claude (#5932) Extract the thinking-budget fitting cluster (fitThinkingToMaxTokens + private safeCapMaxOutputTokens + MIN_* constants) verbatim into the pure leaf openai-to-claude/thinkingBudget.ts. Host re-exports fitThinkingToMaxTokens so external importers keep working and imports it back for internal use. Host 822 -> 738 LOC (under the 800 cap). No behavior change: byte-identical bodies, public export set unchanged. Adds a split-guard test; all consumer tests stay green (translator-openai-to-claude, strip-empty, minimax-m3, passthrough). --- .../translator/request/openai-to-claude.ts | 92 +------------------ .../openai-to-claude/thinkingBudget.ts | 89 ++++++++++++++++++ ...ai-to-claude-thinking-budget-split.test.ts | 38 ++++++++ 3 files changed, 131 insertions(+), 88 deletions(-) create mode 100644 open-sse/translator/request/openai-to-claude/thinkingBudget.ts create mode 100644 tests/unit/openai-to-claude-thinking-budget-split.test.ts diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 2180bef00e..d5e9c055ec 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -6,8 +6,8 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; -import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; +import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; // Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer // adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. @@ -36,93 +36,9 @@ function applyCopilotSummarizedThinkingDisplay( }; } -// Anthropic constraints for the thinking + max_tokens contract: -// - thinking.budget_tokens must be >= 1024 when thinking is enabled -// - max_tokens must be > thinking.budget_tokens (covers thinking + response) -// - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7) -const MIN_CLAUDE_THINKING_BUDGET = 1024; -const MIN_RESPONSE_ROOM = 1024; - -function safeCapMaxOutputTokens(model: string): number | null { - try { - const cap = capMaxOutputTokens(model); - return typeof cap === "number" && cap > 0 ? cap : null; - } catch { - return null; - } -} - -/** - * Fit Claude thinking budget within the model's max output cap. - * - * Replaces the previous unconditional `max_tokens = budget + 8192` inflation, - * which could exceed the model output cap (e.g. Opus 4.7's 128000 ceiling) and - * trigger HTTP 400 from Anthropic ("max_tokens > 128000"). - * - * Strategy (preserves caller intent up to the model cap): - * - Preserve caller's max_tokens as response room (floored to MIN_RESPONSE_ROOM) - * - Target max_tokens = responseRoom + requestedBudget, capped at modelCap - * - fittedBudget = max_tokens - responseRoom (the thinking budget actually used) - * - If the cap squeezes fittedBudget below the Anthropic minimum, retry with - * responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable - * thinking entirely (cap too tight for any reasoning). - * - * Worked example (real-world Opus 4.7 case that previously 400'd): - * caller max_tokens = 32000, reasoning_effort=high → budget = 131072, - * model cap = 128000. - * responseRoom = max(32000, 1024) = 32000 - * target = min(32000 + 131072, 128000) = 128000 - * fittedBudget = 128000 - 32000 = 96000 (>= 1024, OK) - * → max_tokens=128000, budget_tokens=96000 (vs. the old buggy 139264 / 131072). - */ -export function fitThinkingToMaxTokens( - model: string, - callerMaxTokens: number, - thinking: Record | undefined -): { maxTokens: number; thinking: Record | undefined } { - const modelCap = safeCapMaxOutputTokens(model); - const requestedBudget = Number(thinking?.budget_tokens) || 0; - - // No budgeted thinking — just cap max_tokens to the model output ceiling. - if (!thinking || requestedBudget <= 0) { - return { - maxTokens: - modelCap === null - ? Math.max(callerMaxTokens, 1) - : Math.min(Math.max(callerMaxTokens, 1), modelCap), - thinking, - }; - } - - let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); - let target = - modelCap === null - ? responseRoom + requestedBudget - : Math.min(responseRoom + requestedBudget, modelCap); - let fittedBudget = target - responseRoom; - - // If the cap squeezed thinking below Anthropic's floor, try shrinking - // response room to MIN_RESPONSE_ROOM to recover budget. - if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) { - responseRoom = MIN_RESPONSE_ROOM; - target = - modelCap === null - ? responseRoom + requestedBudget - : Math.min(responseRoom + requestedBudget, modelCap); - fittedBudget = target - responseRoom; - } - - // Cap too tight for any thinking — disable rather than send an invalid request. - if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) { - return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined }; - } - - const adjustedThinking: Record = { ...thinking }; - if (fittedBudget < requestedBudget) { - adjustedThinking.budget_tokens = fittedBudget; - } - return { maxTokens: target, thinking: adjustedThinking }; -} +// Thinking-budget fitting extracted to a pure leaf; re-exported for external +// importers (tests). Host also uses fitThinkingToMaxTokens internally. +export { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; type ClaudeContentBlock = Record; type ClaudeMessage = { diff --git a/open-sse/translator/request/openai-to-claude/thinkingBudget.ts b/open-sse/translator/request/openai-to-claude/thinkingBudget.ts new file mode 100644 index 0000000000..e78275570f --- /dev/null +++ b/open-sse/translator/request/openai-to-claude/thinkingBudget.ts @@ -0,0 +1,89 @@ +import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts"; + +// Anthropic constraints for the thinking + max_tokens contract: +// - thinking.budget_tokens must be >= 1024 when thinking is enabled +// - max_tokens must be > thinking.budget_tokens (covers thinking + response) +// - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7) +const MIN_CLAUDE_THINKING_BUDGET = 1024; +const MIN_RESPONSE_ROOM = 1024; + +function safeCapMaxOutputTokens(model: string): number | null { + try { + const cap = capMaxOutputTokens(model); + return typeof cap === "number" && cap > 0 ? cap : null; + } catch { + return null; + } +} + +/** + * Fit Claude thinking budget within the model's max output cap. + * + * Replaces the previous unconditional `max_tokens = budget + 8192` inflation, + * which could exceed the model output cap (e.g. Opus 4.7's 128000 ceiling) and + * trigger HTTP 400 from Anthropic ("max_tokens > 128000"). + * + * Strategy (preserves caller intent up to the model cap): + * - Preserve caller's max_tokens as response room (floored to MIN_RESPONSE_ROOM) + * - Target max_tokens = responseRoom + requestedBudget, capped at modelCap + * - fittedBudget = max_tokens - responseRoom (the thinking budget actually used) + * - If the cap squeezes fittedBudget below the Anthropic minimum, retry with + * responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable + * thinking entirely (cap too tight for any reasoning). + * + * Worked example (real-world Opus 4.7 case that previously 400'd): + * caller max_tokens = 32000, reasoning_effort=high → budget = 131072, + * model cap = 128000. + * responseRoom = max(32000, 1024) = 32000 + * target = min(32000 + 131072, 128000) = 128000 + * fittedBudget = 128000 - 32000 = 96000 (>= 1024, OK) + * → max_tokens=128000, budget_tokens=96000 (vs. the old buggy 139264 / 131072). + */ +export function fitThinkingToMaxTokens( + model: string, + callerMaxTokens: number, + thinking: Record | undefined +): { maxTokens: number; thinking: Record | undefined } { + const modelCap = safeCapMaxOutputTokens(model); + const requestedBudget = Number(thinking?.budget_tokens) || 0; + + // No budgeted thinking — just cap max_tokens to the model output ceiling. + if (!thinking || requestedBudget <= 0) { + return { + maxTokens: + modelCap === null + ? Math.max(callerMaxTokens, 1) + : Math.min(Math.max(callerMaxTokens, 1), modelCap), + thinking, + }; + } + + let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); + let target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); + let fittedBudget = target - responseRoom; + + // If the cap squeezed thinking below Anthropic's floor, try shrinking + // response room to MIN_RESPONSE_ROOM to recover budget. + if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) { + responseRoom = MIN_RESPONSE_ROOM; + target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); + fittedBudget = target - responseRoom; + } + + // Cap too tight for any thinking — disable rather than send an invalid request. + if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) { + return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined }; + } + + const adjustedThinking: Record = { ...thinking }; + if (fittedBudget < requestedBudget) { + adjustedThinking.budget_tokens = fittedBudget; + } + return { maxTokens: target, thinking: adjustedThinking }; +} diff --git a/tests/unit/openai-to-claude-thinking-budget-split.test.ts b/tests/unit/openai-to-claude-thinking-budget-split.test.ts new file mode 100644 index 0000000000..aa3076cd19 --- /dev/null +++ b/tests/unit/openai-to-claude-thinking-budget-split.test.ts @@ -0,0 +1,38 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the openai-to-claude thinking-budget extraction. +// `fitThinkingToMaxTokens` (+ its private helpers safeCapMaxOutputTokens / MIN_*) +// live in the pure leaf `openai-to-claude/thinkingBudget.ts`; the host re-exports +// the public symbol so external importers (tests) keep working unchanged. +const HERE = dirname(fileURLToPath(import.meta.url)); +const REQ = join(HERE, "../../open-sse/translator/request"); +const HOST = join(REQ, "openai-to-claude.ts"); +const LEAF = join(REQ, "openai-to-claude/thinkingBudget.ts"); + +test("leaf hosts fitThinkingToMaxTokens and does not import the host", () => { + const leaf = readFileSync(LEAF, "utf8"); + assert.match(leaf, /export function fitThinkingToMaxTokens\(/); + assert.match(leaf, /function safeCapMaxOutputTokens\(/); + assert.doesNotMatch(leaf, /from "\.\.\/openai-to-claude\.ts"/); +}); + +test("host re-exports fitThinkingToMaxTokens from the leaf", () => { + const host = readFileSync(HOST, "utf8"); + assert.match( + host, + /export \{ fitThinkingToMaxTokens \} from "\.\/openai-to-claude\/thinkingBudget\.ts"/ + ); +}); + +test("re-exported fitThinkingToMaxTokens is callable via the host module and behaves", async () => { + const mod = await import("../../open-sse/translator/request/openai-to-claude.ts"); + assert.equal(typeof mod.fitThinkingToMaxTokens, "function"); + // No budgeted thinking → max_tokens floored to >= 1, thinking passed through. + const out = mod.fitThinkingToMaxTokens("gpt-4o-mini", 0, undefined); + assert.equal(out.thinking, undefined); + assert.ok(out.maxTokens >= 1); +}); From a8d1e7bf7890cc5fd3020862583e5cf972807c75 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:27:31 -0300 Subject: [PATCH 03/21] =?UTF-8?q?chore(release):=20pipeline=20hardening=20?= =?UTF-8?q?=E2=80=94=20test-masking=20pre-flight=20gate=20+=20contributors?= =?UTF-8?q?/uncovered=20helpers=20(#5926)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): add test-masking PR-context gate to release-green pre-flight Reproduce check:test-masking (vs origin/main) inside validate-release-green so non-allowlisted net-assert reductions surface in the local pre-flight instead of in a ~40-min CI layer on the release PR. run() now merges a per-gate opts.env so GITHUB_BASE_REF reaches the child. HARD gate; skipped under --quick. Context: v3.8.43 release cost 3 CI round-trips for PR-context gates (test-masking, file-size, pr-evidence) that check:release-green did not reproduce locally. * chore(release): add contributors generator + uncovered-commit reconciliation helpers - scripts/release/gen-contributors.mjs: reproducible `### 🙌 Contributors` table for a CHANGELOG version (parenthetical-group parser → accurate per-PR attribution, noise-handle denylist). v3.8.43 shipped without the section (a real miss) because it was hand-built. npm run release:contributors [--inject]. - scripts/release/list-uncovered-commits.mjs: lists commits since the last tag with no CHANGELOG bullet (v3.8.43 had 123/176 uncovered at reconciliation start). Advisory, maintainer-side. npm run release:uncovered. - 20 unit tests (parenthetical attribution, noise exclusion, idempotent injection, coverage window). * chore(quality): absorb web-cookie-providers-new file-size drift from #5928 (base-red on release/v3.8.44) --- config/quality/file-size-baseline.json | 3 +- package.json | 4 +- scripts/quality/validate-release-green.mjs | 23 ++- scripts/release/gen-contributors.mjs | 186 +++++++++++++++++++++ scripts/release/list-uncovered-commits.mjs | 119 +++++++++++++ tests/unit/gen-contributors.test.ts | 110 ++++++++++++ tests/unit/list-uncovered-commits.test.ts | 63 +++++++ tests/unit/validate-release-green.test.ts | 19 +++ 8 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 scripts/release/gen-contributors.mjs create mode 100644 scripts/release/list-uncovered-commits.mjs create mode 100644 tests/unit/gen-contributors.test.ts create mode 100644 tests/unit/list-uncovered-commits.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 15a134b796..2756977aa9 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -313,7 +313,8 @@ "tests/unit/usage-service-hardening.test.ts": 1633, "tests/unit/vscode-token-routes.test.ts": 1212, "tests/unit/combo-config.test.ts": 881, - "tests/unit/web-cookie-providers-new.test.ts": 845, + "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", + "tests/unit/web-cookie-providers-new.test.ts": 850, "tests/unit/response-sanitizer.test.ts": 906 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", diff --git a/package.json b/package.json index 9c2fdfb7b6..b223ffbf0a 100644 --- a/package.json +++ b/package.json @@ -205,7 +205,9 @@ "uninstall:full": "node scripts/build/uninstall.mjs --full", "prepare": "husky", "system-info": "node scripts/dev/system-info.mjs", - "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs" + "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs", + "release:contributors": "node scripts/release/gen-contributors.mjs", + "release:uncovered": "node scripts/release/list-uncovered-commits.mjs" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1073.0", diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 2914db0b50..78091ce8ab 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -146,7 +146,7 @@ function run(cmd, cmdArgs, opts = {}) { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, - env: { ...process.env, FORCE_COLOR: "0" }, + env: { ...process.env, FORCE_COLOR: "0", ...(opts.env || {}) }, // A hard ceiling for the long, silent test suites (execFileSync buffers all output until // exit, so they show no progress while running). undefined = no timeout for fast gates. ...(opts.timeout ? { timeout: opts.timeout } : {}), @@ -255,6 +255,27 @@ function main() { }); } + // test-masking (hard) — a PR-context gate: it only runs on the release PR (PR→main) in CI, so + // net-assert reductions accrue unseen on release/** and explode on the release PR. Reproduce it + // here against origin/main so a non-allowlisted reduction surfaces in the pre-flight, not in a + // ~40-min CI layer (v3.8.43 cost 3 such round-trips). Legitimate reductions get allowlisted in + // config/quality/test-masking-allowlist.json; tautology/skip/deletion signals are never allowlistable. + if (!QUICK) { + announce("Test-masking (weakened-assert guard vs main)"); + // best-effort fetch so the merge-base diff is accurate; ignore fetch failure (offline pre-flight) + run("git", ["fetch", "--no-tags", "origin", "main", "--depth=200"], { timeout: 60 * 1000 }); + const { code, out } = run(npmCmd, ["run", "check:test-masking"], { + env: { GITHUB_BASE_REF: "main" }, + }); + record({ + id: "test-masking", + label: "Test-masking (weakened-assert guard)", + kind: "hard", + ok: code === 0, + detail: code === 0 ? "no weakening" : firstFailureLine(out), + }); + } + // Remaining quality-gate / quality-extended ratchets that the PR→release // fast-gates skip and that historically surfaced — one at a time, because the // CI Quality Ratchet job is fail-fast — only on the release PR. Running them all diff --git a/scripts/release/gen-contributors.mjs b/scripts/release/gen-contributors.mjs new file mode 100644 index 0000000000..660fbb5893 --- /dev/null +++ b/scripts/release/gen-contributors.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Generate (or inject) the `### 🙌 Contributors` table for a CHANGELOG version section. +// +// WHY: every version's CHANGELOG `## [vX.Y.Z]` section MUST end with a `### 🙌 Contributors` +// table (the convention across every prior version). v3.8.43 shipped without it (a real miss the +// owner caught) because it was assembled by hand. This makes it reproducible + accurate. +// +// A naive `@handle` scan mis-assigns rollup PRs — a maintenance bullet lists many PRs under one +// `— thanks @X`, and a flat scan would credit every handle on the line with all of them. This +// parses each `([#refs] — thanks @X / @Y)` PARENTHETICAL GROUP and assigns that group's refs only +// to that group's handles (crediting is per-parenthetical, matching how bullets are written). +// +// Usage: +// node scripts/release/gen-contributors.mjs # print the table +// node scripts/release/gen-contributors.mjs --inject # insert/replace it in CHANGELOG.md +// +// Exit codes: 0 ok · 2 version section not found · 3 nothing to inject over. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +// Handles that are package names / code refs / scopes, never people. Extend as needed. +export const NOISE_HANDLES = new Set([ + "toon-format", + "dnd-kit", + "om-usage", + "anthropic-ai", + "huggingface", + "oven", + "latest", + "next", + "types", +]); + +const MAINTAINER = "diegosouzapw"; + +/** Extract the `## [version]` … up to the next `## [` section body (exclusive of the next header). */ +export function extractVersionSection(changelog, version) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"); + const sm = changelog.match(startRe); + if (!sm) return null; + const bodyStart = sm.index + sm[0].length; + const rest = changelog.slice(bodyStart); + const nextIdx = rest.search(/\n## \[/); + return nextIdx === -1 ? rest : rest.slice(0, nextIdx); +} + +/** + * Parse contributor → set of ref numbers from a version section body. + * Rules (in order, per bullet line starting with "- "): + * 1. Parenthetical groups containing "thanks": refs in the group → handles in the group. + * 2. A "thanks @X" NOT inside such a group (direct-commit trailing credit): the last ref before + * it on the line (if any) → the handles. + * 3. "Extracted from [#N] by [@X]": N → X. + * Excludes NOISE_HANDLES and the maintainer (returned separately by caller). + */ +export function parseContributors(sectionText) { + const agg = new Map(); // handle -> Set(refs) + const add = (handle, refs) => { + if (NOISE_HANDLES.has(handle) || handle === MAINTAINER) return; + if (!agg.has(handle)) agg.set(handle, new Set()); + for (const r of refs) agg.get(handle).add(r); + }; + const handlesIn = (s) => [...s.matchAll(/@([A-Za-z0-9_-]+)/g)].map((m) => m[1]); + const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); + + for (const raw of sectionText.split("\n")) { + if (!raw.startsWith("- ")) continue; + // Collapse markdown links so parenthetical groups aren't broken by the URL's own parens: + // [#5720](https://…/pull/5720) → #5720 · [@pizzav-xyz](https://…) → @pizzav-xyz + const line = raw + .replace(/\[#(\d+)\]\([^)]*\)/g, "#$1") + .replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1"); + const usedSpans = []; + + // (1) parenthetical groups with "thanks" + for (const g of line.matchAll(/\(([^()]*thanks[^()]*)\)/g)) { + const inner = g[1]; + const refs = refsIn(inner); + for (const th of inner.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) { + for (const h of handlesIn(th[1])) add(h, refs); + } + usedSpans.push([g.index, g.index + g[0].length]); + } + + // (2) trailing "— thanks @X" outside any used parenthetical (direct commits) + for (const th of line.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) { + const inGroup = usedSpans.some(([s, e]) => th.index >= s && th.index < e); + if (inGroup) continue; + const before = line.slice(0, th.index); + const refsBefore = refsIn(before); + const refs = refsBefore.length ? [refsBefore[refsBefore.length - 1]] : []; + for (const h of handlesIn(th[1])) add(h, refs); + } + + // (3) "Extracted from #N by @X" (links already collapsed by the preprocessing above) + for (const em of line.matchAll(/[Ee]xtracted from #(\d+)\s+by\s+@([A-Za-z0-9_-]+)/g)) { + add(em[2], [Number(em[1])]); + } + } + return agg; +} + +export function renderContributors(version, agg, maintainerNote = "maintainer") { + const fmt = (set) => + set.size + ? [...set] + .sort((a, b) => a - b) + .map((n) => `#${n}`) + .join(", ") + : "direct commit / report"; + const rows = [...agg.entries()].sort((a, b) => + a[0].toLowerCase().localeCompare(b[0].toLowerCase()) + ); + const lines = [ + "### 🙌 Contributors", + "", + `Thanks to everyone whose work landed in v${version}:`, + "", + "| Contributor | PRs / Issues |", + "| --- | --- |", + ]; + for (const [h, refs] of rows) { + lines.push(`| [@${h}](https://github.com/${h}) | ${fmt(refs)} |`); + } + lines.push(`| [@${MAINTAINER}](https://github.com/${MAINTAINER}) | ${maintainerNote} |`); + return lines.join("\n"); +} + +/** Insert or replace the Contributors section inside the version block, before its closing `---`. */ +export function injectContributors(changelog, version, table) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"); + const sm = changelog.match(startRe); + if (!sm) return null; + const headerEnd = sm.index + sm[0].length; + const rest = changelog.slice(headerEnd); + const nextIdx = rest.search(/\n## \[/); + const bodyEnd = nextIdx === -1 ? changelog.length : headerEnd + nextIdx; + let body = changelog.slice(headerEnd, bodyEnd); + // strip an existing Contributors section (idempotent re-run) + body = body.replace(/\n### 🙌 Contributors[\s\S]*?(?=\n---\n|$)/, "\n"); + // insert before the trailing `---` (or append if none) + const idx = body.lastIndexOf("\n---"); + const insertion = `\n${table}\n`; + body = idx >= 0 ? body.slice(0, idx) + insertion + body.slice(idx) : `${body}${insertion}\n---\n`; + return changelog.slice(0, headerEnd) + body + changelog.slice(bodyEnd); +} + +function main(argv) { + const version = argv[0]; + const inject = argv.includes("--inject"); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { + process.stderr.write("usage: gen-contributors.mjs [--inject]\n"); + process.exit(1); + } + const clPath = path.join(ROOT, "CHANGELOG.md"); + const changelog = fs.readFileSync(clPath, "utf8"); + const section = extractVersionSection(changelog, version); + if (section == null) { + process.stderr.write(`No [${version}] section in CHANGELOG.md\n`); + process.exit(2); + } + const agg = parseContributors(section); + const table = renderContributors(version, agg); + if (!inject) { + process.stdout.write(table + "\n"); + return; + } + const next = injectContributors(changelog, version, table); + if (next == null) { + process.stderr.write(`Could not locate [${version}] block for injection\n`); + process.exit(3); + } + fs.writeFileSync(clPath, next); + process.stderr.write(`✓ Injected ${agg.size} external contributor(s) into [${version}]\n`); +} + +// direct-run guard (importable for tests) +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/scripts/release/list-uncovered-commits.mjs b/scripts/release/list-uncovered-commits.mjs new file mode 100644 index 0000000000..ba8127c124 --- /dev/null +++ b/scripts/release/list-uncovered-commits.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Reconciliation helper: list non-merge commits since the last tag whose PR/issue ref is NOT +// represented in the current version's CHANGELOG section (or [Unreleased]). +// +// WHY: during the cycle, PRs merge into release/** and some land WITHOUT a CHANGELOG bullet, so +// /generate-release reconciliation has to rediscover them by hand (v3.8.43: 123 of 176 commits had +// no bullet). This surfaces exactly that gap in seconds — maintainer-side, non-blocking, run it at +// reconciliation (Phase 0a) so the release CHANGELOG is complete before the PR opens. +// +// A commit is "covered" iff ANY `#N` in its subject appears anywhere in the CHANGELOG scan window +// (the version section + [Unreleased]) — matching on issue OR PR number, since a bullet may cite +// either. Internal commits (chore/ci/test/refactor) are listed under "rollup candidates" so the +// maintainer can consolidate rather than write one bullet each. +// +// Usage: node scripts/release/list-uncovered-commits.mjs [--json] +// Exit: 0 always (advisory). Prints a report to stdout. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); + +const ROLLUP_TYPES = new Set(["chore", "ci", "test", "refactor", "build", "docs", "style"]); + +export function refsOf(subject) { + return [...subject.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); +} + +export function typeOf(subject) { + const m = subject.match(/^([a-z]+)(\(|:|!)/); + return m ? m[1] : "other"; +} + +/** + * @param {{hash:string, subject:string}[]} commits + * @param {Set} changelogRefs every #N present in the CHANGELOG scan window + * @returns {{covered:number, uncovered:{hash,subject,refs,type,rollup}[]}} + */ +export function computeUncovered(commits, changelogRefs) { + const uncovered = []; + let covered = 0; + for (const c of commits) { + const refs = refsOf(c.subject); + const isCovered = refs.length > 0 && refs.some((r) => changelogRefs.has(r)); + if (isCovered) { + covered++; + } else { + const type = typeOf(c.subject); + uncovered.push({ ...c, refs, type, rollup: ROLLUP_TYPES.has(type) }); + } + } + return { covered, uncovered }; +} + +/** Read every #N in the version's CHANGELOG section + the [Unreleased] section. */ +export function changelogRefWindow(changelog, version) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // From [Unreleased] up to (but excluding) the version-after-this one. + const startRe = /^## \[Unreleased\]/m; + const s = changelog.match(startRe); + const from = s ? s.index : 0; + // find the header AFTER the target version + const verRe = new RegExp(`^## \\[${esc}\\]`, "m"); + const vm = changelog.slice(from).match(verRe); + const afterVersionStart = vm ? from + vm.index + vm[0].length : from; + const rest = changelog.slice(afterVersionStart); + const nextIdx = rest.search(/\n## \[/); + const to = nextIdx === -1 ? changelog.length : afterVersionStart + nextIdx; + const window = changelog.slice(from, to); + return new Set([...window.matchAll(/#(\d+)/g)].map((m) => Number(m[1]))); +} + +function main(argv) { + const jsonOut = argv.includes("--json"); + const lastTag = git(["describe", "--tags", "--abbrev=0"]); + const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version; + const log = git(["log", "--no-merges", `${lastTag}..HEAD`, "--pretty=format:%h%x09%s"]); + const commits = log + ? log.split("\n").map((l) => { + const [hash, subject] = l.split("\t"); + return { hash, subject }; + }) + : []; + const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8"); + const refs = changelogRefWindow(changelog, version); + const { covered, uncovered } = computeUncovered(commits, refs); + + if (jsonOut) { + process.stdout.write( + JSON.stringify({ version, lastTag, total: commits.length, covered, uncovered }, null, 2) + + "\n" + ); + return; + } + const bulletsWorthy = uncovered.filter((c) => !c.rollup); + const rollupCandidates = uncovered.filter((c) => c.rollup); + process.stdout.write(`# Uncovered-commit reconciliation — v${version} (${lastTag}..HEAD)\n\n`); + process.stdout.write( + `Commits: ${commits.length} · covered: ${covered} · uncovered: ${uncovered.length}\n\n` + ); + process.stdout.write( + `## Needs a bullet (feat/fix/other — user-facing) — ${bulletsWorthy.length}\n` + ); + for (const c of bulletsWorthy) process.stdout.write(`- ${c.hash} ${c.subject}\n`); + process.stdout.write( + `\n## Rollup candidates (chore/ci/test/refactor/docs) — ${rollupCandidates.length}\n` + ); + for (const c of rollupCandidates) process.stdout.write(`- ${c.hash} ${c.subject}\n`); + process.stdout.write( + `\n> Advisory. Add a bullet for each user-facing item; consolidate rollup candidates into a few Maintenance bullets (list their PR numbers).\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/tests/unit/gen-contributors.test.ts b/tests/unit/gen-contributors.test.ts new file mode 100644 index 0000000000..d6ba809f8e --- /dev/null +++ b/tests/unit/gen-contributors.test.ts @@ -0,0 +1,110 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../scripts/release/gen-contributors.mjs"); +const { + extractVersionSection, + parseContributors, + renderContributors, + injectContributors, + NOISE_HANDLES, +} = mod; + +const FIXTURE = `# Changelog + +## [Unreleased] + +--- + +## [3.9.0] — 2026-08-01 + +### ✨ New Features + +- **feat(a):** thing one. ([#100](https://github.com/x/y/pull/100) — thanks @alice) +- **feat(b):** uses \`@toon-format/toon\` and \`@dnd-kit\`. ([#101](https://github.com/x/y/pull/101) — thanks @bob) + +### 🔧 Bug Fixes + +- **fix(c):** direct commit fix. (thanks @carol) +- **fix(d):** extracted. Extracted from [#102](https://github.com/x/y/pull/102) by [@dave](https://github.com/dave). + +### 📝 Maintenance + +- **refactor(rollup):** god-file split ([#200](https://github.com/x/y/pull/200), [#201](https://github.com/x/y/pull/201) — thanks @erin); editorconfig ([#202](https://github.com/x/y/pull/202) — thanks @frank). — thanks @diegosouzapw + +--- + +## [3.8.99] — 2026-07-31 + +### 🔧 Bug Fixes + +- **fix(z):** other version, must not leak. ([#999](https://github.com/x/y/pull/999) — thanks @zoe) + +--- +`; + +test("extractVersionSection returns only the target version body (not the next section)", () => { + const sec = extractVersionSection(FIXTURE, "3.9.0"); + assert.ok(sec.includes("thing one"), "includes 3.9.0 content"); + assert.ok(!sec.includes("must not leak"), "excludes 3.8.99 content"); + assert.ok(!sec.includes("#999"), "does not bleed into next version"); +}); + +test("parseContributors credits per parenthetical group, not a flat scan", () => { + const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0")); + // rollup: erin gets 200+201, frank gets 202 — NOT both getting all three + assert.deepEqual( + [...agg.get("erin")].sort((a, b) => a - b), + [200, 201] + ); + assert.deepEqual([...agg.get("frank")], [202]); + // simple bullets + assert.deepEqual([...agg.get("alice")], [100]); + // direct-commit credit with no PR ref + assert.ok(agg.has("carol") && agg.get("carol").size === 0); + // "Extracted from #N by @X" + assert.deepEqual([...agg.get("dave")], [102]); +}); + +test("noise handles and the maintainer are excluded from the contributor map", () => { + const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0")); + assert.ok(!agg.has("toon-format"), "package scope is not a contributor"); + assert.ok(!agg.has("dnd-kit"), "package scope is not a contributor"); + assert.ok(!agg.has("diegosouzapw"), "maintainer is rendered separately, not in the map"); + assert.ok(NOISE_HANDLES.has("toon-format")); +}); + +test("renderContributors emits an alphabetical table with maintainer last", () => { + const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0")); + const table = renderContributors("3.9.0", agg); + assert.ok(table.startsWith("### 🙌 Contributors")); + const rows = table.split("\n").filter((l) => l.startsWith("| [@")); + const handles = rows.map((r) => r.match(/@([A-Za-z0-9_-]+)/)[1]); + assert.equal(handles[handles.length - 1], "diegosouzapw", "maintainer is last"); + const external = handles.slice(0, -1); + assert.deepEqual( + external, + [...external].sort((a, b) => a.localeCompare(b)), + "external sorted" + ); + assert.ok(table.includes("| [@carol](https://github.com/carol) | direct commit / report |")); +}); + +test("injectContributors inserts before the closing --- and is idempotent", () => { + const once = injectContributors( + FIXTURE, + "3.9.0", + renderContributors("3.9.0", parseContributors(extractVersionSection(FIXTURE, "3.9.0"))) + ); + assert.ok(once.includes("### 🙌 Contributors"), "section injected"); + // 3.8.99 untouched + assert.ok(once.includes("must not leak")); + // idempotent: injecting again does not duplicate + const twice = injectContributors( + once, + "3.9.0", + renderContributors("3.9.0", parseContributors(extractVersionSection(once, "3.9.0"))) + ); + const count = (twice.match(/### 🙌 Contributors/g) || []).length; + assert.equal(count, 1, "no duplicate Contributors section on re-run"); +}); diff --git a/tests/unit/list-uncovered-commits.test.ts b/tests/unit/list-uncovered-commits.test.ts new file mode 100644 index 0000000000..a49cbcecfa --- /dev/null +++ b/tests/unit/list-uncovered-commits.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../scripts/release/list-uncovered-commits.mjs"); +const { refsOf, typeOf, computeUncovered, changelogRefWindow } = mod; + +test("refsOf extracts every #N from a subject", () => { + assert.deepEqual(refsOf("fix(x): thing (#5842) (#5901)"), [5842, 5901]); + assert.deepEqual(refsOf("chore: no refs here"), []); +}); + +test("typeOf reads the conventional-commit type", () => { + assert.equal(typeOf("feat(api): x"), "feat"); + assert.equal(typeOf("fix: y"), "fix"); + assert.equal(typeOf("refactor(db)!: z"), "refactor"); + assert.equal(typeOf("Merge branch main"), "other"); +}); + +test("computeUncovered: a commit is covered iff ANY of its refs is in the changelog window", () => { + const commits = [ + { hash: "a1", subject: "fix(x): covered by issue ref (#100)" }, // issue 100 in changelog + { hash: "b2", subject: "feat(y): uncovered feature (#200)" }, // 200 not in changelog + { hash: "c3", subject: "refactor(z): internal (#300)" }, // rollup type, uncovered + { hash: "d4", subject: "chore: no ref at all" }, // no ref → uncovered, rollup + ]; + const refs = new Set([100]); // only #100 is documented + const { covered, uncovered } = computeUncovered(commits, refs); + assert.equal(covered, 1); + assert.equal(uncovered.length, 3); + const byHash = Object.fromEntries(uncovered.map((c) => [c.hash, c])); + assert.equal(byHash.b2.rollup, false, "feat is user-facing, not a rollup candidate"); + assert.equal(byHash.c3.rollup, true, "refactor is a rollup candidate"); + assert.equal(byHash.d4.rollup, true, "chore is a rollup candidate"); +}); + +test("changelogRefWindow scans [Unreleased] + the version section but not older versions", () => { + const cl = `# Changelog + +## [Unreleased] + +- **fix:** something ([#10](u)) + +--- + +## [3.9.0] — x + +### 🔧 Bug Fixes + +- **fix(a):** landed ([#20](u)) + +--- + +## [3.8.99] — y + +- **fix(old):** must not count ([#999](u)) + +--- +`; + const refs = changelogRefWindow(cl, "3.9.0"); + assert.ok(refs.has(10), "picks up [Unreleased] refs"); + assert.ok(refs.has(20), "picks up the target version refs"); + assert.ok(!refs.has(999), "does NOT bleed into the previous version"); +}); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index b31be512b9..d2d165bd53 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -121,3 +121,22 @@ test("classifyRunError: a kill WITHOUT a configured timeout is not misreported a assert.equal(r.code, 1); assert.doesNotMatch(r.out, /ceiling/); }); + +test("pre-flight wires the test-masking PR-context gate against origin/main (v3.8.43 gap fix)", async () => { + const fs = await import("node:fs"); + const src = fs.readFileSync( + new URL("../../scripts/quality/validate-release-green.mjs", import.meta.url), + "utf8" + ); + // The gate must run check:test-masking, pin the base to main, and be classified HARD — + // it caught a real net-assert reduction that only surfaced on the release PR before. + assert.match(src, /check:test-masking/, "test-masking gate must be wired into the pre-flight"); + assert.match(src, /GITHUB_BASE_REF:\s*"main"/, "test-masking must diff against origin/main"); + assert.match( + src, + /id:\s*"test-masking"[\s\S]*?kind:\s*"hard"/, + "test-masking must be a HARD gate (non-allowlisted weakening blocks the release)" + ); + // run() must honor a per-gate env override so GITHUB_BASE_REF actually reaches the child. + assert.match(src, /\.\.\.\(opts\.env \|\| \{\}\)/, "run() must merge opts.env into the child env"); +}); From 0d6add19dcc803ebe0d5d7c83e0d4b88e22e8546 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:50:08 -0300 Subject: [PATCH 04/21] refactor(translator): split openai-responses request translator into pure leaves (#5940) Extract the shared pure primitives and the chat->Responses direction out of the 894-line openai-responses.ts request translator: - openai-responses/helpers.ts: pure primitives (toRecord/toString/clampCallId/ normalizeVerbosity/etc + markers/regexes/JsonRecord), zero host imports - openai-responses/toResponses.ts: openaiToOpenAIResponsesRequest (chat->Responses), imports the helpers leaf Host keeps openaiResponsesToOpenAIRequest (Responses->chat, imported by production) plus both register() directions, and re-exports openaiToOpenAIResponsesRequest so external importers (tests) keep working. Host 894 -> 529 LOC (under the 800 cap). Verbatim bodies (multiset check: leaf A 54/54, leaf B 294 lines, fn1 intact), public export set unchanged, leaves never import the host (no cycle). Adds a split-guard test; all consumer tests stay green (responses-translation-fixes 37, verbosity 4, reasoning-effort 4, orphaned-tool-filter 8, empty-tool-name-loop 8, headroom-responses-format 3). --- .../translator/request/openai-responses.ts | 403 +----------------- .../request/openai-responses/helpers.ts | 69 +++ .../request/openai-responses/toResponses.ts | 334 +++++++++++++++ .../openai-responses-request-split.test.ts | 56 +++ 4 files changed, 478 insertions(+), 384 deletions(-) create mode 100644 open-sse/translator/request/openai-responses/helpers.ts create mode 100644 open-sse/translator/request/openai-responses/toResponses.ts create mode 100644 tests/unit/openai-responses-request-split.test.ts diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index a79259bd67..23f32f53d4 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -6,73 +6,28 @@ */ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; -import { generateToolCallId } from "../helpers/toolCallHelper.ts"; import { register } from "../registry.ts"; import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts"; -type JsonRecord = Record; -const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; -const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; +import { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; +import { + JsonRecord, + RESPONSES_STORE_MARKER, + COPILOT_REASONING_SUMMARY_MARKER, + WEB_SEARCH_TOOL_TYPES, + TOOL_SEARCH_TOOL_TYPES, + IMAGE_GENERATION_TOOL_TYPES, + toRecord, + toArray, + toString, + normalizeVerbosity, + normalizeResponsesReasoningEffort, + shouldRequestClaudeSummarizedThinking, + unsupportedFeature, +} from "./openai-responses/helpers.ts"; -// Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names. -const WEB_SEARCH_TOOL_TYPES = /^web_search/; -// tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions -// equivalent and must be silently dropped (not rejected with 400). -const TOOL_SEARCH_TOOL_TYPES = /^tool_search/; -// image_generation is a Responses API hosted tool that Codex Desktop injects into every request -// (even text-only ones); it has no Chat Completions equivalent and must be silently dropped (#2950). -const IMAGE_GENERATION_TOOL_TYPES = /^image_generation/; - -// GPT-5 output verbosity: `verbosity` on Chat Completions, `text.verbosity` on the -// Responses API. Only these three levels are valid upstream; anything else is dropped. -const VERBOSITY_LEVELS = new Set(["low", "medium", "high"]); -function normalizeVerbosity(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const level = value.toLowerCase(); - return VERBOSITY_LEVELS.has(level) ? level : undefined; -} - -function toRecord(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; -} - -// The Responses API rejects call_id values longer than 64 characters (9router#396). -// Clamp deterministically so a function_call and its matching function_call_output keep -// the same id and stay paired through the orphaned-output filter below. -const MAX_CALL_ID_LEN = 64; -function clampCallId(id: string): string { - return id.length > MAX_CALL_ID_LEN ? id.slice(0, MAX_CALL_ID_LEN) : id; -} - -function toArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function toString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function imageUrlToText(value: unknown): string { - if (typeof value === "string") return value; - const record = toRecord(value); - return toString(record.url); -} - -function normalizeResponsesReasoningEffort(value: unknown): string { - const effort = toString(value).toLowerCase(); - return effort === "max" ? "xhigh" : effort; -} - -function shouldRequestClaudeSummarizedThinking(value: unknown): boolean { - const summary = toString(value).toLowerCase(); - return !!summary && summary !== "off" && summary !== "none" && summary !== "disabled"; -} - -function unsupportedFeature(message: string): Error & { statusCode: number; errorType: string } { - const error = new Error(message) as Error & { statusCode: number; errorType: string }; - error.statusCode = 400; - error.errorType = "unsupported_feature"; - return error; -} +// chat -> Responses direction extracted to a pure leaf; re-exported for external +// importers (tests). Host imports it back for registration below. +export { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; /** * Convert OpenAI Responses API request to OpenAI Chat Completions format @@ -569,326 +524,6 @@ export function openaiResponsesToOpenAIRequest( return result; } -/** - * Convert OpenAI Chat Completions to OpenAI Responses API format - */ -export function openaiToOpenAIResponsesRequest( - model: unknown, - body: unknown, - stream: unknown, - credentials: unknown -): unknown { - void stream; - - const root = toRecord(body); - const credentialRecord = toRecord(credentials); - const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); - const result: JsonRecord = { - model, - input: [], - stream: true, - }; - if (!storeEnabled) { - result.store = false; - } - - const input = result.input as JsonRecord[]; - - // Extract first system message as instructions - let hasSystemMessage = false; - const messages = toArray(root.messages); - - for (const messageValue of messages) { - const msg = toRecord(messageValue); - const role = toString(msg.role); - - if (role === "system" || role === "developer") { - if (!hasSystemMessage) { - result.instructions = typeof msg.content === "string" ? msg.content : ""; - hasSystemMessage = true; - } - continue; - } - - // Convert user messages - if (role === "user") { - const content = - typeof msg.content === "string" - ? [{ type: "input_text", text: msg.content }] - : Array.isArray(msg.content) - ? msg.content.map((contentValue) => { - const contentItem = toRecord(contentValue); - if (contentItem.type === "text") { - return { type: "input_text", text: toString(contentItem.text) }; - } - if (contentItem.type === "image_url") { - const imgUrl = contentItem.image_url as - | string - | { url?: string; detail?: string }; - const imgResult: JsonRecord = { - type: "input_image", - image_url: typeof imgUrl === "string" ? imgUrl : imgUrl?.url || "", - }; - if (typeof imgUrl === "object" && imgUrl?.detail !== undefined) { - imgResult.detail = imgUrl.detail; - } - return imgResult; - } - if ( - contentItem.type === "image" && - typeof contentItem.image === "string" && - /^data:([^;]+);base64,(.+)$/.test(contentItem.image) - ) { - // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) - const imgResult: JsonRecord = { - type: "input_image", - image_url: contentItem.image, - detail: contentItem.detail !== undefined ? contentItem.detail : "auto", - }; - return imgResult; - } - if (contentItem.type === "file" || contentItem.type === "document") { - // Accept both the OpenAI `file` shape and the Gemini-style `document` shape, - // and map the bare `data`/`url` fields too, so a PDF reaches Codex/Responses - // regardless of which content-part name the client used (#2515). - const file = toRecord( - contentItem.type === "document" ? contentItem.document : contentItem.file - ); - const fileResult: JsonRecord = { type: "input_file" }; - if (file.file_data !== undefined) fileResult.file_data = file.file_data; - else if (file.data !== undefined) fileResult.file_data = file.data; - if (file.file_id !== undefined) fileResult.file_id = file.file_id; - if (file.file_url !== undefined) fileResult.file_url = file.file_url; - else if (file.url !== undefined) fileResult.file_url = file.url; - if (file.filename !== undefined) fileResult.filename = file.filename; - else if (file.name !== undefined) fileResult.filename = file.name; - return fileResult; - } - return contentValue; - }) - : [{ type: "input_text", text: "" }]; - - input.push({ - type: "message", - role: "user", - content, - }); - } - - // Convert assistant messages - if (role === "assistant") { - // Skip reasoning_content — OpenAI Responses API requires server-generated - // rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N) - // causes 400 errors from Responses-compatible upstreams. (#224) - - // Skip thinking blocks in array content — same rs_* ID constraint applies - - // Build assistant output content - const outputContent: unknown[] = []; - if (typeof msg.content === "string" && msg.content) { - outputContent.push({ type: "output_text", text: msg.content }); - } else if (Array.isArray(msg.content)) { - for (const contentValue of msg.content) { - const contentItem = toRecord(contentValue); - if (contentItem.type === "text") { - outputContent.push({ type: "output_text", text: toString(contentItem.text) }); - } else if (contentItem.type === "image_url") { - const url = imageUrlToText(contentItem.image_url); - outputContent.push({ type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" }); - } else if (contentItem.type === "thinking" || contentItem.type === "redacted_thinking") { - // Reasoning already moved above - continue; - } else { - outputContent.push(contentValue); - } - } - } - - // Only add assistant message if content exists - if (outputContent.length > 0) { - input.push({ - type: "message", - role: "assistant", - content: outputContent, - }); - } - - // Convert tool_calls to function_call items - if (Array.isArray(msg.tool_calls)) { - for (const toolCallValue of msg.tool_calls) { - const toolCall = toRecord(toolCallValue); - const fn = toRecord(toolCall.function); - // Skip tool calls with empty names to avoid infinite placeholder_tool loops - const fnName = toString(fn.name).trim(); - if (!fnName) { - continue; - } - input.push({ - type: "function_call", - call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()), - name: fnName, - arguments: toString(fn.arguments, "{}"), - }); - } - } - - // Handle deprecated function_call field (pre-tool_calls API) - if (msg.function_call && !msg.tool_calls) { - const fc = toRecord(msg.function_call); - const fnName = toString(fc.name).trim(); - if (fnName) { - input.push({ - type: "function_call", - call_id: clampCallId(`call_${fnName}`), - name: fnName, - arguments: toString(fc.arguments, "{}"), - }); - } - } - } - - // Convert tool results - if (role === "tool") { - input.push({ - type: "function_call_output", - call_id: clampCallId(toString(msg.tool_call_id)), - output: - typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? msg.content.map((c) => { - const part = toRecord(c); - if (part.type === "text") - return { type: "input_text", text: toString(part.text) }; - return c; - }) - : String(msg.content ?? ""), - }); - } - - // Handle deprecated function role messages - if (role === "function") { - input.push({ - type: "function_call_output", - call_id: clampCallId(`call_${toString(msg.name)}`), - output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), - }); - } - } - - // Filter orphaned function_call_output items (no matching function_call) - // This happens when Claude Code compaction removes messages but leaves tool results - const knownCallIds = new Set( - input - .filter( - (item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id - ) - .map((item: { type?: string; call_id?: string }) => item.call_id) - ); - result.input = input.filter((item: { type?: string; call_id?: string }) => { - if (item.type === "function_call_output" && item.call_id) { - return knownCallIds.has(item.call_id); - } - return true; - }); - - // If no system message, keep empty instructions - if (!hasSystemMessage) { - result.instructions = ""; - } - - // Convert tools format - if (Array.isArray(root.tools)) { - result.tools = root.tools.map((toolValue) => { - const tool = toRecord(toolValue); - if (tool.type === "function") { - const fn = toRecord(tool.function); - const name = toString(fn.name); - return { - type: "function", - name, - description: toString(fn.description), - parameters: fn.parameters, - strict: fn.strict, - }; - } - return toolValue; - }); - } - - // Translate tool_choice: Chat {type,function:{name}} → Responses {type,name} - if (root.tool_choice !== undefined) { - if (typeof root.tool_choice === "string") { - result.tool_choice = root.tool_choice; - } else if (typeof root.tool_choice === "object" && !Array.isArray(root.tool_choice)) { - const tc = toRecord(root.tool_choice); - if (tc.type === "function" && tc.function) { - const fn = toRecord(tc.function); - result.tool_choice = { type: "function", name: fn.name }; - } else { - result.tool_choice = root.tool_choice; - } - } else { - result.tool_choice = root.tool_choice; - } - } - - // Pass through relevant fields - if (root.previous_response_id !== undefined) { - result.previous_response_id = root.previous_response_id; - } - if (root.prompt_cache_key !== undefined) { - result.prompt_cache_key = root.prompt_cache_key; - } - if (root.session_id !== undefined) { - result.session_id = root.session_id; - } - if (root.conversation_id !== undefined) { - result.conversation_id = root.conversation_id; - } - if (root.service_tier !== undefined) result.service_tier = root.service_tier; - if (root.temperature !== undefined) result.temperature = root.temperature; - // Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API. - // The Responses API does not accept max_tokens or max_completion_tokens; it requires - // max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field. - if (root.max_completion_tokens !== undefined) { - result.max_output_tokens = root.max_completion_tokens; - } else if (root.max_tokens !== undefined) { - result.max_output_tokens = root.max_tokens; - } - if (root.top_p !== undefined) result.top_p = root.top_p; - // GPT-5 verbosity: Chat Completions `verbosity` → Responses `text.verbosity`. - const chatVerbosity = normalizeVerbosity(root.verbosity); - if (chatVerbosity) { - result.text = { ...toRecord(result.text), verbosity: chatVerbosity }; - } - if (root.reasoning !== undefined) { - result.reasoning = root.reasoning; - } else if (root.reasoning_effort !== undefined) { - const effort = normalizeResponsesReasoningEffort(root.reasoning_effort); - if (effort) { - result.reasoning = { effort }; - } - } - - // Propagate Responses-API-only fields when a chat client sent them. - // Without this, e.g. `include: ["reasoning.encrypted_content"]` is lost on - // the way upstream and Codex returns an empty reasoning summary, so clients - // (OpenCode, Cursor, etc.) see no thinking stream. - if (Array.isArray(root.include) && root.include.length > 0) { - result.include = root.include; - } - if (storeEnabled) { - if (root[RESPONSES_STORE_MARKER] !== undefined) { - result.store = root[RESPONSES_STORE_MARKER]; - } else if (root.store !== undefined) { - result.store = root.store; - } - } - - return result; -} - // Register both directions register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null); register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null); diff --git a/open-sse/translator/request/openai-responses/helpers.ts b/open-sse/translator/request/openai-responses/helpers.ts new file mode 100644 index 0000000000..f50f5132ce --- /dev/null +++ b/open-sse/translator/request/openai-responses/helpers.ts @@ -0,0 +1,69 @@ +// Pure shared primitives for the OpenAI Responses <-> Chat Completions request +// translators. Extracted verbatim from openai-responses.ts (no host imports). + +export type JsonRecord = Record; +export const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; +export const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; + +// Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names. +export const WEB_SEARCH_TOOL_TYPES = /^web_search/; +// tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions +// equivalent and must be silently dropped (not rejected with 400). +export const TOOL_SEARCH_TOOL_TYPES = /^tool_search/; +// image_generation is a Responses API hosted tool that Codex Desktop injects into every request +// (even text-only ones); it has no Chat Completions equivalent and must be silently dropped (#2950). +export const IMAGE_GENERATION_TOOL_TYPES = /^image_generation/; + +// GPT-5 output verbosity: `verbosity` on Chat Completions, `text.verbosity` on the +// Responses API. Only these three levels are valid upstream; anything else is dropped. +export const VERBOSITY_LEVELS = new Set(["low", "medium", "high"]); +export function normalizeVerbosity(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const level = value.toLowerCase(); + return VERBOSITY_LEVELS.has(level) ? level : undefined; +} + +export function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +// The Responses API rejects call_id values longer than 64 characters (9router#396). +// Clamp deterministically so a function_call and its matching function_call_output keep +// the same id and stay paired through the orphaned-output filter below. +export const MAX_CALL_ID_LEN = 64; +export function clampCallId(id: string): string { + return id.length > MAX_CALL_ID_LEN ? id.slice(0, MAX_CALL_ID_LEN) : id; +} + +export function toArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function toString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function imageUrlToText(value: unknown): string { + if (typeof value === "string") return value; + const record = toRecord(value); + return toString(record.url); +} + +export function normalizeResponsesReasoningEffort(value: unknown): string { + const effort = toString(value).toLowerCase(); + return effort === "max" ? "xhigh" : effort; +} + +export function shouldRequestClaudeSummarizedThinking(value: unknown): boolean { + const summary = toString(value).toLowerCase(); + return !!summary && summary !== "off" && summary !== "none" && summary !== "disabled"; +} + +export function unsupportedFeature( + message: string +): Error & { statusCode: number; errorType: string } { + const error = new Error(message) as Error & { statusCode: number; errorType: string }; + error.statusCode = 400; + error.errorType = "unsupported_feature"; + return error; +} diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts new file mode 100644 index 0000000000..5cb12419f5 --- /dev/null +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -0,0 +1,334 @@ +/** + * Translator: OpenAI Chat Completions -> OpenAI Responses API + * + * Extracted verbatim from openai-responses.ts. Registration stays in the host. + */ +import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; +import { generateToolCallId } from "../../helpers/toolCallHelper.ts"; +import { + JsonRecord, + RESPONSES_STORE_MARKER, + toRecord, + toArray, + toString, + clampCallId, + imageUrlToText, + normalizeVerbosity, + normalizeResponsesReasoningEffort, +} from "./helpers.ts"; + +export function openaiToOpenAIResponsesRequest( + model: unknown, + body: unknown, + stream: unknown, + credentials: unknown +): unknown { + void stream; + + const root = toRecord(body); + const credentialRecord = toRecord(credentials); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); + const result: JsonRecord = { + model, + input: [], + stream: true, + }; + if (!storeEnabled) { + result.store = false; + } + + const input = result.input as JsonRecord[]; + + // Extract first system message as instructions + let hasSystemMessage = false; + const messages = toArray(root.messages); + + for (const messageValue of messages) { + const msg = toRecord(messageValue); + const role = toString(msg.role); + + if (role === "system" || role === "developer") { + if (!hasSystemMessage) { + result.instructions = typeof msg.content === "string" ? msg.content : ""; + hasSystemMessage = true; + } + continue; + } + + // Convert user messages + if (role === "user") { + const content = + typeof msg.content === "string" + ? [{ type: "input_text", text: msg.content }] + : Array.isArray(msg.content) + ? msg.content.map((contentValue) => { + const contentItem = toRecord(contentValue); + if (contentItem.type === "text") { + return { type: "input_text", text: toString(contentItem.text) }; + } + if (contentItem.type === "image_url") { + const imgUrl = contentItem.image_url as + string | { url?: string; detail?: string }; + const imgResult: JsonRecord = { + type: "input_image", + image_url: typeof imgUrl === "string" ? imgUrl : imgUrl?.url || "", + }; + if (typeof imgUrl === "object" && imgUrl?.detail !== undefined) { + imgResult.detail = imgUrl.detail; + } + return imgResult; + } + if ( + contentItem.type === "image" && + typeof contentItem.image === "string" && + /^data:([^;]+);base64,(.+)$/.test(contentItem.image) + ) { + // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) + const imgResult: JsonRecord = { + type: "input_image", + image_url: contentItem.image, + detail: contentItem.detail !== undefined ? contentItem.detail : "auto", + }; + return imgResult; + } + if (contentItem.type === "file" || contentItem.type === "document") { + // Accept both the OpenAI `file` shape and the Gemini-style `document` shape, + // and map the bare `data`/`url` fields too, so a PDF reaches Codex/Responses + // regardless of which content-part name the client used (#2515). + const file = toRecord( + contentItem.type === "document" ? contentItem.document : contentItem.file + ); + const fileResult: JsonRecord = { type: "input_file" }; + if (file.file_data !== undefined) fileResult.file_data = file.file_data; + else if (file.data !== undefined) fileResult.file_data = file.data; + if (file.file_id !== undefined) fileResult.file_id = file.file_id; + if (file.file_url !== undefined) fileResult.file_url = file.file_url; + else if (file.url !== undefined) fileResult.file_url = file.url; + if (file.filename !== undefined) fileResult.filename = file.filename; + else if (file.name !== undefined) fileResult.filename = file.name; + return fileResult; + } + return contentValue; + }) + : [{ type: "input_text", text: "" }]; + + input.push({ + type: "message", + role: "user", + content, + }); + } + + // Convert assistant messages + if (role === "assistant") { + // Skip reasoning_content — OpenAI Responses API requires server-generated + // rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N) + // causes 400 errors from Responses-compatible upstreams. (#224) + + // Skip thinking blocks in array content — same rs_* ID constraint applies + + // Build assistant output content + const outputContent: unknown[] = []; + if (typeof msg.content === "string" && msg.content) { + outputContent.push({ type: "output_text", text: msg.content }); + } else if (Array.isArray(msg.content)) { + for (const contentValue of msg.content) { + const contentItem = toRecord(contentValue); + if (contentItem.type === "text") { + outputContent.push({ type: "output_text", text: toString(contentItem.text) }); + } else if (contentItem.type === "image_url") { + const url = imageUrlToText(contentItem.image_url); + outputContent.push({ type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" }); + } else if (contentItem.type === "thinking" || contentItem.type === "redacted_thinking") { + // Reasoning already moved above + continue; + } else { + outputContent.push(contentValue); + } + } + } + + // Only add assistant message if content exists + if (outputContent.length > 0) { + input.push({ + type: "message", + role: "assistant", + content: outputContent, + }); + } + + // Convert tool_calls to function_call items + if (Array.isArray(msg.tool_calls)) { + for (const toolCallValue of msg.tool_calls) { + const toolCall = toRecord(toolCallValue); + const fn = toRecord(toolCall.function); + // Skip tool calls with empty names to avoid infinite placeholder_tool loops + const fnName = toString(fn.name).trim(); + if (!fnName) { + continue; + } + input.push({ + type: "function_call", + call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()), + name: fnName, + arguments: toString(fn.arguments, "{}"), + }); + } + } + + // Handle deprecated function_call field (pre-tool_calls API) + if (msg.function_call && !msg.tool_calls) { + const fc = toRecord(msg.function_call); + const fnName = toString(fc.name).trim(); + if (fnName) { + input.push({ + type: "function_call", + call_id: clampCallId(`call_${fnName}`), + name: fnName, + arguments: toString(fc.arguments, "{}"), + }); + } + } + } + + // Convert tool results + if (role === "tool") { + input.push({ + type: "function_call_output", + call_id: clampCallId(toString(msg.tool_call_id)), + output: + typeof msg.content === "string" + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map((c) => { + const part = toRecord(c); + if (part.type === "text") + return { type: "input_text", text: toString(part.text) }; + return c; + }) + : String(msg.content ?? ""), + }); + } + + // Handle deprecated function role messages + if (role === "function") { + input.push({ + type: "function_call_output", + call_id: clampCallId(`call_${toString(msg.name)}`), + output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), + }); + } + } + + // Filter orphaned function_call_output items (no matching function_call) + // This happens when Claude Code compaction removes messages but leaves tool results + const knownCallIds = new Set( + input + .filter( + (item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id + ) + .map((item: { type?: string; call_id?: string }) => item.call_id) + ); + result.input = input.filter((item: { type?: string; call_id?: string }) => { + if (item.type === "function_call_output" && item.call_id) { + return knownCallIds.has(item.call_id); + } + return true; + }); + + // If no system message, keep empty instructions + if (!hasSystemMessage) { + result.instructions = ""; + } + + // Convert tools format + if (Array.isArray(root.tools)) { + result.tools = root.tools.map((toolValue) => { + const tool = toRecord(toolValue); + if (tool.type === "function") { + const fn = toRecord(tool.function); + const name = toString(fn.name); + return { + type: "function", + name, + description: toString(fn.description), + parameters: fn.parameters, + strict: fn.strict, + }; + } + return toolValue; + }); + } + + // Translate tool_choice: Chat {type,function:{name}} → Responses {type,name} + if (root.tool_choice !== undefined) { + if (typeof root.tool_choice === "string") { + result.tool_choice = root.tool_choice; + } else if (typeof root.tool_choice === "object" && !Array.isArray(root.tool_choice)) { + const tc = toRecord(root.tool_choice); + if (tc.type === "function" && tc.function) { + const fn = toRecord(tc.function); + result.tool_choice = { type: "function", name: fn.name }; + } else { + result.tool_choice = root.tool_choice; + } + } else { + result.tool_choice = root.tool_choice; + } + } + + // Pass through relevant fields + if (root.previous_response_id !== undefined) { + result.previous_response_id = root.previous_response_id; + } + if (root.prompt_cache_key !== undefined) { + result.prompt_cache_key = root.prompt_cache_key; + } + if (root.session_id !== undefined) { + result.session_id = root.session_id; + } + if (root.conversation_id !== undefined) { + result.conversation_id = root.conversation_id; + } + if (root.service_tier !== undefined) result.service_tier = root.service_tier; + if (root.temperature !== undefined) result.temperature = root.temperature; + // Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API. + // The Responses API does not accept max_tokens or max_completion_tokens; it requires + // max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field. + if (root.max_completion_tokens !== undefined) { + result.max_output_tokens = root.max_completion_tokens; + } else if (root.max_tokens !== undefined) { + result.max_output_tokens = root.max_tokens; + } + if (root.top_p !== undefined) result.top_p = root.top_p; + // GPT-5 verbosity: Chat Completions `verbosity` → Responses `text.verbosity`. + const chatVerbosity = normalizeVerbosity(root.verbosity); + if (chatVerbosity) { + result.text = { ...toRecord(result.text), verbosity: chatVerbosity }; + } + if (root.reasoning !== undefined) { + result.reasoning = root.reasoning; + } else if (root.reasoning_effort !== undefined) { + const effort = normalizeResponsesReasoningEffort(root.reasoning_effort); + if (effort) { + result.reasoning = { effort }; + } + } + + // Propagate Responses-API-only fields when a chat client sent them. + // Without this, e.g. `include: ["reasoning.encrypted_content"]` is lost on + // the way upstream and Codex returns an empty reasoning summary, so clients + // (OpenCode, Cursor, etc.) see no thinking stream. + if (Array.isArray(root.include) && root.include.length > 0) { + result.include = root.include; + } + if (storeEnabled) { + if (root[RESPONSES_STORE_MARKER] !== undefined) { + result.store = root[RESPONSES_STORE_MARKER]; + } else if (root.store !== undefined) { + result.store = root.store; + } + } + + return result; +} diff --git a/tests/unit/openai-responses-request-split.test.ts b/tests/unit/openai-responses-request-split.test.ts new file mode 100644 index 0000000000..50699805a6 --- /dev/null +++ b/tests/unit/openai-responses-request-split.test.ts @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the openai-responses request-translator extraction. +// Pure shared primitives live in `openai-responses/helpers.ts`; the chat->Responses +// direction (`openaiToOpenAIResponsesRequest`) lives in `openai-responses/toResponses.ts`. +// The host keeps `openaiResponsesToOpenAIRequest` + both register() calls and re-exports +// the moved function so external importers (tests) keep working unchanged. +const HERE = dirname(fileURLToPath(import.meta.url)); +const REQ = join(HERE, "../../open-sse/translator/request"); +const HOST = join(REQ, "openai-responses.ts"); +const HELPERS = join(REQ, "openai-responses/helpers.ts"); +const TO_RESPONSES = join(REQ, "openai-responses/toResponses.ts"); + +test("helpers leaf is pure (no host import) and exports the shared primitives", () => { + const src = readFileSync(HELPERS, "utf8"); + assert.doesNotMatch(src, /from "\.\.\/openai-responses\.ts"/); + for (const sym of ["toRecord", "toString", "clampCallId", "normalizeVerbosity"]) { + assert.match(src, new RegExp(`export (function|const) ${sym}\\b`)); + } +}); + +test("toResponses leaf hosts the chat->Responses direction and imports helpers, not the host", () => { + const src = readFileSync(TO_RESPONSES, "utf8"); + assert.match(src, /export function openaiToOpenAIResponsesRequest\(/); + assert.match(src, /from "\.\/helpers\.ts"/); + assert.doesNotMatch(src, /from "\.\.\/openai-responses\.ts"/); +}); + +test("host re-exports the moved function and keeps both register() directions", () => { + const src = readFileSync(HOST, "utf8"); + assert.match( + src, + /export \{ openaiToOpenAIResponsesRequest \} from "\.\/openai-responses\/toResponses\.ts"/ + ); + assert.match(src, /export function openaiResponsesToOpenAIRequest\(/); + assert.match(src, /register\(FORMATS\.OPENAI_RESPONSES, FORMATS\.OPENAI,/); + assert.match(src, /register\(FORMATS\.OPENAI, FORMATS\.OPENAI_RESPONSES,/); +}); + +test("both directions are callable via the host module", async () => { + const mod = await import("../../open-sse/translator/request/openai-responses.ts"); + assert.equal(typeof mod.openaiResponsesToOpenAIRequest, "function"); + assert.equal(typeof mod.openaiToOpenAIResponsesRequest, "function"); + // chat->Responses basic shape: wraps into { input: [...], stream: true }. + const out = mod.openaiToOpenAIResponsesRequest( + "gpt-4", + { messages: [{ role: "user", content: "hi" }] }, + true, + null + ) as Record; + assert.ok(Array.isArray(out.input)); +}); From 16ccd5f586f8921d48581f022b2fae25ea34c1a2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:53:58 -0300 Subject: [PATCH 05/21] chore(ci): pr-evidence FAIL output tells you to push (body edit does not re-run the gate) (#5944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci.yml ignores the 'edited' event, so adding the Evidence block to the PR body after a push does not re-run check:pr-evidence — you need another commit. The FAIL report now says so, at the exact place someone sees the red check. + 5 unit tests (classification + hint-on-fail / no-hint-on-pass). Decided against a separate edited-triggered workflow: pr-evidence is not a required check (no ruleset gates it; release PRs merge UNSTABLE, not BLOCKED), so the gap is cosmetic and the generate-release skill already puts Evidence in the body before the first push. --- scripts/check/check-pr-evidence.mjs | 11 +++++- tests/unit/check-pr-evidence.test.ts | 55 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/unit/check-pr-evidence.test.ts diff --git a/scripts/check/check-pr-evidence.mjs b/scripts/check/check-pr-evidence.mjs index f5cbbf8624..41f059d67f 100644 --- a/scripts/check/check-pr-evidence.mjs +++ b/scripts/check/check-pr-evidence.mjs @@ -231,7 +231,16 @@ if (isMain) { } else if (result === "pass") { reportLines.push("Result: PASS", "", reason); } else { - reportLines.push("Result: FAIL", "", reason); + reportLines.push( + "Result: FAIL", + "", + reason, + "", + "> ℹ️ Editing the PR body to add the evidence does NOT re-run this gate — `ci.yml` " + + "does not listen to the `edited` event. Add the `## Evidence` block, then **push a " + + "commit** (or re-run this job) to re-validate. For releases, put the Evidence block in " + + "the body BEFORE the first push (see the generate-release skill, Phase 0)." + ); } const report = buildReport(reportLines); diff --git a/tests/unit/check-pr-evidence.test.ts b/tests/unit/check-pr-evidence.test.ts new file mode 100644 index 0000000000..3125ac1fe3 --- /dev/null +++ b/tests/unit/check-pr-evidence.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const mod = await import("../../scripts/check/check-pr-evidence.mjs"); +const { evaluatePrBody } = mod; +const SCRIPT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../scripts/check/check-pr-evidence.mjs" +); + +function run(body) { + try { + const out = execFileSync("node", [SCRIPT], { + encoding: "utf8", + env: { ...process.env, PR_BODY: body }, + }); + return { code: 0, out }; + } catch (err) { + return { code: err.status ?? 1, out: `${err.stdout || ""}${err.stderr || ""}` }; + } +} + +test("evaluatePrBody: no outcome claim → pass (no evidence required)", () => { + const r = evaluatePrBody("Adds a helper module."); + assert.equal(r.result, "pass"); + assert.match(r.reason, /no evidence required/i); +}); + +test("evaluatePrBody: outcome claim + evidence block → pass", () => { + const r = evaluatePrBody("Tests pass.\n\n## Evidence\n```\ntests 20 / pass 20 / fail 0\n```"); + assert.equal(r.result, "pass"); +}); + +test("evaluatePrBody: outcome claim without evidence → fail", () => { + const r = evaluatePrBody("All 20 tests pass and 0 errors."); + assert.equal(r.result, "fail"); +}); + +test("the FAIL report explains that editing the body does not re-run the gate (push instead)", () => { + const { code, out } = run("All 20 tests pass and 0 errors."); // claim, no evidence + assert.equal(code, 1, "gate fails on a claim with no evidence"); + assert.match(out, /Result: FAIL/); + assert.match(out, /does NOT re-run this gate/); + assert.match(out, /push a commit/i); +}); + +test("the hint does NOT appear when the gate passes", () => { + const { code, out } = run("Tests pass.\n\n## Evidence\n```\ntests 20 / pass 20 / fail 0\n```"); + assert.equal(code, 0); + assert.match(out, /Result: PASS/); + assert.doesNotMatch(out, /does NOT re-run this gate/); +}); From 9ba79c7a701894dd5a0bd291169c51b83f4c342a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:04:30 -0300 Subject: [PATCH 06/21] fix(providers): Perplexity Web emits real tool_calls in streaming mode (mirror chatgpt-web toolMode) (#5927) (#5937) Perplexity Web (Pro/Max) only converted {...} text into OpenAI tool_calls for non-streaming requests (hasTools && !stream). Streaming requests -- the default for agentic coding clients -- got the raw text as plain delta.content and never emitted a tool_calls SSE delta, so clients could not execute tools. Reuses the provider-agnostic buildToolModeResponse()/ toolCompletionToSseStream() helpers already shipped for chatgpt-web (#5240): when tools are requested, buffer the full completion and convert it into either a JSON completion or a terminal SSE replay carrying delta.tool_calls + finish_reason: tool_calls, regardless of the caller's stream flag. Extended buildToolModeResponse()'s idSeed to be caller-supplied (default 'cgpt', perplexity-web passes 'pplx') so tool_call ids stay provider-specific without duplicating the helper. Non-tool streaming is unchanged (still lives token-by-token via buildStreamingResponse). --- open-sse/executors/chatgptWebTools.ts | 32 ++-- open-sse/executors/perplexity-web.ts | 51 +++--- ...erplexity-web-streaming-tools-5927.test.ts | 167 ++++++++++++++++++ 3 files changed, 211 insertions(+), 39 deletions(-) create mode 100644 tests/unit/perplexity-web-streaming-tools-5927.test.ts diff --git a/open-sse/executors/chatgptWebTools.ts b/open-sse/executors/chatgptWebTools.ts index 4a52506b33..55a1c5be91 100644 --- a/open-sse/executors/chatgptWebTools.ts +++ b/open-sse/executors/chatgptWebTools.ts @@ -1,13 +1,16 @@ -// Tool-call emulation helpers for the ChatGPT Web executor (#5240). +// Tool-call emulation helpers for web-cookie executors (#5240, #5927). // -// chatgpt.com has no native function calling. When the OpenAI request carries -// `tools`, the prompt-side shim (`prepareToolMessages` in -// ../translator/webTools.ts) injects a `` contract; on the response side -// we parse `{...}` blocks back into OpenAI `tool_calls` — -// mirroring the sibling web-session executors (qwen-web, perplexity-web, ...). +// Web-cookie providers (chatgpt-web, perplexity-web, ...) have no native +// function calling. When the OpenAI request carries `tools`, the prompt-side +// shim (`prepareToolMessages` in ../translator/webTools.ts) injects a `` +// contract; on the response side we parse `{...}` blocks back +// into OpenAI `tool_calls`. // -// The whole tool-mode orchestration lives here so the (frozen) chatgpt-web.ts -// only gains an import + a single delegating call. +// The whole tool-mode orchestration lives here — provider-agnostic — so each +// (frozen) executor only gains an import + a single delegating call. Despite +// the filename (kept for git-blame continuity from #5240, the first caller), +// this module is shared: `buildToolModeResponse()` accepts an `idSeed` so +// every provider gets its own `tool_calls[].id` prefix. import { buildToolAwareResult } from "../translator/webTools.ts"; @@ -28,7 +31,8 @@ function sseChunk(data: unknown): string { */ async function applyToolCallsToJsonResponse( response: Response, - requestedTools: unknown + requestedTools: unknown, + idSeed: string ): Promise { const bodyText = await response.text(); try { @@ -37,7 +41,7 @@ async function applyToolCallsToJsonResponse( const { content, toolCalls, finishReason } = buildToolAwareResult( rawContent, requestedTools, - "cgpt" + idSeed ); if (toolCalls) { json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; @@ -107,9 +111,13 @@ export async function buildToolModeResponse( bufferedJson: Response, requestedTools: unknown, stream: boolean, - meta: { cid: string; created: number; model: string } + meta: { cid: string; created: number; model: string; idSeed?: string } ): Promise { - const jsonResponse = await applyToolCallsToJsonResponse(bufferedJson, requestedTools); + const jsonResponse = await applyToolCallsToJsonResponse( + bufferedJson, + requestedTools, + meta.idSeed ?? "cgpt" + ); if (!stream) return jsonResponse; const completion = await jsonResponse.json(); return new Response(toolCompletionToSseStream(completion, meta.cid, meta.created, meta.model), { diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 9b4397fae4..2e3ea7dea5 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -13,7 +13,8 @@ import { TlsClientUnavailableError, type TlsFetchResult, } from "../services/perplexityTlsClient.ts"; -import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { prepareToolMessages } from "../translator/webTools.ts"; +import { buildToolModeResponse } from "./chatgptWebTools.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; @@ -965,8 +966,29 @@ export class PerplexityWebExecutor extends BaseExecutor { const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); + // Tool mode buffers the full completion (no live token streaming) and + // converts text into real tool_calls — even when the caller asked + // for a streaming response — mirroring chatgpt-web's toolMode (#5240, + // #5927). Without this, streaming requests (the default for agentic + // coding clients) never emitted a tool_calls SSE delta. let finalResponse: Response; - if (stream) { + if (hasTools) { + const bufferedJson = await buildNonStreamingResponse( + response.body, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + signal + ); + finalResponse = await buildToolModeResponse(bufferedJson, requestedTools, stream, { + cid, + created, + model, + idSeed: "pplx", + }); + } else if (stream) { const sseStream = buildStreamingResponse( response.body, model, @@ -996,31 +1018,6 @@ export class PerplexityWebExecutor extends BaseExecutor { ); } - if (hasTools && !stream) { - const bodyText = await (finalResponse as Response).text(); - try { - const json = JSON.parse(bodyText); - const rawContent = json?.choices?.[0]?.message?.content || ""; - const { content, toolCalls, finishReason } = buildToolAwareResult( - rawContent, - requestedTools, - "pplx" - ); - if (toolCalls) { - json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; - json.choices[0].finish_reason = finishReason; - } else { - json.choices[0].message.content = content; - } - finalResponse = new Response(JSON.stringify(json), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch { - /* keep original response */ - } - } - return { response: finalResponse, url: PPLX_SSE_ENDPOINT, diff --git a/tests/unit/perplexity-web-streaming-tools-5927.test.ts b/tests/unit/perplexity-web-streaming-tools-5927.test.ts new file mode 100644 index 0000000000..8e867e6262 --- /dev/null +++ b/tests/unit/perplexity-web-streaming-tools-5927.test.ts @@ -0,0 +1,167 @@ +// Tool-call emulation for the Perplexity Web executor in STREAMING mode (#5927). +// +// perplexity-web.ts converts {...} text into real OpenAI tool_calls +// only for non-streaming requests (the `hasTools && !stream` gate). Streaming +// requests — the default for agentic coding clients — got the raw text +// as plain delta.content and never emitted a tool_calls SSE delta, so clients +// could not execute tools. These tests live in a dedicated file mirroring +// tests/unit/chatgpt-web-tools-5240.test.ts (the reference fix for chatgpt-web). + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts"); +const { __setTlsFetchOverrideForTesting } = await import( + "../../open-sse/services/perplexityTlsClient.ts" +); + +// ─── Helper: Build a mock SSE stream from Perplexity events ───────────────── + +function mockPplxStream(events: unknown[]) { + const encoder = new TextEncoder(); + const chunks: string[] = []; + for (const evt of events) { + chunks.push(`event: message\r\ndata: ${JSON.stringify(evt)}\r\n\r\n`); + } + chunks.push("event: end_of_stream\r\n\r\n"); + const body = chunks.join(""); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); +} + +function installMockFetch(streamEvents: unknown[]) { + __setTlsFetchOverrideForTesting(async () => { + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: mockPplxStream(streamEvents), + }; + }); + return () => __setTlsFetchOverrideForTesting(null); +} + +const WEATHER_TOOL = { + type: "function", + function: { + name: "write_file", + description: "Write a file to disk", + parameters: { + type: "object", + properties: { path: { type: "string" }, content: { type: "string" } }, + required: ["path", "content"], + }, + }, +}; + +const TOOL_CALL_TEXT = + '{"name":"write_file","arguments":{"path":"a.ts","content":"x"}}'; + +function toolEvents(text: string) { + return [ + { + backend_uuid: "tool-uuid-1", + blocks: [ + { + intended_usage: "markdown", + markdown_block: { chunks: [text], progress: "DONE" }, + }, + ], + status: "COMPLETED", + }, + ]; +} + +test("Tools stream: text becomes delta.tool_calls + finish_reason tool_calls, NOT raw content (#5927)", async () => { + const restore = installMockFetch(toolEvents(TOOL_CALL_TEXT)); + try { + const executor = new PerplexityWebExecutor(); + const result = await executor.execute({ + model: "pplx-auto", + body: { + messages: [{ role: "user", content: "write a file" }], + tools: [WEATHER_TOOL], + stream: true, + }, + stream: true, + credentials: { apiKey: "test-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + } as any); + + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + + const text = await result.response.text(); + const chunks = text + .split("\n") + .filter((l) => l.startsWith("data: ") && !l.includes("[DONE]")) + .map((l) => JSON.parse(l.slice(6))); + + // Must NOT leak raw text as plain content. + assert.ok( + chunks.every((c) => { + const content = c.choices?.[0]?.delta?.content; + return typeof content !== "string" || !content.includes(""); + }), + "no chunk contains raw text in delta.content" + ); + + const toolChunk = chunks.find((c) => c.choices[0].delta && c.choices[0].delta.tool_calls); + assert.ok(toolChunk, "a chunk carries delta.tool_calls"); + assert.equal(toolChunk.choices[0].finish_reason, "tool_calls"); + const tc = toolChunk.choices[0].delta.tool_calls; + assert.ok(Array.isArray(tc) && tc.length === 1); + assert.equal(tc[0].type, "function"); + assert.equal(tc[0].function.name, "write_file"); + assert.equal(typeof tc[0].function.arguments, "string", "arguments is a JSON string"); + assert.deepEqual(JSON.parse(tc[0].function.arguments), { path: "a.ts", content: "x" }); + + const lastLine = text.trim().split("\n").filter(Boolean).pop(); + assert.equal(lastLine, "data: [DONE]"); + } finally { + restore(); + } +}); + +test("Tools regression: streaming request with NO tools still streams plain content unchanged (#5927)", async () => { + const restore = installMockFetch(toolEvents("Just plain text, no tools.")); + try { + const executor = new PerplexityWebExecutor(); + const result = await executor.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: true }, + stream: true, + credentials: { apiKey: "test-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + } as any); + + assert.equal(result.response.status, 200); + const text = await result.response.text(); + const chunks = text + .split("\n") + .filter((l) => l.startsWith("data: ") && !l.includes("[DONE]")) + .map((l) => JSON.parse(l.slice(6))); + + let assembled = ""; + for (const c of chunks) { + const content = c.choices?.[0]?.delta?.content; + if (content) assembled += content; + } + assert.equal(assembled, "Just plain text, no tools."); + + assert.ok( + chunks.every((c) => !(c.choices[0].delta && c.choices[0].delta.tool_calls)), + "no tool_calls emitted without a tools array" + ); + const finishChunk = chunks.find((c) => c.choices[0].finish_reason); + assert.equal(finishChunk.choices[0].finish_reason, "stop"); + } finally { + restore(); + } +}); From cd81b2ab9868f0f33c39581495a288b0285bfa8a Mon Sep 17 00:00:00 2001 From: Hamsa_M <116961508+hamsa0x7@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:00:49 +0530 Subject: [PATCH 07/21] fix(discovery): resolve duplicate /v1 paths and redirect aborts (#5904) Integrated into release/v3.8.44. Thanks @hamsa0x7 for diagnosing the doubled /v1 discovery path and the REDIRECT_BLOCKED probe-loop abort (#5899). De-scoped to the discovery fix (the #5903 session-affinity work is handled by #5943) and added Rule #18 regression guards. --- CHANGELOG.md | 2 +- config/quality/file-size-baseline.json | 3 +- src/app/api/providers/[id]/models/route.ts | 9 +- tests/unit/provider-models-route.test.ts | 124 +++++++++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 070d8dae6e..7484b1c082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ _TBD_ ### 🔧 Bug Fixes -_TBD_ +- **fix(providers): Api Airforce model discovery no longer produces a doubled `/v1` path** — a base URL ending in `/v1/chat/completions` (e.g. `https://api.airforce/v1/chat/completions`) was only stripped of `/chat/completions`, leaving a trailing `/v1` that the endpoint builder then doubled into `…/v1/v1/models`. That 308 redirect was surfaced as `REDIRECT_BLOCKED` and aborted the whole discovery probe loop before the correct `…/v1/models` candidate. The `/v1` suffix is now stripped independently (guarding a host literally named `v1`), and a `REDIRECT_BLOCKED` on one candidate continues to the next endpoint instead of aborting. Regression guards: `tests/unit/provider-models-route.test.ts`. ([#5904](https://github.com/diegosouzapw/OmniRoute/pull/5904) — thanks [@hamsa0x7](https://github.com/hamsa0x7)). Also reported/fixed independently by [@anki1kr](https://github.com/anki1kr) in [#5920](https://github.com/diegosouzapw/OmniRoute/pull/5920). ### 📝 Maintenance diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 2756977aa9..69c2eacabe 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -294,7 +294,7 @@ "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "tests/unit/oauth-providers-config.test.ts": 873, "tests/unit/perplexity-web.test.ts": 959, - "tests/unit/provider-models-route.test.ts": 1628, + "tests/unit/provider-models-route.test.ts": 1752, "tests/unit/provider-validation-specialty.test.ts": 2874, "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", "tests/unit/providers-page-utils.test.ts": 1052, @@ -356,6 +356,7 @@ "_rebaseline_2026_06_17_4107_pending_reaper": "PR #4107 own growth: usageHistory.ts 854->934 (+80 = orphaned-pending-request reaper — sweepStalePendingRequests() evicts pending details older than 15min + a hard 5000 cap, plus an unref'd 5min sweep timer wired lazily into trackPendingRequest). Fixes an unbounded memory leak where abnormally-terminated requests left payload previews in pendingById forever. Cohesive with the existing pending-request bookkeeping (mirrors the normal removal path: decrement counters + cleanup buckets); not extractable.", "_rebaseline_2026_06_17_4116_combo_hedge_listener": "combo.ts: +9 lines from #4116 (detach per-target listener from shared hedge abort signal to fix a listener leak). Behavior-preserving cleanup; 5289 -> 5298.", "_rebaseline_2026_06_20_4355_gpt5x_pro_pricing": "PR #4355 own growth: pricing.ts 1581->1592 (+11 = pure-data pricing rows for openai gpt-5.5-pro + gpt-5.4-pro, closing the $0 gap that tripped the catalog pricing gate after the #4324 sweep added them to the registry; -pro mirrors its base family tier). provider-models-route.test.ts 1616->1618 (+2 = test-only alignment to the intentional opencode-go discovery behavior: owned_by stamp + T39 two-endpoint fail-path fetchCalls). Both are data/test-only; not extractable.", + "_rebaseline_2026_07_02_5899_airforce_v1_discovery": "PR #5904 own growth: provider-models-route.test.ts 1628->1752 (+124 = test-only Rule #18 regression guards for the Api Airforce /v1/v1/models discovery bug (#5899): (a) a baseUrl ending in /v1/chat/completions must probe .../v1/models not the doubled .../v1/v1/models, and the host-guard case http://v1; (b) a REDIRECT_BLOCKED on one candidate must continue to the next endpoint instead of aborting the probe loop. Both guards fail on the pre-fix code. Test-only additions cohesive with the existing provider-models discovery suite (shared seedConnection/callRoute harness); not separately extractable without duplicating the harness.", "_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable.", "_rebaseline_2026_06_20_4447_openai_gpt41mini_o_mini_pricing": "PR #4447 own growth: pricing.ts 1592->1620 (+28 = pure-data pricing rows closing the null/$0 gap for registry-exposed OpenAI ids gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini that tripped the catalog pricing gate; getPricingForModel does an exact lookup, so a missing key resolves to null. Official OpenAI per-1M prices + the table's derived-field convention (reasoning=output*1.5, cache_creation=input, cached=official). Restore-green for a pre-existing release/v3.8.32 red surfaced by #4432's __RUN_ALL__ run. Cohesive data; not extractable.", "_rebaseline_2026_06_20_web_cookie_validator_shadow_fix": "validation.ts 4518->4522 (+4 = move the generic web-cookie validateWebCookieProvider dispatch from the TOP of validateProviderApiKey to a FALLBACK after SPECIALTY_VALIDATORS, plus a comment, so #4023's generic AUTH_007 ping no longer shadows the rich per-provider validators (grok-web #3474 IP-reputation/Cloudflare, chatgpt-web cf-mitigated, claude/gemini/copilot/qwen/t3-web). Restores provider-validation-specialty.test.ts (112/112) while keeping web-cookie-auth007 (5/5). Behavior fix at an existing dispatch boundary; not extractable.", diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index c217c45636..532edc74dd 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -533,7 +533,9 @@ export async function GET( base = base.slice(0, -17); } else if (base.endsWith("/completions")) { base = base.slice(0, -12); - } else if (base.endsWith("/v1")) { + } + + if (base.endsWith("/v1") && !base.endsWith("://v1")) { base = base.slice(0, -3); } @@ -576,6 +578,11 @@ export async function GET( } } catch (err: any) { if (err.message === "auth_failed") break; // Don't try other endpoints if auth failed + + if (err?.code === "REDIRECT_BLOCKED") { + continue; // Try next endpoint + } + const status = getSafeOutboundFetchErrorStatus(err); if (status) { throw err; diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 0dd23c0f6e..f83c81e5a3 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -320,6 +320,130 @@ test("provider models route discovers SiliconFlow models from configured China b ]); }); +test("provider models route handles local hostnames named 'v1' correctly", async () => { + const connection = await seedConnection("openai-compatible-local-v1", { + apiKey: "sk-local", + providerSpecificData: { + baseUrl: "http://v1/chat/completions", + }, + }); + const seenUrls: string[] = []; + + globalThis.fetch = async (url) => { + seenUrls.push(String(url)); + return Response.json({ + data: [{ id: "local-v1-model", name: "Local v1 Model" }], + }); + }; + + const response = await callRoute(connection.id); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.source, "api"); + assert.deepEqual(seenUrls, ["http://v1/v1/models"]); +}); + +test("provider models route correctly strips standard /v1 paths", async () => { + const connection = await seedConnection("openai-compatible-standard-v1", { + apiKey: "sk-standard", + providerSpecificData: { + baseUrl: "https://api.openai.com/v1", + }, + }); + const seenUrls: string[] = []; + + globalThis.fetch = async (url) => { + seenUrls.push(String(url)); + return Response.json({ + data: [{ id: "standard-model", name: "Standard Model" }], + }); + }; + + const response = await callRoute(connection.id); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.source, "api"); + assert.deepEqual(seenUrls, ["https://api.openai.com/v1/models"]); +}); + +test("provider models route strips /v1 when it precedes /chat/completions (#5899 no double /v1)", async () => { + // Regression for #5899 (Api Airforce): a baseUrl of the form + // "https://api.airforce/v1/chat/completions" must probe ".../v1/models" — NOT + // ".../v1/v1/models". The old `else if` strip chain only removed + // "/chat/completions", leaving a trailing "/v1" that the endpoint builder then + // doubled, producing a 308 redirect that aborted discovery. + const connection = await seedConnection("openai-compatible-airforce-v1", { + apiKey: "sk-airforce", + providerSpecificData: { + baseUrl: "https://api.airforce/v1/chat/completions", + }, + }); + const seenUrls: string[] = []; + + globalThis.fetch = async (url) => { + seenUrls.push(String(url)); + return Response.json({ + data: [{ id: "airforce-model", name: "Airforce Model" }], + }); + }; + + const response = await callRoute(connection.id); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.source, "api"); + // First probed endpoint must have a single /v1 — no ".../v1/v1/models". + assert.equal(seenUrls[0], "https://api.airforce/v1/models"); + assert.ok( + !seenUrls.some((u) => u.includes("/v1/v1/")), + `no endpoint should contain a doubled /v1: ${JSON.stringify(seenUrls)}` + ); +}); + +test("provider models route continues probing past a REDIRECT_BLOCKED endpoint (#5899)", async () => { + // Regression for #5899: a REDIRECT_BLOCKED error on one candidate endpoint must + // not abort the whole probe loop — discovery should fall through to the next + // endpoint instead of surfacing an empty catalog. + const connection = await seedConnection("openai-compatible-redirect-v1", { + apiKey: "sk-redirect", + providerSpecificData: { + baseUrl: "https://redirect.example", + }, + }); + const seenUrls: string[] = []; + + globalThis.fetch = async (url) => { + const u = String(url); + seenUrls.push(u); + // First candidate ".../v1/models" answers with a real 308 redirect → + // safeOutboundFetch throws a SafeOutboundFetchError(REDIRECT_BLOCKED). The old + // code re-threw on it (status 503) and aborted the loop; the fix `continue`s. + if (u === "https://redirect.example/v1/models") { + return new Response(null, { + status: 308, + headers: { location: "https://redirect.example/models" }, + }); + } + return Response.json({ + data: [{ id: "redirect-model", name: "Redirect Model" }], + }); + }; + + const response = await callRoute(connection.id); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + // Without the REDIRECT_BLOCKED `continue`, discovery aborted and fell back to a + // non-api catalog. The fix lets it reach the next endpoint and return live models. + assert.equal(body.source, "api"); + assert.ok( + seenUrls.length >= 2, + `expected the loop to continue past REDIRECT_BLOCKED: ${JSON.stringify(seenUrls)}` + ); +}); + test("provider models route returns static catalog entries for providers with hardcoded models", async () => { const connection = await seedConnection("bailian-coding-plan", { apiKey: "bailian-key", From f199e40ed5364969f6a32cdcb69bbaea4a691d2b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:43:08 -0300 Subject: [PATCH 08/21] docs(changelog): record #5926 + #5944 (release-pipeline hardening) under v3.8.44 Maintenance (#5952) --- CHANGELOG.md | 4 +++- docs/i18n/ar/CHANGELOG.md | 4 +++- docs/i18n/az/CHANGELOG.md | 4 +++- docs/i18n/bg/CHANGELOG.md | 4 +++- docs/i18n/bn/CHANGELOG.md | 4 +++- docs/i18n/cs/CHANGELOG.md | 4 +++- docs/i18n/da/CHANGELOG.md | 4 +++- docs/i18n/de/CHANGELOG.md | 4 +++- docs/i18n/es/CHANGELOG.md | 4 +++- docs/i18n/fa/CHANGELOG.md | 4 +++- docs/i18n/fi/CHANGELOG.md | 4 +++- docs/i18n/fr/CHANGELOG.md | 4 +++- docs/i18n/gu/CHANGELOG.md | 4 +++- docs/i18n/he/CHANGELOG.md | 4 +++- docs/i18n/hi/CHANGELOG.md | 4 +++- docs/i18n/hu/CHANGELOG.md | 4 +++- docs/i18n/id/CHANGELOG.md | 4 +++- docs/i18n/in/CHANGELOG.md | 4 +++- docs/i18n/it/CHANGELOG.md | 4 +++- docs/i18n/ja/CHANGELOG.md | 4 +++- docs/i18n/ko/CHANGELOG.md | 4 +++- docs/i18n/mr/CHANGELOG.md | 4 +++- docs/i18n/ms/CHANGELOG.md | 4 +++- docs/i18n/nl/CHANGELOG.md | 4 +++- docs/i18n/no/CHANGELOG.md | 4 +++- docs/i18n/phi/CHANGELOG.md | 4 +++- docs/i18n/pl/CHANGELOG.md | 4 +++- docs/i18n/pt-BR/CHANGELOG.md | 4 +++- docs/i18n/pt/CHANGELOG.md | 4 +++- docs/i18n/ro/CHANGELOG.md | 4 +++- docs/i18n/ru/CHANGELOG.md | 4 +++- docs/i18n/sk/CHANGELOG.md | 4 +++- docs/i18n/sv/CHANGELOG.md | 4 +++- docs/i18n/sw/CHANGELOG.md | 4 +++- docs/i18n/ta/CHANGELOG.md | 4 +++- docs/i18n/te/CHANGELOG.md | 4 +++- docs/i18n/th/CHANGELOG.md | 4 +++- docs/i18n/tr/CHANGELOG.md | 4 +++- docs/i18n/uk-UA/CHANGELOG.md | 4 +++- docs/i18n/ur/CHANGELOG.md | 4 +++- docs/i18n/vi/CHANGELOG.md | 4 +++- docs/i18n/zh-CN/CHANGELOG.md | 4 +++- docs/i18n/zh-TW/CHANGELOG.md | 4 +++- 43 files changed, 129 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7484b1c082..7dc8921677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 9cf34b1f55..b4c82b1fd6 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 12e3ffec84..8de9ab05bb 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 12e3ffec84..8de9ab05bb 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 3b312b29f9..617accb78e 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index f05e07d9b1..e62fbf84b6 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index c6058fd910..80fbdba782 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 5b506a73ce..9e14f4b4c3 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 5aec22e1cc..69e027a78a 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index eb56a9949b..ffbe0a483e 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 9cdd3373f9..f871b94115 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 29f7cd89d1..9e2fcaafd4 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 361959fa08..4c7a87e6c2 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index c1aa1037bb..2e4bd19326 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index a965418055..df76512c74 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 7a0ca0188d..a18e470447 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 615a64b2f1..8d2aed4eab 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 1a74a82caa..fcf2ed56fa 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index bd27ead536..c94f8141bd 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 6c6b62d385..b85edc2f77 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 468b5df85c..15379d4c50 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index c80ef577d2..04f2941515 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 9bdfea1f3d..0716d956ae 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 4f11577737..7310fa3d05 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 4c54209466..5120498b4a 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index c86ded1b41..b70c00c980 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 98c93872e4..3ea2df224e 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index c185eed816..544c063285 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 2f01d48a1e..50d165299b 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index ccb612610d..85c2e5f0a9 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index eac409263a..559cc49cd9 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 13dc93966a..f64d625fa0 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index d8060dd265..cb14cfd613 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 944dc9423d..4db8d7f643 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 1b1f178386..12fe054a6a 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 5bade31007..5d4d5d2b0f 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 1082acedca..e914ae4e52 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 8d96dfca4a..de9f3a3504 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 01d2af9713..28140682bc 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index f4db47a880..889b71d4ff 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 682c3134ff..63fcd348f0 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 692a04c30d..c12a4802f3 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- diff --git a/docs/i18n/zh-TW/CHANGELOG.md b/docs/i18n/zh-TW/CHANGELOG.md index a370d59e21..b06956abe3 100644 --- a/docs/i18n/zh-TW/CHANGELOG.md +++ b/docs/i18n/zh-TW/CHANGELOG.md @@ -18,7 +18,9 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) --- From 283a501a1a752612d9e5610bfd13375b867f509c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:16:19 -0300 Subject: [PATCH 09/21] =?UTF-8?q?docs(claude):=20add=20Hard=20Rule=20#22?= =?UTF-8?q?=20=E2=80=94=20cross-session=20safety=20(git=20stash=20+=20in-f?= =?UTF-8?q?light=20PRs)=20(#5955)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — Hard Rule #22 (cross-session safety). --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index dcfda7c548..12d4e1c41e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -541,6 +541,9 @@ the stale-enforcement added in Fase 6A.3. 19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation". 20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`. 21. **Release-freeze — the release branch is frozen to campaign merges while a `/generate-release` is running.** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a) and closes it once the release PR squash-merges to `main`. Before merging **any** PR into the active `release/vX.Y.Z` branch, every campaign workflow (`/review-issues`, `/review-prs`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active, **HOLD the merge** (leave the PR ready and open; do NOT merge to the release branch), tell the operator, and resume once the freeze lifts. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view --json state`) is the authorized captain freeze — hold, don't touch. +22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening): + - **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show :` or `git diff -- `; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent). + - **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create *this* session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.) --- From b6249dd374b3cb68e26a2061676d43bf6c07cec5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:38:38 -0300 Subject: [PATCH 10/21] refactor(translator): extract pure helpers from response/openai-responses (#5949) Extract the 5 stateless helpers (normalizeToolName, stripEmptyOptionalToolArgs, normalizeOutputIndex, normalizeUpstreamFailure, extractResponsesReasoningSummaryText) verbatim into the pure leaf openai-responses/pureHelpers.ts (no stream state, no host import). Host imports them back and re-exports normalizeUpstreamFailure for external importers (tests). Host 1091 -> 1001 LOC. The stateful streaming core stays in the host (out of scope). Byte-identical bodies (multiset 73/73), no cycle. Adds a split-guard; consumer tests stay green (responses-translation-fixes 37, combo-param-validation-fallback-4519 5). --- .../translator/response/openai-responses.ts | 98 ++----------------- .../response/openai-responses/pureHelpers.ts | 92 +++++++++++++++++ ...openai-responses-purehelpers-split.test.ts | 60 ++++++++++++ 3 files changed, 161 insertions(+), 89 deletions(-) create mode 100644 open-sse/translator/response/openai-responses/pureHelpers.ts create mode 100644 tests/unit/response-openai-responses-purehelpers-split.test.ts diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 3a9a1299da..6e62715f84 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -7,38 +7,16 @@ import { FORMATS } from "../formats.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { fallbackToolCallId } from "../helpers/toolCallHelper.ts"; import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts"; +import { + normalizeToolName, + stripEmptyOptionalToolArgs, + normalizeOutputIndex, + normalizeUpstreamFailure, + extractResponsesReasoningSummaryText, +} from "./openai-responses/pureHelpers.ts"; -function normalizeToolName(value) { - return typeof value === "string" ? value.trim() : ""; -} - -function stripEmptyOptionalToolArgs(value, toolName) { - if (value == null) return value; - - if (typeof value === "string") { - // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. - // For arbitrary tools, empty strings/arrays may be valid user payloads. - if (toolName !== "Read") return value; - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; - const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); - return JSON.stringify(cleaned ?? {}); - } catch { - return value; - } - } - - if (Array.isArray(value) || typeof value !== "object") return value; - - const cleaned = { ...value }; - for (const [key, entry] of Object.entries(cleaned)) { - if (entry === "" || (Array.isArray(entry) && entry.length === 0)) { - delete cleaned[key]; - } - } - return cleaned; -} +// normalizeUpstreamFailure is re-exported for external importers (tests). +export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; /** * Translate OpenAI chunk to Responses API events @@ -192,11 +170,6 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } // Normalize output_index to a non-negative integer (replaces fragile parseInt calls) -function normalizeOutputIndex(outputIndex) { - const normalized = Number(outputIndex); - return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0; -} - // Record a finalized item keyed by output_index so buildDenseOutput can sort later function recordCompletedItem(state, outputIndex, item) { if (!Array.isArray(state.completedOutputItems)) { @@ -564,50 +537,6 @@ function flushEvents(state) { return events; } -export function normalizeUpstreamFailure(data, fallbackType = "server_error") { - const response = data?.response && typeof data.response === "object" ? data.response : null; - const error = - response?.error && typeof response.error === "object" - ? response.error - : data?.error && typeof data.error === "object" - ? data.error - : null; - - const code = typeof error?.code === "string" ? error.code : ""; - const message = - typeof error?.message === "string" - ? error.message - : typeof data?.message === "string" - ? data.message - : "Upstream failure"; - - // Preserve upstream error semantics: - // - context_length_exceeded → 400 (client can retry with smaller context) - // - rate_limit_exceeded → 429 (client should back off) - // - Everything else → 502 (upstream failure) - const isContextOverflow = code === "context_length_exceeded"; - const isRateLimit = code === "rate_limit_exceeded" || code === "rate_limited"; - let status: number; - let type: string; - if (isRateLimit) { - status = 429; - type = "rate_limit_error"; - } else if (isContextOverflow) { - status = 400; - type = "invalid_request_error"; - } else { - status = 502; - type = fallbackType; - } - - return { - status, - type, - code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"), - message, - }; -} - /** * OpenAI Chat Completions streams announce the assistant role on the FIRST delta * (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant", @@ -680,15 +609,6 @@ function buildResponsesReasoningDeltaChunk(state, text) { }; } -function extractResponsesReasoningSummaryText(item) { - if (!item || !Array.isArray(item.summary)) return ""; - return item.summary - .map((part) => - part && typeof part === "object" && typeof part.text === "string" ? part.text : "" - ) - .join(""); -} - /** * Translate OpenAI Responses API chunk to OpenAI Chat Completions format * This is for when Codex returns data and we need to send it to an OpenAI-compatible client diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts new file mode 100644 index 0000000000..3ed559fb55 --- /dev/null +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -0,0 +1,92 @@ +// Pure, stateless helpers for the OpenAI Responses <-> Chat response translator. +// Extracted verbatim from response/openai-responses.ts (no host imports, no stream state). + +export function normalizeToolName(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function stripEmptyOptionalToolArgs(value, toolName) { + if (value == null) return value; + + if (typeof value === "string") { + // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. + // For arbitrary tools, empty strings/arrays may be valid user payloads. + if (toolName !== "Read") return value; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; + const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); + return JSON.stringify(cleaned ?? {}); + } catch { + return value; + } + } + + if (Array.isArray(value) || typeof value !== "object") return value; + + const cleaned = { ...value }; + for (const [key, entry] of Object.entries(cleaned)) { + if (entry === "" || (Array.isArray(entry) && entry.length === 0)) { + delete cleaned[key]; + } + } + return cleaned; +} + +export function normalizeOutputIndex(outputIndex) { + const normalized = Number(outputIndex); + return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0; +} + +export function normalizeUpstreamFailure(data, fallbackType = "server_error") { + const response = data?.response && typeof data.response === "object" ? data.response : null; + const error = + response?.error && typeof response.error === "object" + ? response.error + : data?.error && typeof data.error === "object" + ? data.error + : null; + + const code = typeof error?.code === "string" ? error.code : ""; + const message = + typeof error?.message === "string" + ? error.message + : typeof data?.message === "string" + ? data.message + : "Upstream failure"; + + // Preserve upstream error semantics: + // - context_length_exceeded → 400 (client can retry with smaller context) + // - rate_limit_exceeded → 429 (client should back off) + // - Everything else → 502 (upstream failure) + const isContextOverflow = code === "context_length_exceeded"; + const isRateLimit = code === "rate_limit_exceeded" || code === "rate_limited"; + let status: number; + let type: string; + if (isRateLimit) { + status = 429; + type = "rate_limit_error"; + } else if (isContextOverflow) { + status = 400; + type = "invalid_request_error"; + } else { + status = 502; + type = fallbackType; + } + + return { + status, + type, + code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"), + message, + }; +} + +export function extractResponsesReasoningSummaryText(item) { + if (!item || !Array.isArray(item.summary)) return ""; + return item.summary + .map((part) => + part && typeof part === "object" && typeof part.text === "string" ? part.text : "" + ) + .join(""); +} diff --git a/tests/unit/response-openai-responses-purehelpers-split.test.ts b/tests/unit/response-openai-responses-purehelpers-split.test.ts new file mode 100644 index 0000000000..7b8485471e --- /dev/null +++ b/tests/unit/response-openai-responses-purehelpers-split.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the response/openai-responses pure-helper extraction. +// The stateless helpers (normalizeToolName / stripEmptyOptionalToolArgs / +// normalizeOutputIndex / normalizeUpstreamFailure / extractResponsesReasoningSummaryText) +// live in the pure leaf `openai-responses/pureHelpers.ts` (no stream state, no host import). +// The host imports them back and re-exports normalizeUpstreamFailure for external importers. +const HERE = dirname(fileURLToPath(import.meta.url)); +const RESP = join(HERE, "../../open-sse/translator/response"); +const HOST = join(RESP, "openai-responses.ts"); +const LEAF = join(RESP, "openai-responses/pureHelpers.ts"); + +test("leaf hosts the pure helpers, has no stream state and no host import", () => { + const src = readFileSync(LEAF, "utf8"); + for (const sym of [ + "normalizeToolName", + "stripEmptyOptionalToolArgs", + "normalizeOutputIndex", + "normalizeUpstreamFailure", + "extractResponsesReasoningSummaryText", + ]) { + assert.match(src, new RegExp(`export function ${sym}\\b`)); + } + assert.doesNotMatch(src, /from "\.\.\/openai-responses\.ts"/); + // No stream-state parameter leaked into the pure leaf (ignore comments). + const code = src + .split("\n") + .filter((l) => !l.trim().startsWith("//")) + .join("\n"); + assert.doesNotMatch(code, /\bstate\b/); +}); + +test("host imports helpers back and re-exports normalizeUpstreamFailure", () => { + const src = readFileSync(HOST, "utf8"); + assert.match(src, /from "\.\/openai-responses\/pureHelpers\.ts"/); + assert.match( + src, + /export \{ normalizeUpstreamFailure \} from "\.\/openai-responses\/pureHelpers\.ts"/ + ); +}); + +test("normalizeUpstreamFailure preserves upstream error semantics", async () => { + const { normalizeUpstreamFailure } = + await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts"); + assert.equal( + normalizeUpstreamFailure({ error: { code: "rate_limit_exceeded", message: "slow down" } }) + .status, + 429 + ); + assert.equal( + normalizeUpstreamFailure({ error: { code: "context_length_exceeded", message: "too big" } }) + .status, + 400 + ); + assert.equal(normalizeUpstreamFailure({ message: "boom" }).status, 502); +}); From 26dc500c1660405419aaf8b485829cd2f0efaa18 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:27:44 -0300 Subject: [PATCH 11/21] docs(compression): document upstream sync policy for RTK/Caveman engines (#5830) (#5948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — docs-only upstream sync policy for RTK/Caveman engines (closes #5830). All 7 checks green. --- docs/compression/EXTENDING_COMPRESSION.md | 60 ++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/compression/EXTENDING_COMPRESSION.md b/docs/compression/EXTENDING_COMPRESSION.md index 9aea461674..7b33f5b37d 100644 --- a/docs/compression/EXTENDING_COMPRESSION.md +++ b/docs/compression/EXTENDING_COMPRESSION.md @@ -1,7 +1,7 @@ --- title: "Extending the Compression Pipeline" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.44 +lastUpdated: 2026-07-02 --- # Extending the Compression Pipeline @@ -512,6 +512,62 @@ To drive it from config, set `mode: "stacked"` and provide the step array under --- +## Upstream Sync Policy + +OmniRoute's compression engines credit several upstream projects in the README +("inspired by RTK, Caveman, LLMLingua-2, Troglodita"). A common contributor +question is: **when upstream RTK adds a new tool filter or Caveman adds a rule +pack, how does that reach OmniRoute?** This section is the authoritative answer. + +### Vendored copies vs. independent implementations + +| Engine | Relationship to upstream | Location | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **RTK** | **Independent reimplementation** (inspired-by, not a copy) | `open-sse/services/compression/engines/rtk/` | +| **Caveman** | **Independent reimplementation** (inspired-by) | `open-sse/services/compression/engines/cavemanAdapter.ts` | +| **Headroom** | Mostly internal; only the `gcf/` codec is **genuinely vendored** from `gcf-typescript` (MIT, SPDX-marked, generic profile only) | `open-sse/services/compression/engines/headroom/gcf/` | +| **LLMLingua-2 / Troglodita** | Inspired-by (drive the `llmlingua` + `session-dedup` engines) | `open-sse/services/compression/engines/llmlingua/`, `session-dedup` | + +Key point: **RTK and Caveman are clean-room TypeScript implementations of the +_ideas_ (filter rules, rule packs), not vendored source trees.** There is no +upstream copy to `git pull` from — which is exactly why the README says +"inspired by" rather than "bundled". + +### How upstream improvements are merged + +There is **no automated upstream-release tracking and no `compression-sync` +label** — by design. Because the engines are reimplementations, an upstream RTK +filter or Caveman rule pack is not merged as code; it is **re-expressed as a new +rule/filter in OmniRoute's own format** (see +[COMPRESSION_RULES_FORMAT.md](./COMPRESSION_RULES_FORMAT.md)) and lands ad-hoc via +a normal PR. The extension points above (custom engine, language pack, RTK filter) +are the sanctioned way to contribute one. + +Recent examples of exactly this flow: + +- RTK filters for Gradle & `dotnet` build output (v3.8.42) +- RTK filters for kubectl / docker-build / composer / gh (#2824) +- Caveman Indonesian language pack (#3975), plus German / French / Japanese / Chinese packs + +### Headroom (input-compression proxy) + +Headroom is **fully internal** — a pinned vendored `gcf` codec snapshot plus +OmniRoute's own `smartcrusher` / `toon` / `tabular` layers. There is no live +upstream to track beyond the vendored copy; updates to `gcf` are refreshed +manually when the codec changes and re-validated against the compression budget +gate (`check:compression-budget`). + +### Proposing an upstream-inspired improvement + +1. **Don't vendor** — re-express the upstream rule/filter in OmniRoute's format. +2. Add it via the matching extension point below (language pack, RTK filter, or + custom engine). +3. Reference the upstream project in the PR description (attribution), not by + copying its license-bearing source. +4. Include tests and confirm the `check:compression-budget` gate still passes. + +--- + ## Best Practices ### Engine Development From 058cfd4f9590587b8db97111e62916bf4e8ea693 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:29:16 -0300 Subject: [PATCH 12/21] fix(sse): strip ANSI/VT100 codes from gemini-cli stream frames (#5934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — ReDoS-safe ANSI/VT100 strip for gemini-cli stream frames (port of upstream #2273, thanks @anki1kr). PR test green (5/5), file-size gate OK. --- CHANGELOG.md | 6 +-- .../translator/response/gemini-to-openai.ts | 15 ++++-- open-sse/utils/streamHelpers.ts | 36 +++++++++++++-- .../unit/gemini-cli-ansi-sanitization.test.ts | 46 +++++++++++++++++++ 4 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 tests/unit/gemini-cli-ansi-sanitization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc8921677..57375278ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,11 @@ _TBD_ ### 🔧 Bug Fixes -- **fix(providers): Api Airforce model discovery no longer produces a doubled `/v1` path** — a base URL ending in `/v1/chat/completions` (e.g. `https://api.airforce/v1/chat/completions`) was only stripped of `/chat/completions`, leaving a trailing `/v1` that the endpoint builder then doubled into `…/v1/v1/models`. That 308 redirect was surfaced as `REDIRECT_BLOCKED` and aborted the whole discovery probe loop before the correct `…/v1/models` candidate. The `/v1` suffix is now stripped independently (guarding a host literally named `v1`), and a `REDIRECT_BLOCKED` on one candidate continues to the next endpoint instead of aborting. Regression guards: `tests/unit/provider-models-route.test.ts`. ([#5904](https://github.com/diegosouzapw/OmniRoute/pull/5904) — thanks [@hamsa0x7](https://github.com/hamsa0x7)). Also reported/fixed independently by [@anki1kr](https://github.com/anki1kr) in [#5920](https://github.com/diegosouzapw/OmniRoute/pull/5920). +- **fix(sse):** strip ANSI/VT100 escape codes from gemini-cli stream frames so ANSI-prefixed `data:` lines are no longer silently dropped. (thanks @anki1kr) ### 📝 Maintenance -- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) - -- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) +_TBD_ --- diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index dbf608b148..9d8a8629fe 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -9,6 +9,7 @@ import { containsTextualToolCallMarker, } from "../../utils/textualToolCall.ts"; import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts"; +import { stripAnsiCodes } from "../../utils/streamHelpers.ts"; type GeminiToOpenAIState = { functionIndex: number; @@ -401,6 +402,10 @@ export function geminiToOpenAIResponse(chunk, state) { // Process parts if (content?.parts) { for (const part of content.parts) { + // Normalize the part text once: strip ANSI/VT100 escape codes that some + // upstreams (gemini-cli terminal redraws) inject, so the `` / + // `[Tool call:]` textual parsers below never see stray control bytes (#2273). + const partText = stripAnsiCodes(part.text); const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; if (hasThoughtSig && typeof hasThoughtSig === "string") { @@ -409,7 +414,7 @@ export function geminiToOpenAIResponse(chunk, state) { // Handle thought signature (thinking mode) or native gemini thought flag if (hasThoughtSig || isThought) { - const hasTextContent = part.text !== undefined && part.text !== ""; + const hasTextContent = partText !== undefined && partText !== ""; const hasFunctionCall = !!part.functionCall; // Gemini/Antigravity can emit thoughtSignature as a standalone part @@ -433,7 +438,7 @@ export function geminiToOpenAIResponse(chunk, state) { choices: [ { index: 0, - delta: isThought ? { reasoning_content: part.text } : { content: part.text }, + delta: isThought ? { reasoning_content: partText } : { content: partText }, finish_reason: null, }, ], @@ -463,10 +468,10 @@ export function geminiToOpenAIResponse(chunk, state) { // "[Tool call: ...]" block instead of native functionCall. Convert that // back to a structured OpenAI tool call so clients/tools do not see it as // assistant prose. - if (part.text !== undefined && part.text !== "") { + if (partText !== undefined && partText !== "") { const afterReasoning = parseTextualReasoningTags - ? consumeTextualReasoningTags(part.text, state, results) - : part.text; + ? consumeTextualReasoningTags(partText, state, results) + : partText; if (!afterReasoning) continue; let accumulated = (state.textualToolCallBuffer || "") + afterReasoning; diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index bfa5a2aad2..03e0b66f5c 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -53,6 +53,31 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +/** + * Matches ANSI/VT100 terminal control sequences plus non-whitespace C0 control + * codes, while preserving `\t` (0x09), `\n` (0x0a), and `\r` (0x0d). + * + * Some upstream CLIs (notably gemini-cli via the `gc/` bridge) prefix SSE frames + * with cursor-movement escapes such as `\x1b[2K\x1b[1A` to redraw the terminal. + * Those bytes are not whitespace, so `line.trimStart().startsWith("data:")` fails + * and the frame is silently dropped, stalling the client SSE parser (issue #2273). + * + * The pattern is strictly bounded (no unbounded quantifiers over overlapping + * alternatives) so it runs in linear time on untrusted input — ReDoS-safe. + */ +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_RE = + /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g; + +/** + * Strip ANSI/VT100 escape sequences (and stray C0 controls) from a string. + * Non-string inputs (null/undefined) are returned unchanged. Preserves \t \n \r. + */ +export function stripAnsiCodes(str: T): T { + if (typeof str !== "string") return str; + return str.replace(ANSI_ESCAPE_RE, "") as T; +} + export function parseSSEDataPayload( data: unknown, options: SSEPayloadOptions = {} @@ -88,15 +113,18 @@ export function parseSSEDataLines( export function parseSSELine(line: string): SSEJsonPayload | null { if (!line) return null; - // Trim leading whitespace before checking field name. + // Trim leading whitespace before checking field name. Also strip ANSI/VT100 + // escape codes so terminal-redraw-prefixed frames (e.g. gemini-cli `\x1b[2K\x1b[1A`) + // still resolve to a `data:` line instead of being silently dropped (#2273). const trimmed = line.trimStart(); - if (!trimmed.startsWith("data:")) return null; + const clean = stripAnsiCodes(trimmed); + if (!clean.startsWith("data:")) return null; - return parseSSEDataPayload(trimmed.slice(5)); + return parseSSEDataPayload(clean.slice(5)); } function extractSseDataLine(line: string): string | null { - const trimmed = line.trimStart().replace(/\r$/, ""); + const trimmed = stripAnsiCodes(line.trimStart().replace(/\r$/, "")); if (!trimmed.startsWith("data:")) return null; return trimmed.slice(5).trimStart(); } diff --git a/tests/unit/gemini-cli-ansi-sanitization.test.ts b/tests/unit/gemini-cli-ansi-sanitization.test.ts new file mode 100644 index 0000000000..0291be48f7 --- /dev/null +++ b/tests/unit/gemini-cli-ansi-sanitization.test.ts @@ -0,0 +1,46 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { parseSSELine, stripAnsiCodes } from "../../open-sse/utils/streamHelpers.ts"; + +test("parseSSELine resolves an ANSI/VT100-prefixed data: frame (gemini-cli redraw)", () => { + // gemini-cli prefixes SSE frames with cursor-redraw escapes (\x1b[2K clears the + // line, \x1b[1A moves the cursor up). 0x1b is not whitespace, so before the fix + // trimStart().startsWith("data:") failed and the frame was silently dropped (#2273). + const line = `\x1b[2K\x1b[1Adata: ${JSON.stringify({ + choices: [{ delta: { content: "hi" } }], + })}`; + const r = parseSSELine(line); + assert.ok(r, "expected a parsed payload, got null (frame was dropped)"); + assert.equal(r?.choices?.[0]?.delta?.content, "hi"); +}); + +test("parseSSELine returns null for a pure-ANSI line (nothing after stripping)", () => { + assert.equal(parseSSELine("\x1b[2K\x1b[1A"), null); +}); + +test("stripAnsiCodes strips CSI/SGR/OSC/C0 but preserves \\t \\n \\r", () => { + // CSI cursor moves + SGR color codes + assert.equal(stripAnsiCodes("\x1b[2K\x1b[1Ahello"), "hello"); + assert.equal(stripAnsiCodes("\x1b[31mred\x1b[0m"), "red"); + // OSC sequence terminated by BEL (\x07) + assert.equal(stripAnsiCodes("\x1b]0;title\x07text"), "text"); + // OSC sequence terminated by ST (\x1b\\) + assert.equal(stripAnsiCodes("\x1b]8;;https://x\x1b\\link"), "link"); + // stray C0 control byte + assert.equal(stripAnsiCodes("a\x00b"), "ab"); + // whitespace preserved + assert.equal(stripAnsiCodes("a\tb\nc\rd"), "a\tb\nc\rd"); +}); + +test("stripAnsiCodes passes null/undefined through unchanged", () => { + assert.equal(stripAnsiCodes(null), null); + assert.equal(stripAnsiCodes(undefined), undefined); +}); + +test("stripAnsiCodes runs in linear time on adversarial input (ReDoS guard)", () => { + const hostile = "\x1b[" + "0;".repeat(50000) + "m"; + const start = Date.now(); + stripAnsiCodes(hostile); + assert.ok(Date.now() - start < 1000, "stripAnsiCodes should not backtrack catastrophically"); +}); From 18cf641df49e103992c1ae4862df6329b5a7ba3d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:30:29 -0300 Subject: [PATCH 13/21] =?UTF-8?q?fix(translator):=20strict=20Anthropic=20c?= =?UTF-8?q?ontent-block=20compliance=20in=20antigravity=E2=86=92openai=20r?= =?UTF-8?q?equest=20(#5935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — strict Anthropic content-block compliance in antigravity→openai (port upstream #2296). PR test green (9/9). UNSTABLE red is the pre-existing environmental setup-claude base-red (opencode-plugin dist not built in fast-path), not a regression from this PR. --- CHANGELOG.md | 2 +- .../request/antigravity-to-openai.ts | 31 +++++++-- .../translator-antigravity-to-openai.test.ts | 68 +++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57375278ec..b08d31be40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ _TBD_ ### 🔧 Bug Fixes -- **fix(sse):** strip ANSI/VT100 escape codes from gemini-cli stream frames so ANSI-prefixed `data:` lines are no longer silently dropped. (thanks @anki1kr) +- **fix(translator):** antigravity→openai request now emits Anthropic-compliant content blocks — drops empty text blocks and preserves tool calls/text co-located with tool results. (thanks @SahrulRamadhanHardiansyah) ### 📝 Maintenance diff --git a/open-sse/translator/request/antigravity-to-openai.ts b/open-sse/translator/request/antigravity-to-openai.ts index 24d25d4b11..d2ae57fcb2 100644 --- a/open-sse/translator/request/antigravity-to-openai.ts +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -229,14 +229,17 @@ function convertContent(content) { continue; } - // Text with thoughtSignature = regular text after thinking + // Text with thoughtSignature = regular text after thinking. + // Skip empty text — Anthropic rejects empty content blocks with a 400. if (part.thoughtSignature && part.text !== undefined) { - textParts.push({ type: "text", text: part.text }); + if (part.text) { + textParts.push({ type: "text", text: part.text }); + } continue; } - // Regular text - if (part.text !== undefined) { + // Regular text — skip empty strings (Anthropic rejects empty content blocks). + if (part.text !== undefined && part.text !== "") { textParts.push({ type: "text", text: part.text }); } @@ -274,8 +277,26 @@ function convertContent(content) { } } - // Content with only functionResponses → return array of tool messages + // Function responses may be co-located with function calls / text / reasoning in + // the same content. Emit the tool messages AND the accompanying assistant message so + // nothing is dropped (previously only the tool messages survived). if (toolResults.length > 0) { + if (toolCalls.length > 0 || textParts.length > 0 || reasoningContent) { + const assistantMsg: JsonRecord = { role: "assistant" }; + if (textParts.length > 0) { + assistantMsg.content = + textParts.length === 1 && textParts[0].type === "text" + ? textParts[0].text + : textParts; + } + if (reasoningContent) { + assistantMsg.reasoning_content = reasoningContent; + } + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls; + } + return [...toolResults, assistantMsg]; + } return toolResults; } diff --git a/tests/unit/translator-antigravity-to-openai.test.ts b/tests/unit/translator-antigravity-to-openai.test.ts index 6012b0bb9a..1cd2de11a4 100644 --- a/tests/unit/translator-antigravity-to-openai.test.ts +++ b/tests/unit/translator-antigravity-to-openai.test.ts @@ -154,6 +154,74 @@ test("Antigravity -> OpenAI returns tool messages when content contains only fun ]); }); +test("Antigravity -> OpenAI keeps co-located function response, function call and text", () => { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + contents: [ + { + role: "model", + parts: [ + { text: "Let me look that up." }, + { functionResponse: { id: "call_9", name: "lookup", response: { result: { ok: true } } } }, + { functionCall: { id: "call_10", name: "lookup", args: { q: "weather" } } }, + ], + }, + ], + }, + }, + false + ); + + // Both the tool-result message AND the accompanying assistant message must survive. + const toolMsg = result.messages.find((m) => m.role === "tool"); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(toolMsg, "expected a role:tool message"); + assert.equal(toolMsg.tool_call_id, "call_9"); + assert.ok(assistantMsg, "expected a role:assistant message"); + assert.equal(assistantMsg.content, "Let me look that up."); + assert.deepEqual(assistantMsg.tool_calls, [ + { + id: "call_10", + type: "function", + function: { name: "lookup", arguments: '{"q":"weather"}' }, + }, + ]); +}); + +test("Antigravity -> OpenAI drops empty thoughtSignature text instead of emitting empty content", () => { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + contents: [ + { + role: "model", + parts: [ + { thoughtSignature: "sig", text: "" }, + { functionCall: { id: "call_11", name: "noop", args: {} } }, + ], + }, + ], + }, + }, + false + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "expected a role:assistant message"); + // No empty content block should be emitted (Anthropic rejects it with a 400). + assert.equal("content" in assistantMsg, false); + assert.deepEqual(assistantMsg.tool_calls, [ + { + id: "call_11", + type: "function", + function: { name: "noop", arguments: "{}" }, + }, + ]); +}); + test("Antigravity -> OpenAI lowers schema types recursively", () => { const result = antigravityToOpenAIRequest( "gpt-4o", From 70a70e68c8fa30af52a3c0b809f62e3a91e49850 Mon Sep 17 00:00:00 2001 From: Chewji <126886556+Chewji9875@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:32:47 +0700 Subject: [PATCH 14/21] fix(mcp): auto-recover stale streamable HTTP sessions on initialize (#5957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — MCP stale streamable-HTTP session auto-recovery (thanks @Chewji9875). --- open-sse/mcp-server/httpTransport.ts | 21 ++++++++ tests/unit/mcp-session-sweep.test.ts | 73 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index 67448e15d0..7b982142d4 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -173,6 +173,27 @@ async function handleStreamableRequest(request: Request): Promise { // terminated/unknown, the server MUST respond with HTTP 404 Not Found so the // client re-initializes. A 400 here is non-recoverable for spec-compliant // clients (they only re-init on 404). See issue #5169. + // + // Auto-recovery: if the client sends an initialize request with a stale session + // id (e.g. after a server restart or idle eviction), treat it as a fresh + // initialization rather than hard-failing with 404. This avoids requiring users + // to manually restart their MCP client after every server restart. + if (await isInitializeRequest(request)) { + const newSession = createStreamableSession(); + try { + const response = await withMcpHttpAuthContext(request, () => + newSession.transport.handleRequest(request) + ); + return withSessionHeader(response, newSession.sessionId); + } catch (err) { + closeStreamableSession(newSession.sessionId); + console.error("[MCP] Streamable HTTP error during stale-session recovery:", err); + return new Response(JSON.stringify({ error: "MCP transport error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + } return errorResponse("Not Found: Unknown Mcp-Session-Id header", -32000, 404); } diff --git a/tests/unit/mcp-session-sweep.test.ts b/tests/unit/mcp-session-sweep.test.ts index 8848d60ab3..95a09c2ea1 100644 --- a/tests/unit/mcp-session-sweep.test.ts +++ b/tests/unit/mcp-session-sweep.test.ts @@ -347,3 +347,76 @@ test("handleMcpStreamableHTTP keeps 400 for a missing session id (non-initialize // only the *present-but-unknown* case changed to 404. This must NOT regress. assert.equal(res.status, 400); }); + +test("handleMcpStreamableHTTP auto-recovers when stale session id is sent with initialize", async () => { + mod.shutdownMcpHttp(); + + const initReq = new Request("http://localhost/api/mcp/stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }); + + const firstRes = await mod.handleMcpStreamableHTTP(initReq); + const staleSessionId = firstRes.headers.get("mcp-session-id"); + if (!staleSessionId) { + mod.shutdownMcpHttp(); + return; + } + + mod.shutdownMcpHttp(); + assert.equal(mod.isMcpHttpActive(), false); + + const reinitReq = new Request("http://localhost/api/mcp/stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": staleSessionId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }); + + const recoveryRes = await mod.handleMcpStreamableHTTP(reinitReq); + + assert.notEqual( + recoveryRes.status, + 404, + "Stale session + initialize must NOT return 404 — server should auto-recover" + ); + assert.ok( + recoveryRes.status >= 200 && recoveryRes.status < 300, + `Expected 2xx response on auto-recovery, got ${recoveryRes.status}` + ); + + const newSessionId = recoveryRes.headers.get("mcp-session-id"); + assert.ok(newSessionId, "Server must issue a new mcp-session-id on auto-recovery"); + assert.equal( + mod.isMcpHttpActive(), + true, + "Server must have an active session after auto-recovery" + ); + + mod.shutdownMcpHttp(); +}); From 11a22bb59909aacd8b282b338c9f974fe5d89519 Mon Sep 17 00:00:00 2001 From: Vittor Guilherme Borges de Oliveira Date: Thu, 2 Jul 2026 17:33:08 -0300 Subject: [PATCH 15/21] fix(providers): validate v0 Platform API keys via chats endpoint (#5954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — v0-vercel Platform API key validation (thanks @vittoroliveira-dev). --- src/lib/providers/validation.ts | 33 ++++++++++++ .../provider-validation-specialty.test.ts | 53 +++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index b8df0801a1..faa4002767 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -264,6 +264,39 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // ── Specialty provider validation ── const SPECIALTY_VALIDATORS = { + "v0-vercel": async ({ apiKey, providerSpecificData }: any) => { + try { + const configuredBaseUrl = + typeof providerSpecificData?.baseUrl === "string" && providerSpecificData.baseUrl.trim() + ? providerSpecificData.baseUrl.trim() + : "https://api.v0.dev"; + + const root = normalizeBaseUrl(configuredBaseUrl) + .replace(/\/v1\/chat\/completions$/, "") + .replace(/\/v1$/, ""); + + const res = await validationRead( + `${root}/v1/chats?limit=1`, + { + method: "GET", + headers: buildBearerHeaders(apiKey, providerSpecificData), + }, + isLocal + ); + + if (res.ok) { + return { valid: true, error: null, method: "v0_platform_chats_list" }; + } + + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + return { valid: false, error: `v0 validation failed: ${res.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } + }, jules: validateJulesProvider, qoder: async ({ apiKey, providerSpecificData }: any) => { // Bifurcate validation: PAT tokens use Cosy auth against api1.qoder.sh; diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 6a7bbd3d6d..e1afca203e 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -242,6 +242,48 @@ test("embedding and rerank specialty validators surface auth failures for Voyage assert.equal(jina.error, "Invalid API key"); }); +test("v0-vercel specialty validator checks the Platform API chats endpoint", async () => { + globalThis.fetch = async (url, init = {}) => { + assert.equal(String(url), "https://api.v0.dev/v1/chats?limit=1"); + assert.equal((init.headers as Record).Authorization, "Bearer v0-key"); + return new Response(JSON.stringify({ object: "list", data: [] }), { status: 200 }); + }; + + const result = await validateProviderApiKey({ + provider: "v0-vercel", + apiKey: "v0-key", + providerSpecificData: { + baseUrl: "https://api.v0.dev/v1/chat/completions", + }, + }); + + assert.deepEqual(result, { + valid: true, + error: null, + method: "v0_platform_chats_list", + }); +}); + +test("v0-vercel specialty validator treats auth failures as invalid API key", async () => { + globalThis.fetch = async (url, init = {}) => { + assert.equal(String(url), "https://api.v0.dev/v1/chats?limit=1"); + assert.equal((init.headers as Record).Authorization, "Bearer bad-v0-key"); + return new Response(JSON.stringify({ error: { type: "unauthorized_error" } }), { + status: 401, + }); + }; + + const result = await validateProviderApiKey({ + provider: "v0-vercel", + apiKey: "bad-v0-key", + providerSpecificData: { + baseUrl: "https://api.v0.dev/v1", + }, + }); + + assert.equal(result.error, "Invalid API key"); +}); + test("gitlab specialty validator accepts PAT auth on the direct access endpoint", async () => { globalThis.fetch = async (url, init = {}) => { assert.equal(String(url), "https://gitlab.com/api/v4/code_suggestions/direct_access"); @@ -2783,12 +2825,14 @@ test("huggingface validator accepts a token whoami-v2 recognizes", async () => { }); test("huggingface validator treats 401/403 as an invalid token", async () => { - globalThis.fetch = async () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); + globalThis.fetch = async () => + new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); const unauthorized = await validateProviderApiKey({ provider: "huggingface", apiKey: "hf_bad" }); assert.equal(unauthorized.valid, false); assert.equal(unauthorized.error, "Invalid API key"); - globalThis.fetch = async () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }); + globalThis.fetch = async () => + new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }); const forbidden = await validateProviderApiKey({ provider: "huggingface", apiKey: "hf_bad" }); assert.equal(forbidden.valid, false); assert.equal(forbidden.error, "Invalid API key"); @@ -2800,7 +2844,10 @@ test("huggingface validator does NOT mark a fine-grained token invalid on a non- // non-OK status must surface as a transient error, never "Invalid API key". globalThis.fetch = async () => new Response("upstream down", { status: 503 }); - const result = await validateProviderApiKey({ provider: "huggingface", apiKey: "hf_finegrained" }); + const result = await validateProviderApiKey({ + provider: "huggingface", + apiKey: "hf_finegrained", + }); assert.equal(result.valid, false); assert.notEqual(result.error, "Invalid API key"); From b2f77f302840359fc84fcfe287f022c3fbedbf14 Mon Sep 17 00:00:00 2001 From: nickwizard <35692452+nickwizard@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:33:27 +0300 Subject: [PATCH 16/21] fix(api): relax provider-scoped chat completion validation (#5907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — relaxed provider-scoped chat validation + regression test (thanks @nickwizard). --- .../[provider]/chat/completions/route.ts | 22 +++-- ...scoped-chat-completions-validation.test.ts | 86 +++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 tests/unit/provider-scoped-chat-completions-validation.test.ts diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.ts b/src/app/api/v1/providers/[provider]/chat/completions/route.ts index 20b2e15c81..d6c336a659 100644 --- a/src/app/api/v1/providers/[provider]/chat/completions/route.ts +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.ts @@ -3,8 +3,6 @@ import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; -import { providerChatCompletionSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; let initialized = false; @@ -30,6 +28,7 @@ export async function OPTIONS() { /** * POST /v1/providers/{provider}/chat/completions * Routes to the specified provider, validating model/provider match. + * Full body format validation is delegated to handleChat. */ export async function POST(request, { params }) { const { provider: rawProvider } = await params; @@ -45,18 +44,25 @@ export async function POST(request, { params }) { await ensureInitialized(); - // Clone request with provider-prefixed model - let rawBody; + // Parse body once so this provider-scoped route can normalize the model prefix + // before delegating full chat-format validation to handleChat. + let rawBody: unknown; try { rawBody = await request.json(); } catch { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); } - const validation = validateBody(providerChatCompletionSchema, rawBody); - if (isValidationFailure(validation)) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message); + + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Request body must be a JSON object"); + } + + const body = rawBody as { model?: string; [key: string]: unknown }; + + // Keep the route-level checks minimal: only guard fields needed for provider prefix handling. + if (body.model !== undefined && typeof body.model !== "string") { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "model must be a string"); } - const body = validation.data; // Validate model belongs to this provider if (body.model) { diff --git a/tests/unit/provider-scoped-chat-completions-validation.test.ts b/tests/unit/provider-scoped-chat-completions-validation.test.ts new file mode 100644 index 0000000000..24650b5f23 --- /dev/null +++ b/tests/unit/provider-scoped-chat-completions-validation.test.ts @@ -0,0 +1,86 @@ +// Regression guard for #5907 — the provider-scoped chat/completions route +// (`/v1/providers/{provider}/chat/completions`) must NOT re-apply the strict +// `providerChatCompletionSchema`. Full body-format validation is delegated to +// handleChat; the route keeps only the minimal guards it needs to normalize the +// model prefix. This test locks that contract without mocking handleChat (the +// project's node:test runner does not enable --experimental-test-module-mocks, +// and the Stryker tap-runner rejects mock.module), by exercising the branches +// that return BEFORE delegation: +// - a loosely-valid body (no `messages`, which the removed strict schema would +// have 400'd) now reaches the model-prefix logic instead of a schema 400; +// - the minimal route-level guards still reject invalid JSON, non-object +// bodies, non-string models, and unknown providers. +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +const { POST } = + await import("../../src/app/api/v1/providers/[provider]/chat/completions/route.ts"); + +// Importing the route transitively opens the SQLite handle (handleChat's graph). +// Release it so Node's native test runner does not hang on open handles. +after(async () => { + try { + const core = await import("../../src/lib/db/core.ts"); + core.resetDbInstance(); + } catch { + // best-effort cleanup — never fail the suite on teardown + } +}); + +function makeRequest(body: string) { + return new Request("http://localhost/v1/providers/openai/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); +} + +const params = (provider: string) => ({ params: Promise.resolve({ provider }) }); + +test("#5907 a loosely-valid body (no messages) is no longer rejected by the removed strict schema", async () => { + // `{ model: "anthropic/..." }` has NO `messages`. Under the old strict + // providerChatCompletionSchema this 400'd at the route before any prefix + // check. The relaxed route must instead run the model-prefix validation and + // return the *specific* cross-provider error — proving the schema is gone. + const res = await POST( + makeRequest(JSON.stringify({ model: "anthropic/claude-3-5-sonnet" })), + params("openai") + ); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match( + body.error.message, + /does not belong to provider/i, + "expected the model-prefix check to run — a schema validation 400 would mean the strict schema is still applied" + ); +}); + +test("#5907 rejects invalid JSON with 400", async () => { + const res = await POST(makeRequest("{not json"), params("openai")); + assert.equal(res.status, 400); +}); + +test("#5907 rejects a non-object body (array) with 400", async () => { + const res = await POST(makeRequest(JSON.stringify([1, 2, 3])), params("openai")); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error.message, /must be a JSON object/i); +}); + +test("#5907 rejects a non-string model with 400", async () => { + const res = await POST(makeRequest(JSON.stringify({ model: 123 })), params("openai")); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error.message, /model must be a string/i); +}); + +test("#5907 rejects an unknown provider with 400", async () => { + const res = await POST(makeRequest(JSON.stringify({ model: "gpt-4o" })), params("nope-xyz")); + assert.equal(res.status, 400); +}); + +test("#5907 route errors are sanitized (no stack trace leak in body)", async () => { + const res = await POST(makeRequest("{bad"), params("openai")); + const body = await res.json(); + assert.ok(!JSON.stringify(body).includes("at /"), "error body must not leak a stack trace"); +}); From ad9a8599e306a925909fdb83252b11585907d9d9 Mon Sep 17 00:00:00 2001 From: Ankit <177378174+anki1kr@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:03:45 +0530 Subject: [PATCH 17/21] fix(providers): strip /v1 unconditionally to avoid /v1/v1/models fetch error (#5899) (#5920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — unconditional /v1 strip in both models-discovery paths + regression test (thanks @anki1kr). --- src/app/api/providers/[id]/models/route.ts | 11 ++- .../airforce-v1-double-prefix-5899.test.ts | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/unit/airforce-v1-double-prefix-5899.test.ts diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 532edc74dd..6d1448d803 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -535,6 +535,11 @@ export async function GET( base = base.slice(0, -12); } + // Strip trailing /v1 unconditionally so the next step re-adds it exactly once. + // Without this, baseUrls that embed /v1 (e.g. "https://api.airforce/v1/chat/completions") + // become "…/v1" after stripping "/chat/completions", and then appending "/v1/models" + // produces "…/v1/v1/models" — a 308 redirect that blocked model fetch (#5899). + // Guard against a literal "scheme://v1" authority so we never strip the host itself. if (base.endsWith("/v1") && !base.endsWith("://v1")) { base = base.slice(0, -3); } @@ -1724,7 +1729,11 @@ export async function GET( base = base.slice(0, -"/chat/completions".length); } else if (base.endsWith("/completions")) { base = base.slice(0, -"/completions".length); - } else if (base.endsWith("/v1")) { + } + // Strip a trailing /v1 unconditionally (same #5899 double-prefix guard as the + // discovery path above): a customBaseUrl like ".../v1/chat/completions" would + // otherwise leave base as ".../v1" and produce ".../v1/v1/models" below. + if (base.endsWith("/v1") && !base.endsWith("://v1")) { base = base.slice(0, -"/v1".length); } url = `${base}/v1/models`; diff --git a/tests/unit/airforce-v1-double-prefix-5899.test.ts b/tests/unit/airforce-v1-double-prefix-5899.test.ts new file mode 100644 index 0000000000..288fbd0d03 --- /dev/null +++ b/tests/unit/airforce-v1-double-prefix-5899.test.ts @@ -0,0 +1,71 @@ +/** + * Regression for #5899 (PR #5920): the OpenAI-compatible models-discovery URL + * builder must strip a trailing `/v1` UNCONDITIONALLY before appending + * `/v1/models`. A gateway baseUrl like ".../v1/chat/completions" was reduced to + * ".../v1" (the old `else if` skipped the /v1 strip once `/chat/completions` + * matched) and then produced ".../v1/v1/models" — a 308 redirect that blocked + * model discovery. The fix converts the `/v1` strip to an independent `if` + * (guarding against a literal "scheme://v1" authority) in BOTH the general + * discovery path and the `provider === "openai"` custom-base-URL path. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-5899-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#5899 openai gateway baseUrl ending in /v1/chat/completions never probes /v1/v1/models", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "airforce-gateway", + apiKey: "sk-airforce", + providerSpecificData: { baseUrl: "https://api.airforce/v1/chat/completions" }, + }); + + const requestedUrls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + requestedUrls.push(u); + // The correctly-stripped candidate must be the one that serves models. + if (u === "https://api.airforce/v1/models") { + return Response.json({ object: "list", data: [{ id: "gpt-4o" }, { id: "gpt-5" }] }); + } + // The double-prefixed URL upstream answered with a 308 redirect (#5899). + if (u === "https://api.airforce/v1/v1/models") { + return new Response(null, { status: 308, headers: { location: u } }); + } + return new Response("not found", { status: 404 }); + }; + + try { + await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + } finally { + globalThis.fetch = originalFetch; + } + + assert.ok( + requestedUrls.includes("https://api.airforce/v1/models"), + `expected a request to the correctly-stripped /v1/models URL; got: ${JSON.stringify(requestedUrls)}` + ); + assert.ok( + !requestedUrls.includes("https://api.airforce/v1/v1/models"), + `must never probe the double-prefixed /v1/v1/models URL; got: ${JSON.stringify(requestedUrls)}` + ); +}); From 476968e290590cb6cb0bf6c5eb8f94fcf0927476 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:34:06 -0300 Subject: [PATCH 18/21] fix(resilience): per-window is_exhausted + honor quota-exhaustion preflight for priority combos (#5923) (#5941) Integrated into release/v3.8.44. --- open-sse/services/combo.ts | 87 ++++----- .../services/combo/quotaExhaustionCutoff.ts | 140 ++++++++++++++ src/domain/quotaCache.ts | 9 +- ...ority-quota-exhaustion-cutoff-5923.test.ts | 183 ++++++++++++++++++ ...cache-is-exhausted-per-window-5923.test.ts | 59 ++++++ 5 files changed, 426 insertions(+), 52 deletions(-) create mode 100644 open-sse/services/combo/quotaExhaustionCutoff.ts create mode 100644 tests/unit/combo-priority-quota-exhaustion-cutoff-5923.test.ts create mode 100644 tests/unit/quota-cache-is-exhausted-per-window-5923.test.ts diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c99c9bbd1d..4dc59ea8f4 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -48,7 +48,6 @@ import { fetchCodexQuota } from "./codexQuotaFetcher.ts"; import { evaluateQuotaCutoff, getQuotaFetcher, - type PreflightQuotaThresholds, type QuotaInfo, } from "./quotaPreflight.ts"; import * as semaphore from "./rateLimitSemaphore.ts"; @@ -195,6 +194,10 @@ import { orderTargetsByHeadroom, type PreScreenResult, } from "./combo/quotaStrategies.ts"; +import { + buildAutoQuotaThresholds, + resolveQuotaExhaustionCutoffForTarget, +} from "./combo/quotaExhaustionCutoff.ts"; import { classifyTask, getConversationCacheKey, @@ -257,55 +260,6 @@ function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } -function asThresholdMap(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - const result: Record = {}; - for (const [key, raw] of Object.entries(value as Record)) { - const numeric = Number(raw); - if (key && Number.isFinite(numeric)) result[key] = numeric; - } - return result; -} - -function quotaWindowLookupNames(provider: string, windowName: string): string[] { - const names = [windowName]; - const lower = windowName.toLowerCase(); - if (lower !== windowName) names.push(lower); - if (provider === "codex") { - if (lower.includes("session") || lower === "5h" || lower === "five_hour") names.push("session"); - if (lower.includes("weekly") || lower === "7d" || lower === "seven_day") names.push("weekly"); - if (lower.includes("monthly") || lower === "30d") names.push("monthly"); - } - return [...new Set(names)]; -} - -function buildAutoQuotaThresholds( - provider: string, - connection: Record | undefined, - resilienceSettings: ResilienceSettings | null | undefined -): PreflightQuotaThresholds { - const quotaPreflight = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight; - const defaultThresholdPercent = quotaPreflight?.defaultThresholdPercent ?? 2; - const warnThresholdPercent = quotaPreflight?.warnThresholdPercent ?? 20; - const providerWindowMap = asThresholdMap(quotaPreflight?.providerWindowDefaults?.[provider]); - const perConnectionWindowOverrides = asThresholdMap(connection?.quotaWindowThresholds); - - return { - resolveMinRemainingPercent: (windowName: string | null): number => { - if (windowName !== null) { - for (const lookupWindowName of quotaWindowLookupNames(provider, windowName)) { - const override = perConnectionWindowOverrides[lookupWindowName]; - if (typeof override === "number") return override; - const providerDefault = providerWindowMap[lookupWindowName]; - if (typeof providerDefault === "number") return providerDefault; - } - } - return defaultThresholdPercent; - }, - resolveWarnRemainingPercent: () => warnThresholdPercent, - }; -} - function quotaRemainingPercentFromQuota(quota: unknown): number { if (!quota || typeof quota !== "object") return 100; const record = quota as Record; @@ -1638,6 +1592,12 @@ export async function handleComboChat({ ) : new Map(); + // #5923 (Finding #4) — reset-window config for the shared per-target quota- + // exhaustion cutoff below. The "auto" strategy already applies its own cutoff + // via buildAutoCandidates/routableCandidates, so this only affects the other + // 16 strategies (priority, weighted, etc.) that funnel through executeTarget. + const quotaCutoffResetWindowConfig = resolveResetWindowConfig(config as Record); + if (orderedTargets.length === 0) { return comboModelNotFoundResponse("Combo has no executable targets"); } @@ -1790,6 +1750,33 @@ export async function handleComboChat({ return null; } + // #5923 (Finding #4) — honor the same opt-in quota-exhaustion cutoff the + // "auto" strategy already applies (buildAutoCandidates), for every other + // strategy (priority, weighted, etc.). Strictly scoped per (provider, + // connectionId): a 0%-remaining connection is skipped here, but sibling + // connections/models on the same provider are untouched — the provider + // circuit breaker is never touched by this check. The "auto" strategy is + // excluded to avoid a redundant duplicate fetch — it already filtered its + // candidate pool via `routableCandidates` before reaching this loop. + if (strategy !== "auto" && provider && target.connectionId) { + const quotaCutoff = await resolveQuotaExhaustionCutoffForTarget( + provider, + target.connectionId, + resilienceSettings, + quotaCutoffResetWindowConfig, + combo.name, + log + ); + if (quotaCutoff.blocked) { + log.info( + "COMBO", + `Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})` + ); + if (i > 0) fallbackCount++; + return null; + } + } + // Pre-screen snapshot is NOT used as a permanent skip — availability // is always re-checked via isModelAvailable below because connection // cooldowns can expire between setTry retries, making a previously diff --git a/open-sse/services/combo/quotaExhaustionCutoff.ts b/open-sse/services/combo/quotaExhaustionCutoff.ts new file mode 100644 index 0000000000..e0b75c1873 --- /dev/null +++ b/open-sse/services/combo/quotaExhaustionCutoff.ts @@ -0,0 +1,140 @@ +/** + * Quota-exhaustion cutoff helpers for combo routing. + * + * Home of the opt-in per-(provider, connection, window) quota-exhaustion cutoff + * shared by the "auto" strategy candidate builder (`buildAutoQuotaThresholds`, + * consumed by combo.ts::buildAutoCandidates) and the per-target eligibility loop + * (`resolveQuotaExhaustionCutoffForTarget`, consumed by combo.ts::handleComboChat + * for every non-auto strategy). Extracted from combo.ts (#5923 Finding #4) to + * keep the god-file under its frozen size cap; behavior is byte-identical. + * + * Pure leaf: this module never imports from the combo barrel. Threshold math and + * cutoff evaluation are delegated to ./quotaPreflight.ts; the reset-aware quota + * fetch/cache is delegated to ./quotaStrategies.ts. + */ + +import { + evaluateQuotaCutoff, + getQuotaFetcher, + type PreflightQuotaThresholds, + type QuotaInfo, +} from "../quotaPreflight.ts"; +import { getProviderConnectionById } from "../../../src/lib/db/providers"; +import { + resolveResilienceSettings, + type ResilienceSettings, +} from "../../../src/lib/resilience/settings"; +import { fetchResetAwareQuotaWithCache } from "./quotaStrategies.ts"; +import type { ResetWindowConfig } from "./quotaScoring.ts"; + +function asThresholdMap(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + const numeric = Number(raw); + if (key && Number.isFinite(numeric)) result[key] = numeric; + } + return result; +} + +function quotaWindowLookupNames(provider: string, windowName: string): string[] { + const names = [windowName]; + const lower = windowName.toLowerCase(); + if (lower !== windowName) names.push(lower); + if (provider === "codex") { + if (lower.includes("session") || lower === "5h" || lower === "five_hour") names.push("session"); + if (lower.includes("weekly") || lower === "7d" || lower === "seven_day") names.push("weekly"); + if (lower.includes("monthly") || lower === "30d") names.push("monthly"); + } + return [...new Set(names)]; +} + +export function buildAutoQuotaThresholds( + provider: string, + connection: Record | undefined, + resilienceSettings: ResilienceSettings | null | undefined +): PreflightQuotaThresholds { + const quotaPreflight = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight; + const defaultThresholdPercent = quotaPreflight?.defaultThresholdPercent ?? 2; + const warnThresholdPercent = quotaPreflight?.warnThresholdPercent ?? 20; + const providerWindowMap = asThresholdMap(quotaPreflight?.providerWindowDefaults?.[provider]); + const perConnectionWindowOverrides = asThresholdMap(connection?.quotaWindowThresholds); + + return { + resolveMinRemainingPercent: (windowName: string | null): number => { + if (windowName !== null) { + for (const lookupWindowName of quotaWindowLookupNames(provider, windowName)) { + const override = perConnectionWindowOverrides[lookupWindowName]; + if (typeof override === "number") return override; + const providerDefault = providerWindowMap[lookupWindowName]; + if (typeof providerDefault === "number") return providerDefault; + } + } + return defaultThresholdPercent; + }, + resolveWarnRemainingPercent: () => warnThresholdPercent, + }; +} + +/** + * #5923 (Finding #4) — Shared quota-exhaustion cutoff predicate, scoped strictly + * per (provider, connectionId, model window). Extracted from the inline logic + * that `buildAutoCandidates` has always used (fetch via the SAME + * `fetchResetAwareQuotaWithCache` cache, evaluate via the SAME pure + * `evaluateQuotaCutoff` + `buildAutoQuotaThresholds`), so priority/weighted/etc. + * strategies honor the operator's configured quota cutoff instead of only the + * "auto" strategy. + * + * Gated behind the SAME opt-in setting as the auto-strategy cutoff + * (`resilienceSettings.quotaPreflight.enabled`) — when that setting is off this + * is a no-op, exactly like the auto path. Never touches the provider circuit + * breaker; a blocked result only means "skip this one connection", leaving + * every sibling connection/model for the same provider fully eligible. + */ +export async function resolveQuotaExhaustionCutoffForTarget( + provider: string, + connectionId: string | undefined, + resilienceSettings: ResilienceSettings | null | undefined, + resetWindowConfig: ResetWindowConfig, + comboName: string, + log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } +): Promise<{ blocked: boolean; reason?: string }> { + const quotaCutoffEnabled = + (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight?.enabled === true; + if (!quotaCutoffEnabled || !provider || !connectionId) return { blocked: false }; + + const fetcher = getQuotaFetcher(provider); + if (!fetcher) return { blocked: false }; + + let connection: Record | undefined; + try { + connection = (await getProviderConnectionById(connectionId)) as + | Record + | undefined; + } catch { + connection = undefined; + } + + try { + const quota = await fetchResetAwareQuotaWithCache({ + provider, + connectionId, + connection, + fetcher, + config: resetWindowConfig, + log, + comboName, + }); + const cutoffDecision = evaluateQuotaCutoff( + quota as QuotaInfo | null, + buildAutoQuotaThresholds(provider, connection, resilienceSettings) + ); + if (!cutoffDecision.proceed) { + return { blocked: true, reason: cutoffDecision.reason || "quota_exhausted" }; + } + } catch { + // Fail-open: never block routing because the preflight fetch itself errored. + return { blocked: false }; + } + return { blocked: false }; +} diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index cb03598f90..ddbf2c6019 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -250,15 +250,20 @@ export function setQuotaCache( } : null, }); + // #5923 (Finding #5) — is_exhausted must reflect THIS window's own remaining + // percentage, not the connection-wide AND-across-all-windows aggregate + // (`entry.exhausted`). A connection with one 0% window and other non-zero + // windows previously never flagged that window's row as exhausted. + const windowExhausted = remainingPercentage <= 0; // #4438 — only persist on the first observation or a real change. - if (!quotaSnapshotChanged(prior, windowKey, remainingPercentage, entry.exhausted)) continue; + if (!quotaSnapshotChanged(prior, windowKey, remainingPercentage, windowExhausted)) continue; try { saveQuotaSnapshot({ provider, connection_id: connectionId, window_key: windowKey, remaining_percentage: remainingPercentage, - is_exhausted: entry.exhausted ? 1 : 0, + is_exhausted: windowExhausted ? 1 : 0, next_reset_at: quotaInfo.resetAt ?? null, window_duration_ms: entry.windowDurationMs ?? null, raw_data: null, diff --git a/tests/unit/combo-priority-quota-exhaustion-cutoff-5923.test.ts b/tests/unit/combo-priority-quota-exhaustion-cutoff-5923.test.ts new file mode 100644 index 0000000000..252c9cef3b --- /dev/null +++ b/tests/unit/combo-priority-quota-exhaustion-cutoff-5923.test.ts @@ -0,0 +1,183 @@ +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"; + +/** + * #5923 (Finding #4) — the quota-exhaustion preflight cutoff only ran for + * strategy === "auto" (buildAutoCandidates / routableCandidates in combo.ts). + * Priority/weighted/etc. strategies funneled through the shared executeTarget + * per-target loop, which only checked the provider circuit breaker + model + * lockout — never a per-(provider, connection) quota-exhaustion cutoff. A 0%- + * remaining connection stayed eligible as the lead leg until it reactively + * 429'd. + * + * Regression guard: with the quota-exhaustion opt-in enabled + * (`resilienceSettings.quotaPreflight.enabled = true`), a "priority" combo + * whose first-listed connection is at 0% remaining must skip straight to the + * sibling connection of the SAME provider — never dispatching to the + * exhausted connection. This must stay strictly per-connection: it must NOT + * touch the provider circuit breaker (both connections belong to the same + * provider, and the healthy one must remain fully eligible). + */ +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-quota-cutoff-priority-5923-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const dbCore = await import("../../src/lib/db/core.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { registerQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); +const { getCircuitBreaker } = await import("../../src/shared/utils/circuitBreaker.ts"); + +test.after(() => { + dbCore.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function makeLog() { + return { + info() {}, + warn() {}, + debug() {}, + error() {}, + }; +} + +function okResponse(model: string) { + return Response.json({ choices: [{ message: { role: "assistant", content: model } }] }); +} + +const PROVIDER = "openai"; +const EXHAUSTED_CONNECTION_ID = "conn-exhausted-5923"; +const HEALTHY_CONNECTION_ID = "conn-healthy-5923"; + +test("#5923 priority combo skips a 0%-remaining lead connection but keeps the sibling connection eligible", async () => { + registerQuotaFetcher(PROVIDER, async (connectionId: string) => { + if (connectionId === EXHAUSTED_CONNECTION_ID) { + return { used: 100, total: 100, percentUsed: 1 }; + } + return { used: 5, total: 100, percentUsed: 0.05 }; + }); + + const combo = { + name: `priority-quota-cutoff-5923-${Date.now()}`, + strategy: "priority", + models: [ + { + kind: "model", + provider: PROVIDER, + providerId: PROVIDER, + model: "gpt-4o-mini", + connectionId: EXHAUSTED_CONNECTION_ID, + id: "step-a", + }, + { + kind: "model", + provider: PROVIDER, + providerId: PROVIDER, + model: "gpt-4o-mini", + connectionId: HEALTHY_CONNECTION_ID, + id: "step-b", + }, + ], + }; + + const calls: Array = []; + const response = await handleComboChat({ + body: { model: combo.name, messages: [{ role: "user", content: "hi" }] }, + combo, + allCombos: [combo], + isModelAvailable: undefined, + relayOptions: undefined, + signal: undefined, + settings: { + resilienceSettings: { + quotaPreflight: { + enabled: true, + defaultThresholdPercent: 2, + warnThresholdPercent: 20, + }, + }, + }, + log: makeLog(), + handleSingleModel: async ( + _body: unknown, + modelStr: string, + target?: { connectionId?: string | null } + ) => { + calls.push(target?.connectionId ?? null); + return okResponse(modelStr); + }, + } as Parameters[0]); + + assert.equal(response.status, 200); + assert.ok(calls.length > 0, "expected at least one dispatched target"); + assert.equal( + calls[0], + HEALTHY_CONNECTION_ID, + "the 0%-remaining lead connection must be skipped; the sibling connection must be dispatched instead" + ); + assert.ok( + !calls.includes(EXHAUSTED_CONNECTION_ID), + "the exhausted connection must never be dispatched to" + ); + + // Strictly per-connection — the provider circuit breaker must stay CLOSED. + // Only one connection was skipped; the provider itself never failed. + assert.equal( + getCircuitBreaker(PROVIDER).getStatus().state, + "CLOSED", + "quota-exhaustion cutoff must never trip the whole-provider circuit breaker" + ); +}); + +test("#5923 priority combo does NOT skip a 0%-remaining connection when the cutoff setting is disabled (default)", async () => { + const provider = "openai"; + const exhaustedConnectionId = "conn-exhausted-disabled-5923"; + registerQuotaFetcher(provider, async () => ({ used: 100, total: 100, percentUsed: 1 })); + + const combo = { + name: `priority-quota-cutoff-disabled-5923-${Date.now()}`, + strategy: "priority", + models: [ + { + kind: "model", + provider, + providerId: provider, + model: "gpt-4o-mini", + connectionId: exhaustedConnectionId, + id: "step-a", + }, + ], + }; + + const calls: Array = []; + const response = await handleComboChat({ + body: { model: combo.name, messages: [{ role: "user", content: "hi" }] }, + combo, + allCombos: [combo], + isModelAvailable: undefined, + relayOptions: undefined, + signal: undefined, + // No resilienceSettings override → quotaPreflight.enabled defaults to false (opt-in). + settings: {}, + log: makeLog(), + handleSingleModel: async ( + _body: unknown, + modelStr: string, + target?: { connectionId?: string | null } + ) => { + calls.push(target?.connectionId ?? null); + return okResponse(modelStr); + }, + } as Parameters[0]); + + assert.equal(response.status, 200); + assert.deepEqual( + calls, + [exhaustedConnectionId], + "with the cutoff setting OFF (default), the exhausted connection must still be dispatched to (unchanged auto-off behavior)" + ); +}); diff --git a/tests/unit/quota-cache-is-exhausted-per-window-5923.test.ts b/tests/unit/quota-cache-is-exhausted-per-window-5923.test.ts new file mode 100644 index 0000000000..e1f1c0cb9b --- /dev/null +++ b/tests/unit/quota-cache-is-exhausted-per-window-5923.test.ts @@ -0,0 +1,59 @@ +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"; + +/** + * #5923 (Finding #5) — `is_exhausted` on `quota_snapshots` rows was written from + * the connection-wide aggregate (`entries.every(q => q.remainingPercentage <= 0)` + * in `isExhausted()`), not from the specific window being persisted. + * + * A connection with one window at 0% and another window at 50% never got its + * 0%-window row flagged `is_exhausted=1`, because the AND-across-all-windows + * aggregate was false (the 50% window kept it false). The reporter observed + * ~360 of 274k snapshot rows ever set `is_exhausted=1` in production. + * + * Regression guard: `setQuotaCache` must persist `is_exhausted` per-window + * (`remainingPercentage <= 0` for THAT window), independent of sibling windows + * on the same connection. + */ +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-per-window-5923-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#5923 setQuotaCache writes is_exhausted per-window, not the connection-wide AND aggregate", () => { + const connectionId = "conn-per-window-5923"; + + quotaCache.setQuotaCache(connectionId, "anthropic", { + session: { remainingPercentage: 0, resetAt: null }, + weekly: { remainingPercentage: 50, resetAt: null }, + }); + + const snapshots = quotaSnapshotsDb.getLatestQuotaSnapshotsForConnection(connectionId); + + const sessionRow = snapshots.find((s: any) => (s.windowKey ?? s.window_key) === "session"); + const weeklyRow = snapshots.find((s: any) => (s.windowKey ?? s.window_key) === "weekly"); + + assert.ok(sessionRow, "expected a persisted row for the session window"); + assert.ok(weeklyRow, "expected a persisted row for the weekly window"); + + assert.equal( + (sessionRow as any).isExhausted ?? (sessionRow as any).is_exhausted, + 1, + "the 0%-remaining session window must be flagged is_exhausted=1" + ); + assert.equal( + (weeklyRow as any).isExhausted ?? (weeklyRow as any).is_exhausted, + 0, + "the 50%-remaining weekly window must NOT be flagged is_exhausted (sibling window is exhausted, but this one isn't)" + ); +}); From 8d2df914f05d816895de11c7331d7057b919a8ad Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:34:11 -0300 Subject: [PATCH 19/21] fix(resilience): honor active codex session affinity over per-request reset-aware re-scoring (#5903) (#5943) Integrated into release/v3.8.44. --- src/sse/services/auth.ts | 113 +++----- src/sse/services/sessionAffinityPin.ts | 247 ++++++++++++++++++ ...-session-affinity-reset-aware-5903.test.ts | 181 +++++++++++++ 3 files changed, 458 insertions(+), 83 deletions(-) create mode 100644 src/sse/services/sessionAffinityPin.ts create mode 100644 tests/unit/codex-session-affinity-reset-aware-5903.test.ts diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 38148e3edd..e14b791a8c 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -6,10 +6,6 @@ import { updateProviderConnection, getSettings, getCachedSettings, - getSessionAccountAffinity, - upsertSessionAccountAffinity, - touchSessionAccountAffinity, - deleteSessionAccountAffinity, } from "@/lib/localDb"; import { DEFAULT_QUOTA_THRESHOLD_PERCENT, @@ -60,6 +56,12 @@ import { WEB_COOKIE_PROVIDERS, } from "@/shared/constants/providers"; import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; +import { + applySessionAffinityPin, + formatSessionKeyForLog, + resolveSessionAffinityTtlMs, + selectSessionAffinityConnection, +} from "./sessionAffinityPin"; import { isNoAuthProviderBlockedBySettings } from "./noAuthProviderSettings"; import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution"; import * as log from "../utils/logger"; @@ -304,10 +306,6 @@ export function extractSessionAffinityKey( return `input:sha256:${createHash("sha256").update(inputText.slice(0, 4096)).digest("hex")}`; } -function formatSessionKeyForLog(sessionKey: string): string { - return `${sessionKey.slice(0, 18)}...`; -} - function getCodexLimitPolicy(providerSpecificData: JsonRecord): { use5h: boolean; useWeekly: boolean; @@ -713,69 +711,6 @@ function compareP2CConnections( return a.id.localeCompare(b.id); } -function compareLruConnections(a: ProviderConnectionView, b: ProviderConnectionView): number { - if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999); - if (!a.lastUsedAt) return -1; - if (!b.lastUsedAt) return 1; - const recencyDelta = new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime(); - if (recencyDelta !== 0) return recencyDelta; - if ((a.consecutiveUseCount || 0) !== (b.consecutiveUseCount || 0)) { - return (a.consecutiveUseCount || 0) - (b.consecutiveUseCount || 0); - } - return (a.priority || 999) - (b.priority || 999); -} - -async function selectSessionAffinityConnection( - provider: string, - sessionKey: string | null | undefined, - connections: ProviderConnectionView[], - ttlMs = 0 -): Promise { - if (!sessionKey || connections.length === 0 || ttlMs <= 0) return null; - - const existing = getSessionAccountAffinity(sessionKey, provider, ttlMs); - if (existing) { - const connection = connections.find((candidate) => candidate.id === existing.connectionId); - if (connection) { - touchSessionAccountAffinity(sessionKey, provider, Date.now(), ttlMs); - await updateProviderConnection(connection.id, { - lastUsedAt: new Date().toISOString(), - consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1, - }); - log.info( - "AUTH", - `session_key=${formatSessionKeyForLog(sessionKey)} -> connection ${connection.id.slice( - 0, - 8 - )} (affinity)` - ); - return connection; - } - - deleteSessionAccountAffinity(sessionKey, provider); - log.info( - "AUTH", - `affinity cleared for session_key=${formatSessionKeyForLog(sessionKey)} provider=${provider}` - ); - } - - const connection = [...connections].sort(compareLruConnections)[0] ?? null; - if (!connection) return null; - - upsertSessionAccountAffinity(sessionKey, provider, connection.id, Date.now(), ttlMs); - await updateProviderConnection(connection.id, { - lastUsedAt: new Date().toISOString(), - consecutiveUseCount: 1, - }); - log.info( - "AUTH", - `new affinity created for session_key=${formatSessionKeyForLog( - sessionKey - )} -> connection ${connection.id.slice(0, 8)}` - ); - return connection; -} - /** * Sentinel connection id used for the synthetic credentials of no-auth / * keyless providers. It is NOT a real DB row, so it @@ -1083,7 +1018,7 @@ export async function getProviderCredentials( const allowRateLimitedConnections = allowSuppressedConnections || options.allowRateLimitedConnections === true; const bypassQuotaPolicy = options.bypassQuotaPolicy === true; - const forcedConnectionId = + let forcedConnectionId = typeof options.forcedConnectionId === "string" && options.forcedConnectionId.trim().length > 0 ? options.forcedConnectionId.trim() : null; @@ -1092,6 +1027,11 @@ export async function getProviderCredentials( options.excludeConnectionIds ); + // Fetched early so the session-affinity-pin override (#5903) can consult + // the TTL before forcedConnectionId narrows the connection pool. + const settings = await getSettings(); + const sessionAffinityTtlMs = resolveSessionAffinityTtlMs(provider, options, settings); + // Fix #922: Check for aliases (nvidia/nvidia_nim) to ensure credentials are found const providersToSearch = await getProviderSearchPool(provider); const connectionResults = await Promise.all( @@ -1106,6 +1046,24 @@ export async function getProviderCredentials( if (allowedConnections && allowedConnections.length > 0) { connections = connections.filter((conn) => allowedConnections.includes(conn.id)); } + + // #5903: an active session-affinity pin outranks a per-request reset-aware + // forcedConnectionId (see sessionAffinityPin leaf for the full rationale). + forcedConnectionId = + applySessionAffinityPin({ + forcedConnectionId, + options, + sessionAffinityTtlMs, + connections, + provider, + requestedModel, + excludedConnectionIds, + isTerminalConnectionStatus, + isCodexScopeUnavailable, + isQuotaPolicyBlocked: (c) => + evaluateQuotaLimitPolicy(provider, c as ProviderConnectionView, requestedModel).blocked, + }) ?? forcedConnectionId; + if (forcedConnectionId) { connections = connections.filter((conn) => conn.id === forcedConnectionId); } @@ -1483,18 +1441,7 @@ export async function getProviderCredentials( const orderedConnections = withQuota; - const settings = await getSettings(); const strategy = settings.fallbackStrategy || "fill-first"; - const sessionAffinityTtlMs = - provider === "codex" - ? Number.isFinite(Number(options.sessionAffinityTtlMs)) && - Number(options.sessionAffinityTtlMs) > 0 - ? Number(options.sessionAffinityTtlMs) - : Number.isFinite(Number(settings.codexSessionAffinityTtlMs)) && - Number(settings.codexSessionAffinityTtlMs) > 0 - ? Number(settings.codexSessionAffinityTtlMs) - : 0 - : 0; let connection; const affinityConnection = await selectSessionAffinityConnection( diff --git a/src/sse/services/sessionAffinityPin.ts b/src/sse/services/sessionAffinityPin.ts new file mode 100644 index 0000000000..f8f4749d48 --- /dev/null +++ b/src/sse/services/sessionAffinityPin.ts @@ -0,0 +1,247 @@ +/** + * #5903 — session-affinity-pin resolution + TTL, extracted from auth.ts as a + * pure leaf so the frozen god-file `auth.ts` does not grow. + * + * Problem: reset-aware (and other quota-scoring) combo strategies recompute a + * "winner" connection on every request and hand it to getProviderCredentials + * as `forcedConnectionId`. That id narrows the connection pool to exactly one + * connection BEFORE session affinity is consulted, so an existing pin pointing + * at a previously-selected account is never found and gets silently + * deleted/re-pinned to the fresh winner — breaking "same session -> reuse + * pinned account". + * + * Fix: when an active, non-expired affinity pin already exists for this + * (session, provider) AND the pinned connection is still eligible, the pin wins + * over the freshly recomputed `forcedConnectionId`. If the pin is ineligible + * (rate-limited / exhausted / model-locked / etc.) the caller keeps its forced + * connection, so the existing 429-driven `deleteSessionAccountAffinity` + * failover still owns rotating away from a pin that stops working. + * + * This module stays decoupled from auth.ts internals: the three predicates that + * live in (or would cause a cycle back into) auth.ts — + * `isTerminalConnectionStatus`, `isCodexScopeUnavailable`, and the quota-policy + * check wrapping `evaluateQuotaLimitPolicy` — are injected as callbacks. + */ + +import { + getSessionAccountAffinity, + upsertSessionAccountAffinity, + touchSessionAccountAffinity, + deleteSessionAccountAffinity, +} from "@/lib/db/sessionAccountAffinity"; +import { updateProviderConnection } from "@/lib/db/providers"; +import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; +import { isAccountQuotaExhausted } from "@/domain/quotaCache"; +import { + isAccountUnavailable, + isModelLocked, +} from "@omniroute/open-sse/services/accountFallback.ts"; +import * as log from "../utils/logger"; + +/** Minimal structural view of a provider connection this module reads. */ +export interface AffinityPinConnection { + id: string; + testStatus?: string | null; + rateLimitedUntil?: string | null; + providerSpecificData?: unknown; +} + +/** Fields the LRU tie-break / session-affinity selection reads. */ +export interface SessionAffinityConnection { + id: string; + lastUsedAt?: string | null; + consecutiveUseCount?: number | null; + priority?: number | null; +} + +export function formatSessionKeyForLog(sessionKey: string): string { + return `${sessionKey.slice(0, 18)}...`; +} + +function compareLruConnections(a: SessionAffinityConnection, b: SessionAffinityConnection): number { + if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999); + if (!a.lastUsedAt) return -1; + if (!b.lastUsedAt) return 1; + const recencyDelta = new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime(); + if (recencyDelta !== 0) return recencyDelta; + if ((a.consecutiveUseCount || 0) !== (b.consecutiveUseCount || 0)) { + return (a.consecutiveUseCount || 0) - (b.consecutiveUseCount || 0); + } + return (a.priority || 999) - (b.priority || 999); +} + +/** + * Session-affinity account selection (moved from auth.ts alongside the #5903 + * pin-override so all session-affinity logic lives in one leaf). Reuses an + * active pin when its connection is in the pool; otherwise picks the LRU + * connection and creates a fresh pin. Behavior byte-identical to the original. + */ +export async function selectSessionAffinityConnection( + provider: string, + sessionKey: string | null | undefined, + connections: T[], + ttlMs = 0 +): Promise { + if (!sessionKey || connections.length === 0 || ttlMs <= 0) return null; + + const existing = getSessionAccountAffinity(sessionKey, provider, ttlMs); + if (existing) { + const connection = connections.find((candidate) => candidate.id === existing.connectionId); + if (connection) { + touchSessionAccountAffinity(sessionKey, provider, Date.now(), ttlMs); + await updateProviderConnection(connection.id, { + lastUsedAt: new Date().toISOString(), + consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1, + }); + log.info( + "AUTH", + `session_key=${formatSessionKeyForLog(sessionKey)} -> connection ${connection.id.slice( + 0, + 8 + )} (affinity)` + ); + return connection; + } + + deleteSessionAccountAffinity(sessionKey, provider); + log.info( + "AUTH", + `affinity cleared for session_key=${formatSessionKeyForLog(sessionKey)} provider=${provider}` + ); + } + + const connection = [...connections].sort(compareLruConnections)[0] ?? null; + if (!connection) return null; + + upsertSessionAccountAffinity(sessionKey, provider, connection.id, Date.now(), ttlMs); + await updateProviderConnection(connection.id, { + lastUsedAt: new Date().toISOString(), + consecutiveUseCount: 1, + }); + log.info( + "AUTH", + `new affinity created for session_key=${formatSessionKeyForLog( + sessionKey + )} -> connection ${connection.id.slice(0, 8)}` + ); + return connection; +} + +/** Subset of credential-selection options the pin resolution consults. */ +export interface AffinityPinOptions { + sessionKey?: string | null; + allowSuppressedConnections?: boolean; + allowRateLimitedConnections?: boolean; + bypassQuotaPolicy?: boolean; + sessionAffinityTtlMs?: number | null; +} + +/** Settings subset needed to resolve the codex session-affinity TTL. */ +export interface AffinityPinSettings { + codexSessionAffinityTtlMs?: number | null; +} + +/** + * Resolve the effective session-affinity TTL. Only codex opts in today: an + * explicit per-request override wins, else the persisted codex setting, else 0 + * (disabled). Kept here so auth.ts can reuse it at both the pin-override site + * and the downstream `selectSessionAffinityConnection` site with one call. + */ +export function resolveSessionAffinityTtlMs( + provider: string, + options: AffinityPinOptions, + settings: AffinityPinSettings +): number { + if (provider !== "codex") return 0; + const override = Number(options.sessionAffinityTtlMs); + if (Number.isFinite(override) && override > 0) return override; + const configured = Number(settings.codexSessionAffinityTtlMs); + if (Number.isFinite(configured) && configured > 0) return configured; + return 0; +} + +/** + * Predicates supplied by the caller because they either live in auth.ts or + * would introduce a circular import if pulled in directly. + */ +export interface AffinityPinPredicates { + /** auth.ts::isTerminalConnectionStatus (banned/expired/credits_exhausted). */ + isTerminalConnectionStatus: (connection: AffinityPinConnection) => boolean; + /** auth.ts::isCodexScopeUnavailable (codex per-scope cooldown). */ + isCodexScopeUnavailable: ( + connection: AffinityPinConnection, + requestedModel: string | null + ) => boolean; + /** Wraps auth.ts::evaluateQuotaLimitPolicy(...).blocked for one connection. */ + isQuotaPolicyBlocked: (connection: AffinityPinConnection) => boolean; +} + +export interface ApplySessionAffinityPinParams extends AffinityPinPredicates { + forcedConnectionId: string | null; + options: AffinityPinOptions; + sessionAffinityTtlMs: number; + connections: AffinityPinConnection[]; + provider: string; + requestedModel: string | null; + excludedConnectionIds: Set; +} + +/** + * Mirrors the eligibility predicates applied later in getProviderCredentials + * (availableConnections filter + quota policy + quota exhaustion) but scoped to + * a single candidate connection. Pure/read-only. + */ +function isConnectionEligibleForAffinityPin( + connection: AffinityPinConnection, + params: ApplySessionAffinityPinParams +): boolean { + const { provider, requestedModel, options } = params; + const allowSuppressed = options.allowSuppressedConnections === true; + const allowRateLimited = allowSuppressed || options.allowRateLimitedConnections === true; + if (params.excludedConnectionIds.has(connection.id)) return false; + if ( + requestedModel && + isModelExcludedByConnection(requestedModel, connection.providerSpecificData) + ) { + return false; + } + if (!allowSuppressed) { + if (!allowRateLimited && isAccountUnavailable(connection.rateLimitedUntil)) return false; + if (params.isTerminalConnectionStatus(connection)) return false; + if (provider === "codex" && params.isCodexScopeUnavailable(connection, requestedModel)) { + return false; + } + if (requestedModel && isModelLocked(provider, connection.id, requestedModel)) return false; + } + if (isAccountQuotaExhausted(connection.id)) return false; + if (options.bypassQuotaPolicy !== true && params.isQuotaPolicyBlocked(connection)) return false; + return true; +} + +/** + * If an active, non-expired affinity pin exists for (sessionKey, provider) and + * the pinned connection is present-and-eligible in the current pool, returns + * that pinned connectionId (which should override `forcedConnectionId`) and + * logs the override. Returns null when the caller should keep its + * `forcedConnectionId` — no session, TTL disabled, no pin, pin already equals + * the forced id, pin absent from pool, or pin ineligible. + */ +export function applySessionAffinityPin(params: ApplySessionAffinityPinParams): string | null { + const { forcedConnectionId, options, sessionAffinityTtlMs, connections, provider } = params; + const sessionKey = options.sessionKey; + if (!forcedConnectionId || !sessionKey || sessionAffinityTtlMs <= 0) return null; + + const pinned = getSessionAccountAffinity(sessionKey, provider, sessionAffinityTtlMs); + if (!pinned || pinned.connectionId === forcedConnectionId) return null; + + const pinnedConnection = connections.find((conn) => conn.id === pinned.connectionId); + if (!pinnedConnection || !isConnectionEligibleForAffinityPin(pinnedConnection, params)) { + return null; + } + + log.info( + "AUTH", + `session affinity pin ${pinned.connectionId.slice(0, 8)}... overrides forcedConnectionId ${forcedConnectionId.slice(0, 8)}... (#5903)` + ); + return pinned.connectionId; +} diff --git a/tests/unit/codex-session-affinity-reset-aware-5903.test.ts b/tests/unit/codex-session-affinity-reset-aware-5903.test.ts new file mode 100644 index 0000000000..02f4ef6f1f --- /dev/null +++ b/tests/unit/codex-session-affinity-reset-aware-5903.test.ts @@ -0,0 +1,181 @@ +// #5903: Codex session affinity must win over a per-request reset-aware +// re-scoring. The reset-aware combo strategy (open-sse/services/combo/quotaStrategies.ts) +// recomputes its "winner" connection on every request and hands it to +// getProviderCredentials as forcedConnectionId (src/sse/handlers/chat.ts). +// Before the fix, forcedConnectionId narrowed the connection pool BEFORE +// session affinity was consulted, so a fresh quota-scoring winner silently +// evicted the existing pin (deleteSessionAccountAffinity) on every request — +// breaking "same session -> reuse pinned account". +// +// This test drives auth.getProviderCredentials directly (the same call shape +// chat.ts uses: sessionKey + forcedConnectionId together) to reproduce the +// bug without needing the full combo/quota-scoring machinery. + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-affinity-5903-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-affinity-5903-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const affinityDb = await import("../../src/lib/db/sessionAccountAffinity.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection(provider: string, overrides: any = {}) { + return providersDb.createProviderConnection({ + provider, + authType: overrides.authType || "oauth", + name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`, + accessToken: overrides.accessToken || `at-${Math.random().toString(16).slice(2, 10)}`, + refreshToken: overrides.refreshToken, + isActive: overrides.isActive ?? true, + testStatus: overrides.testStatus || "active", + priority: overrides.priority, + providerSpecificData: overrides.providerSpecificData || {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("codex session affinity wins over a per-request reset-aware forcedConnectionId (#5903)", async () => { + await settingsDb.updateSettings({ + fallbackStrategy: "reset-aware", + codexSessionAffinityTtlMs: 60_000, + }); + + const connectionA = await seedConnection("codex", { name: "codex-reset-aware-a" }); + const connectionB = await seedConnection("codex", { name: "codex-reset-aware-b" }); + + // Request 1: reset-aware quota scoring picks A as the winner for session S. + const request1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-S", + forcedConnectionId: connectionA.id, + }); + assert.equal(request1?.connectionId, connectionA.id, "request 1 should pin to the scored winner A"); + assert.equal( + affinityDb.getSessionAccountAffinity("session-S", "codex", 60_000)?.connectionId, + connectionA.id, + "affinity row must be created for session-S pointing at A" + ); + + // Request 2: quota state shifted and reset-aware now scores B higher for + // the SAME session. Without the fix, forcedConnectionId=B narrows the pool + // to just B before affinity is checked, evicting the A pin and re-pinning + // to B. With the fix, the existing active pin (A) must win. + const request2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-S", + forcedConnectionId: connectionB.id, + }); + assert.equal( + request2?.connectionId, + connectionA.id, + "request 2 must still use the pinned connection A, not the freshly re-scored B" + ); + assert.equal( + affinityDb.getSessionAccountAffinity("session-S", "codex", 60_000)?.connectionId, + connectionA.id, + "affinity row for session-S must remain pinned to A after re-scoring" + ); + + // A brand-new session (S2) has no existing pin, so the freshly re-scored + // winner (B) must be honored and a NEW pin created for S2. + const request3 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-S2", + forcedConnectionId: connectionB.id, + }); + assert.equal(request3?.connectionId, connectionB.id, "a new session must honor the fresh re-scored pick"); + assert.equal( + affinityDb.getSessionAccountAffinity("session-S2", "codex", 60_000)?.connectionId, + connectionB.id, + "a new affinity row for session-S2 must be created pointing at B" + ); + + // Session S must remain unaffected by S2's independent pin. + assert.equal( + affinityDb.getSessionAccountAffinity("session-S", "codex", 60_000)?.connectionId, + connectionA.id, + "session-S pin must stay isolated from session-S2" + ); +}); + +test("reset-aware forcedConnectionId is honored when the pinned connection becomes ineligible (#5903)", async () => { + await settingsDb.updateSettings({ + fallbackStrategy: "reset-aware", + codexSessionAffinityTtlMs: 60_000, + }); + + const connectionA = await seedConnection("codex", { name: "codex-reset-aware-ineligible-a" }); + const connectionB = await seedConnection("codex", { name: "codex-reset-aware-ineligible-b" }); + + const request1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-failover", + forcedConnectionId: connectionA.id, + }); + assert.equal(request1?.connectionId, connectionA.id); + + // A becomes rate-limited (e.g. 429 handled by markAccountUnavailable in + // production). Reset-aware re-scores and now forces B. The pin (A) is no + // longer eligible, so the freshly forced B must be used instead of + // failing the whole request. + await providersDb.updateProviderConnection(connectionA.id, { + rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), + }); + + const request2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-failover", + forcedConnectionId: connectionB.id, + }); + assert.equal( + request2?.connectionId, + connectionB.id, + "an ineligible pin must fall through to the freshly forced connection" + ); +}); + +test("no session affinity configured: reset-aware forcedConnectionId applies exactly as before (#5903)", async () => { + await settingsDb.updateSettings({ + fallbackStrategy: "reset-aware", + codexSessionAffinityTtlMs: 0, + }); + + const connectionA = await seedConnection("codex", { name: "codex-no-affinity-a" }); + const connectionB = await seedConnection("codex", { name: "codex-no-affinity-b" }); + + const request1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-no-ttl", + forcedConnectionId: connectionA.id, + }); + assert.equal(request1?.connectionId, connectionA.id); + + const request2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", { + sessionKey: "session-no-ttl", + forcedConnectionId: connectionB.id, + }); + assert.equal( + request2?.connectionId, + connectionB.id, + "with affinity disabled (ttl=0) each request must honor the fresh forcedConnectionId" + ); +}); From 2b0da37c193ad80877cf18d6ab92e16dec458bfc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:34:17 -0300 Subject: [PATCH 20/21] fix(thinking): only inject redacted_thinking replay block when tool_use present and thinking enabled (#5945) (#5953) Integrated into release/v3.8.44. --- .../translator/request/openai-to-claude.ts | 233 ++++++++++-------- ...nai-to-claude-redacted-replay-5312.test.ts | 145 +++++++++-- .../unit/translator-openai-to-claude.test.ts | 5 + 3 files changed, 262 insertions(+), 121 deletions(-) diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index d5e9c055ec..078d48e94a 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -165,6 +165,97 @@ export function openaiToClaudeRequest(model, body, stream) { result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } + // Thinking configuration + // NOTE: computed BEFORE message-block conversion (below) so that + // `getContentBlocksFromMessage` knows whether the outbound request actually has + // extended thinking enabled — required to correctly gate the `redacted_thinking` + // replay-placeholder injection (#5945). This block has no dependency on + // `result.messages`/`toolNameMap`, so moving it earlier is safe. + if (body.thinking) { + result.thinking = { + type: body.thinking.type || "enabled", + ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), + ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), + }; + } else if (body.reasoning_effort) { + // Convert OpenAI reasoning_effort to Claude thinking format (#627) + // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible + const requestedEffort = String(body.reasoning_effort).toLowerCase(); + const normalizedEffort = + requestedEffort === "max" && !supportsClaudeMaxEffort(model) + ? "high" + : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) + ? "high" + : requestedEffort; + if (isAdaptiveThinkingOnly(model)) { + // Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens` + // (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive + + // output_config.effort instead of the budget buckets below. Unrecognized levels + // leave thinking unset so the model keeps its adaptive default rather than 400ing + // on an invalid effort value. + if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) { + result.thinking = { + type: "adaptive", + }; + result.output_config = { + ...(result.output_config || {}), + effort: normalizedEffort, + }; + } + } else if (normalizedEffort === "max" || normalizedEffort === "xhigh") { + result.thinking = { + type: "adaptive", + }; + result.output_config = { + ...(result.output_config || {}), + effort: normalizedEffort, + }; + } else { + const effortBudgetMap: Record = { + low: 1024, + medium: 10240, + high: 131072, + max: 131072, + }; + const budget = effortBudgetMap[normalizedEffort]; + if (budget !== undefined && budget > 0) { + result.thinking = { + type: "enabled", + budget_tokens: budget, + }; + } + } + } + + // Fit thinking budget within the model's output cap and ensure + // max_tokens > budget_tokens for all thinking configurations (#627). + // Replaces the previous unconditional `budget + 8192` inflation, which + // could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger + // HTTP 400 from Anthropic. + const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking); + result.max_tokens = fitted.maxTokens; + if (fitted.thinking === undefined) { + delete result.thinking; + } else { + result.thinking = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body); + } + + delete result[COPILOT_REASONING_SUMMARY_MARKER]; + + // Final guard: Claude rejects `temperature` whenever extended thinking is + // enabled. If `result.thinking` was set above from `body.thinking` or + // `body.reasoning_effort` (manual budget or adaptive effort), drop temperature + // defensively. The model-name strip earlier already covers Claude OAuth's + // forced-thinking case (claude-opus-4.x / claude-sonnet-4.x). + if (result.thinking && result.temperature !== undefined) { + delete result.temperature; + } + + // Whether the OUTBOUND request actually has extended thinking enabled. Anthropic's + // schema only requires a precursor thinking/redacted_thinking block before a tool_use + // block when thinking mode is active for THIS request — never unconditionally (#5945). + const thinkingEnabledForRequest = Boolean(result.thinking) && result.thinking.type !== "disabled"; + // Messages const systemParts = []; @@ -199,7 +290,12 @@ export function openaiToClaudeRequest(model, body, stream) { for (const msg of nonSystemMessages) { const newRole = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; - const blocks = getContentBlocksFromMessage(msg, toolNameMap, disableToolPrefix); + const blocks = getContentBlocksFromMessage( + msg, + toolNameMap, + disableToolPrefix, + thinkingEnabledForRequest + ); const hasToolUse = blocks.some((b) => b.type === "tool_use"); const hasToolResult = blocks.some((b) => b.type === "tool_result"); @@ -387,87 +483,6 @@ export function openaiToClaudeRequest(model, body, stream) { : [{ type: "text", text: String(body.system) }]; } - // Thinking configuration - if (body.thinking) { - result.thinking = { - type: body.thinking.type || "enabled", - ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), - ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), - }; - } else if (body.reasoning_effort) { - // Convert OpenAI reasoning_effort to Claude thinking format (#627) - // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible - const requestedEffort = String(body.reasoning_effort).toLowerCase(); - const normalizedEffort = - requestedEffort === "max" && !supportsClaudeMaxEffort(model) - ? "high" - : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) - ? "high" - : requestedEffort; - if (isAdaptiveThinkingOnly(model)) { - // Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens` - // (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive + - // output_config.effort instead of the budget buckets below. Unrecognized levels - // leave thinking unset so the model keeps its adaptive default rather than 400ing - // on an invalid effort value. - if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) { - result.thinking = { - type: "adaptive", - }; - result.output_config = { - ...(result.output_config || {}), - effort: normalizedEffort, - }; - } - } else if (normalizedEffort === "max" || normalizedEffort === "xhigh") { - result.thinking = { - type: "adaptive", - }; - result.output_config = { - ...(result.output_config || {}), - effort: normalizedEffort, - }; - } else { - const effortBudgetMap: Record = { - low: 1024, - medium: 10240, - high: 131072, - max: 131072, - }; - const budget = effortBudgetMap[normalizedEffort]; - if (budget !== undefined && budget > 0) { - result.thinking = { - type: "enabled", - budget_tokens: budget, - }; - } - } - } - - // Fit thinking budget within the model's output cap and ensure - // max_tokens > budget_tokens for all thinking configurations (#627). - // Replaces the previous unconditional `budget + 8192` inflation, which - // could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger - // HTTP 400 from Anthropic. - const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking); - result.max_tokens = fitted.maxTokens; - if (fitted.thinking === undefined) { - delete result.thinking; - } else { - result.thinking = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body); - } - - delete result[COPILOT_REASONING_SUMMARY_MARKER]; - - // Final guard: Claude rejects `temperature` whenever extended thinking is - // enabled. If `result.thinking` was set above from `body.thinking` or - // `body.reasoning_effort` (manual budget or adaptive effort), drop temperature - // defensively. The model-name strip earlier already covers Claude OAuth's - // forced-thinking case (claude-opus-4.x / claude-sonnet-4.x). - if (result.thinking && result.temperature !== undefined) { - delete result.temperature; - } - // Attach toolNameMap to result for response translation if (toolNameMap.size > 0) { result._toolNameMap = toolNameMap; @@ -488,7 +503,12 @@ export function openaiToClaudeRequest(model, body, stream) { } // Get content blocks from single message -function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPrefix = false) { +function getContentBlocksFromMessage( + msg, + toolNameMap = new Map(), + disableToolPrefix = false, + thinkingEnabledForRequest = false +) { const blocks = []; if (msg.role === "tool") { @@ -555,22 +575,6 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr } } } else if (msg.role === "assistant") { - // Add reasoning_content as a replay placeholder (OpenAI extended thinking format). - // #5312 RC-D: reasoning_content carries NO real Claude signature. Emitting a - // `thinking` block with the fabricated DEFAULT signature makes Anthropic reject the - // replay with 400 "Invalid signature in thinking block" — and claudeHelper's - // latest-assistant guard (prepareClaudeRequest) preserves it verbatim, so the fake - // signature leaks upstream. Emit a signature-less redacted_thinking block instead - // (the same shape prepareClaudeRequest produces for Anthropic-native replay); - // Anthropic accepts it without signature validation and non-Anthropic Claude-shape - // upstreams re-hydrate the real text downstream from reasoningCache. - if (msg.reasoning_content) { - blocks.push({ - type: "redacted_thinking", - data: DEFAULT_THINKING_CLAUDE_SIGNATURE, - }); - } - if (Array.isArray(msg.content)) { for (const part of msg.content) { if (part.type === "text" && part.text) { @@ -619,6 +623,37 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr } } } + + // Add reasoning_content as a replay placeholder (OpenAI extended thinking format) — + // ONLY when Anthropic's schema actually requires a precursor thinking block: the + // outbound request has extended thinking enabled AND this assistant turn contains a + // tool_use block (Anthropic rejects a tool_use turn without a preceding + // thinking/redacted_thinking block when thinking is active). #5312 RC-D: + // reasoning_content carries NO real Claude signature. Emitting a `thinking` block + // with the fabricated DEFAULT signature makes Anthropic reject the replay with 400 + // "Invalid signature in thinking block" — and claudeHelper's latest-assistant guard + // (prepareClaudeRequest) preserves it verbatim, so the fake signature leaks + // upstream. Emit a signature-less redacted_thinking block instead (the same shape + // prepareClaudeRequest produces for Anthropic-native replay, gated the same way at + // claudeHelper.ts `thinkingEnabled && !hasThinking && hasToolUse`); Anthropic + // accepts it without signature validation and non-Anthropic Claude-shape upstreams + // re-hydrate the real text downstream from reasoningCache. + // #5945: injecting this unconditionally — for ANY assistant turn carrying + // reasoning_content, regardless of tool_use or thinking state — fabricates a content + // block the client never sent. Some upstream clients (reported: Claude Sonnet 5 via + // the "Pi" harness) detect the extra block and refuse the turn as prompt injection. + // Drop reasoning_content silently when it is not required by the schema, mirroring + // how other echo-only fields are dropped (see OPENAI_INCOMPATIBLE_ECHO_FIELDS). + const hasThinkingBlock = blocks.some( + (b) => b.type === "thinking" || b.type === "redacted_thinking" + ); + const hasToolUseBlock = blocks.some((b) => b.type === "tool_use"); + if (msg.reasoning_content && thinkingEnabledForRequest && hasToolUseBlock && !hasThinkingBlock) { + blocks.unshift({ + type: "redacted_thinking", + data: DEFAULT_THINKING_CLAUDE_SIGNATURE, + }); + } } return blocks; diff --git a/tests/unit/openai-to-claude-redacted-replay-5312.test.ts b/tests/unit/openai-to-claude-redacted-replay-5312.test.ts index 43764ae00c..42c434898e 100644 --- a/tests/unit/openai-to-claude-redacted-replay-5312.test.ts +++ b/tests/unit/openai-to-claude-redacted-replay-5312.test.ts @@ -1,14 +1,37 @@ /** - * TDD regression for #5312 (FIX D / RC-D): openai-to-claude reconstructed a Claude - * `thinking` block from signature-less `reasoning_content` and stamped it with the - * fabricated DEFAULT_THINKING_CLAUDE_SIGNATURE. Anthropic validates signatures and - * rejects the fake one with 400 "Invalid signature in thinking block" — and - * claudeHelper's latest-assistant guard preserves the block verbatim, so the fake - * signature leaks upstream. + * TDD regression for #5312 (FIX D / RC-D) and #5945. * - * Fix: emit a signature-less `redacted_thinking` placeholder (matching what - * prepareClaudeRequest produces downstream). A REAL part.signature must always be - * preserved verbatim — never overwritten with the default. + * #5312 (FIX D / RC-D): openai-to-claude reconstructed a Claude `thinking` block from + * signature-less `reasoning_content` and stamped it with the fabricated + * DEFAULT_THINKING_CLAUDE_SIGNATURE. Anthropic validates signatures and rejects the + * fake one with 400 "Invalid signature in thinking block" — and claudeHelper's + * latest-assistant guard preserves the block verbatim, so the fake signature leaks + * upstream. + * + * Fix (#5312): when a precursor thinking block IS required by Anthropic's schema + * (assistant turn has tool_use AND the outbound request has extended thinking + * enabled), emit a signature-less `redacted_thinking` placeholder (matching what + * prepareClaudeRequest produces downstream) instead of a fabricated-signature + * `thinking` block. A REAL part.signature must always be preserved verbatim — never + * overwritten with the default. + * + * #5945 (over-correction of #5312): the original #5312 fix injected the + * redacted_thinking placeholder UNCONDITIONALLY whenever ANY assistant history + * message carried non-empty `reasoning_content` — regardless of whether the current + * outbound request has thinking enabled, and regardless of whether that assistant + * turn even contains a `tool_use` block (the only case Anthropic's schema actually + * requires a preceding thinking/redacted_thinking block). This fabricated a content + * block the client never sent; reported by dev-cj: Claude Sonnet 5 via the "Pi" + * harness detected the extra block and refused the turn as prompt injection. + * + * Fix (#5945): gate the injection on BOTH (a) the assistant turn containing a + * tool_use block and (b) the outbound request having extended thinking enabled. + * Otherwise `reasoning_content` is dropped silently — it carries no useful signal + * for a plain-text replay turn, and the client never asked for it to appear. + * + * These two fixes are not in tension: #5312 legitimately fixed a real Anthropic 400 + * for the case the redacted_thinking block IS required; #5945 narrows the trigger to + * exactly that case instead of firing for every reasoning_content-bearing message. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -20,7 +43,7 @@ const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import( "../../open-sse/config/defaultThinkingSignature.ts" ); -test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signature thinking block", () => { +test("#5945: reasoning_content on a plain-text assistant turn (no tool_use, thinking not requested) yields NO redacted_thinking/thinking block", () => { const result = openaiToClaudeRequest( "claude-opus-4-8", { @@ -28,6 +51,83 @@ test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signatur { role: "user", content: "hello" }, { role: "assistant", reasoning_content: "thinking about it", content: "hi there" }, ], + // no body.thinking / body.reasoning_effort — thinking is NOT enabled for this request. + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + + // No fabricated block at all — reasoning_content must be dropped silently, exactly + // like OPENAI_INCOMPATIBLE_ECHO_FIELDS drops other echo-only fields. + assert.equal( + assistant.content.find((b) => b && (b.type === "thinking" || b.type === "redacted_thinking")), + undefined, + "must NOT fabricate a thinking/redacted_thinking block the client never sent" + ); + assert.deepEqual( + assistant.content.map((b) => b.type), + ["text"], + "assistant content should contain only the real text block" + ); +}); + +test("#5945: reasoning_content + tool_use, but thinking NOT enabled on the outbound request, yields NO injection", () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + reasoning_content: "thinking about it", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: "{}" }, + }, + ], + }, + ], + // no body.thinking / body.reasoning_effort — thinking is NOT enabled. + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + assert.equal( + assistant.content.find((b) => b && (b.type === "thinking" || b.type === "redacted_thinking")), + undefined, + "must NOT inject a precursor thinking block when the request itself has thinking disabled" + ); + assert.ok( + assistant.content.some((b) => b.type === "tool_use"), + "tool_use block must still be present" + ); +}); + +test("#5312: reasoning_content + tool_use + thinking ENABLED still gets a signature-less redacted_thinking precursor (the legitimate #5312 400-fix case)", () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + reasoning_content: "thinking about it", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: "{}" }, + }, + ], + }, + ], }, false ); @@ -35,24 +135,25 @@ test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signatur const assistant = result.messages.find((m) => m.role === "assistant"); assert.ok(assistant, "expected assistant message"); - // No block may carry the fabricated default signature. + // No block may carry the fabricated default signature on a `thinking`-typed block. const fake = assistant.content.find( - (b) => b && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE + (b) => b && b.type === "thinking" && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE ); - assert.equal(fake, undefined, "must NOT emit a thinking block with the fabricated signature"); + assert.equal(fake, undefined, "must NOT emit a `thinking` block with the fabricated signature"); - // No `thinking`-typed block at all from signature-less reasoning_content. + // It becomes a redacted_thinking placeholder (Anthropic accepts without sig check), + // and it must precede the tool_use block. + assert.equal(assistant.content[0].type, "redacted_thinking", "must be the precursor block"); + assert.equal(assistant.content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); assert.equal( - assistant.content.find((b) => b && b.type === "thinking"), + assistant.content[0].signature, undefined, - "signature-less reasoning_content must not produce a `thinking` block" + "redacted_thinking must not carry a signature" + ); + assert.ok( + assistant.content.some((b) => b.type === "tool_use"), + "tool_use block must still be present" ); - - // It becomes a redacted_thinking placeholder (Anthropic accepts without sig check). - const redacted = assistant.content.find((b) => b && b.type === "redacted_thinking"); - assert.ok(redacted, "expected a redacted_thinking placeholder"); - assert.equal(redacted.data, DEFAULT_THINKING_CLAUDE_SIGNATURE); - assert.equal(redacted.signature, undefined, "redacted_thinking must not carry a signature"); }); test("#5312 RC-D: a REAL thinking signature is preserved verbatim", () => { diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 5754707216..70c3f1a4f2 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -114,6 +114,11 @@ test("OpenAI -> Claude converts multimodal content, tool declarations, tool call const result = openaiToClaudeRequest( "claude-4-sonnet", { + // #5945: the redacted_thinking precursor is only emitted when the outbound + // request actually has extended thinking enabled (Anthropic's schema + // requirement). Set it explicitly so this test keeps exercising that + // legitimate #5312 case alongside the multimodal/tool assertions below. + thinking: { type: "enabled", budget_tokens: 4096 }, messages: [ { role: "user", From 26fd7b6a8a504520cde3519b341f304673d10251 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:36:16 -0300 Subject: [PATCH 21/21] feat(providers): add ClinePass API-key provider (#5942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — ClinePass API-key (BYOK) provider (port upstream 9router#2304, co-authored @adentdk). Validated locally: 16 clinepass tests green; fixed the APIKEY count 158→159 + translate-path golden snapshot (clinepass is a genuine new provider). Remaining UNSTABLE red is the pre-existing environmental setup-claude base-red (opencode-plugin dist not built in fast-path). Supersedes stub #5541. --- CHANGELOG.md | 4 +- open-sse/config/providers/index.ts | 2 + .../providers/registry/clinepass/index.ts | 42 +++++++ open-sse/executors/default.ts | 45 +++++++ open-sse/handlers/chatCore.ts | 58 +++++++++ open-sse/services/clinepassModels.ts | 76 ++++++++++++ open-sse/utils/clinepassEnvelope.ts | 59 +++++++++ open-sse/utils/error.ts | 13 +- .../models/discovery/providerModelsConfig.ts | 11 ++ .../constants/providers/apikey/gateways.ts | 14 +++ tests/snapshots/provider/translate-path.json | 29 +++++ tests/unit/clinepass-provider.test.ts | 117 ++++++++++++++++++ tests/unit/clinepass-thinking-budget.test.ts | 77 ++++++++++++ tests/unit/providers-constants-split.test.ts | 10 +- 14 files changed, 548 insertions(+), 9 deletions(-) create mode 100644 open-sse/config/providers/registry/clinepass/index.ts create mode 100644 open-sse/services/clinepassModels.ts create mode 100644 open-sse/utils/clinepassEnvelope.ts create mode 100644 tests/unit/clinepass-provider.test.ts create mode 100644 tests/unit/clinepass-thinking-budget.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b08d31be40..293f2887fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ ### ✨ New Features -_TBD_ +- **feat(providers):** add ClinePass as a first-class API-key provider (Cline's BYOK gateway). (thanks @adentdk) ### 🔧 Bug Fixes -- **fix(translator):** antigravity→openai request now emits Anthropic-compliant content blocks — drops empty text blocks and preserves tool calls/text co-located with tool results. (thanks @SahrulRamadhanHardiansyah) +_TBD_ ### 📝 Maintenance diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ac9847e86d..9913c7ea1c 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -49,6 +49,7 @@ import { groqProvider } from "./registry/groq/index.ts"; import { inference_netProvider } from "./registry/inference-net/index.ts"; import { llm7Provider } from "./registry/llm7/index.ts"; import { cerebrasProvider } from "./registry/cerebras/index.ts"; +import { clinepassProvider } from "./registry/clinepass/index.ts"; import { sparkdeskProvider } from "./registry/sparkdesk/index.ts"; import { nlpcloudProvider } from "./registry/nlpcloud/index.ts"; import { nvidiaProvider } from "./registry/nvidia/index.ts"; @@ -220,6 +221,7 @@ export const REGISTRY: Record = { "inference-net": inference_netProvider, llm7: llm7Provider, cerebras: cerebrasProvider, + clinepass: clinepassProvider, sparkdesk: sparkdeskProvider, nlpcloud: nlpcloudProvider, nvidia: nvidiaProvider, diff --git a/open-sse/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts new file mode 100644 index 0000000000..b91a4b75d5 --- /dev/null +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -0,0 +1,42 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// ClinePass — Cline's $9.99/mo BYOK API-key gateway (https://cline.bot). Distinct +// from the OAuth `cline` provider: same host (api.cline.bot) but a plain Bearer +// API key and the `cline-pass/*` model namespace. Responses are wrapped in a +// {success, data} envelope — unwrapped by open-sse/utils/clinepassEnvelope.ts. +export const clinepassProvider: RegistryEntry = { + id: "clinepass", + alias: "clinepass", + format: "openai", + executor: "default", + baseUrl: "https://api.cline.bot/api/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + extraHeaders: { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }, + models: [ + { id: "cline-pass/glm-5.2", name: "GLM-5.2 (ClinePass)" }, + { id: "cline-pass/kimi-k2.7-code", name: "Kimi K2.7 Code (ClinePass)" }, + { id: "cline-pass/kimi-k2.6", name: "Kimi K2.6 (ClinePass)" }, + { + id: "cline-pass/deepseek-v4-pro", + name: "DeepSeek V4 Pro (ClinePass)", + supportsReasoning: true, + maxOutputTokens: 50000, + }, + { + id: "cline-pass/deepseek-v4-flash", + name: "DeepSeek V4 Flash (ClinePass)", + supportsReasoning: true, + maxOutputTokens: 50000, + }, + { id: "cline-pass/mimo-v2.5", name: "MiMo-V2.5 (ClinePass)" }, + { id: "cline-pass/mimo-v2.5-pro", name: "MiMo-V2.5-Pro (ClinePass)" }, + { id: "cline-pass/minimax-m3", name: "MiniMax M3 (ClinePass)" }, + { id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max (ClinePass)" }, + { id: "cline-pass/qwen3.7-plus", name: "Qwen3.7 Plus (ClinePass)" }, + ], + passthroughModels: true, +}; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index cbdb8d90bf..0d52630666 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -737,9 +737,54 @@ export class DefaultExecutor extends BaseExecutor { } } + // ClinePass reasoning models burn all of max_tokens on the thinking phase + // when the budget is too small, leaving content empty (finish_reason: + // "length"). Bump max_tokens to a safe floor when reasoning is enabled and + // the budget is undersized. CLINEPASS-GATED — no-op for every other provider. + if (typeof withDefaults === "object" && withDefaults !== null) { + this.ensureThinkingBudget(withDefaults as Record, model); + } + return withDefaults; } + // ClinePass / OpenRouter-style thinking models leave content empty when the + // reasoning budget consumes all of max_tokens. Bump max_tokens to a safe + // minimum only when reasoning is enabled and the budget is undersized. + // CLINEPASS-GATED: returns early for every other provider. + ensureThinkingBudget(body: Record, model: string): Record { + if (!body || this.provider !== "clinepass") return body; + + const outboundModel = typeof body.model === "string" ? body.model : model; + const entry = getRegistryEntry(this.provider); + const modelEntry = entry?.models?.find((m) => m.id === outboundModel); + if (!modelEntry?.supportsReasoning) return body; + + const extraBody = body.extra_body as Record | undefined; + const thinking = extraBody?.thinking as Record | undefined; + const effort = body.reasoning_effort; + const reasoningEnabled = + thinking?.type === "enabled" || + (typeof effort === "string" && effort !== "none" && effort !== "off") || + effort === true; + if (!reasoningEnabled) return body; + + const MIN_TOKENS = 4096; + const maxOutput = + typeof modelEntry.maxOutputTokens === "number" && modelEntry.maxOutputTokens > 0 + ? modelEntry.maxOutputTokens + : MIN_TOKENS; + const target = Math.min(MIN_TOKENS, maxOutput); + const current = body.max_tokens ?? body.max_completion_tokens; + + if (typeof current !== "number" || current <= 0) { + body.max_tokens = target; + } else if (current < MIN_TOKENS && current < maxOutput) { + body.max_tokens = MIN_TOKENS; + } + return body; + } + /** * Refresh credentials via the centralized tokenRefresh service. * Delegates to getAccessToken() which handles all providers with diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4535402de7..23958514e7 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -212,6 +212,7 @@ import { type NonStreamingSseTerminalState, } from "./chatCore/nonStreamingSse.ts"; import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts"; +import { unwrapClinepassEnvelope } from "../utils/clinepassEnvelope.ts"; import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts"; import { createBodyTimeoutError, @@ -3517,6 +3518,63 @@ export async function handleChatCore({ let responseBody = parsed.responseBody; let responsePayloadFormat = parsed.responsePayloadFormat; + // ── ClinePass {success,data} envelope unwrap (before translation) ────────── + // ClinePass wraps non-streaming JSON in a {success, data} envelope; errors + // use {success:false, error}. Transient {success:false, error:"empty..."} + // responses get one 2s retry before surfacing. CLINEPASS-GATED — untouched + // for every other provider. Envelope errors route through createErrorResult + // (→ buildErrorBody/sanitizeErrorMessage, Rule #12). + if (provider === "clinepass") { + let { body: unwrapped, error: envError } = unwrapClinepassEnvelope(responseBody, provider); + if (envError && /empty/i.test(envError.message || "")) { + log?.warn?.("RETRY", "clinepass returned empty content, retrying once after 2s"); + await new Promise((r) => setTimeout(r, 2000)); + try { + const retryResult = await executeProviderRequest(effectiveModel, false); + if (retryResult?.response?.ok) { + const retryParsed = await parseNonStreamingResponseBody({ + providerResponse: retryResult.response, + upstreamStream: undefined, + providerHeaders: retryResult.headers, + finalBody: retryResult.transformedBody, + targetFormat, + model, + log, + }); + if (retryParsed.kind !== "invalid_sse" && retryParsed.kind !== "invalid_json") { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerHeaders = retryResult.headers; + finalBody = providerRequestCapture.body(retryResult.transformedBody); + ({ body: unwrapped, error: envError } = unwrapClinepassEnvelope( + retryParsed.responseBody, + provider + )); + } + } + } catch (retryErr) { + log?.warn?.( + "RETRY", + `clinepass retry failed: ${ + retryErr instanceof Error ? retryErr.message : String(retryErr) + }` + ); + } + } + if (envError) { + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, + }).catch(() => {}); + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "clinepass_envelope_error"); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, envError.message); + } + responseBody = unwrapped; + } + // Check for empty content response (fake success) - trigger fallback if (isEmptyContentResponse(responseBody)) { appendRequestLog({ diff --git a/open-sse/services/clinepassModels.ts b/open-sse/services/clinepassModels.ts new file mode 100644 index 0000000000..ee25f6e8e5 --- /dev/null +++ b/open-sse/services/clinepassModels.ts @@ -0,0 +1,76 @@ +import { buildClineHeaders } from "@/shared/utils/clineAuth"; + +// ClinePass live-models resolver. ClinePass is API-key-only (BYOK), but the +// underlying api.cline.bot host also accepts the OAuth `cline` credential shape, +// so the resolver reuses buildClineHeaders() (the shared workos:-prefixed Cline +// header set) for the non-apikey path. Only `cline-pass/*` model ids are kept. + +const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models"; +const FETCH_TIMEOUT_MS = 5000; + +export interface ClinepassModel { + id: string; + name: string; +} + +/** + * Filter a raw models list down to the ClinePass namespace (`cline-pass/*`). + * Pure — shared by the live resolver and the discovery-config parseResponse. + */ +export function filterClinepassModels(rawList: unknown): ClinepassModel[] { + if (!Array.isArray(rawList)) return []; + return rawList + .filter( + (m): m is { id: string; name?: string } => + !!m && + typeof (m as { id?: unknown }).id === "string" && + (m as { id: string }).id.startsWith("cline-pass/") + ) + .map((m) => ({ id: m.id, name: m.name || m.id })); +} + +function buildModelListHeaders(token: string, isApiKey: boolean): Record { + if (isApiKey) { + return { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }; + } + return buildClineHeaders(token, { Accept: "application/json" }); +} + +/** + * Resolve the live ClinePass model catalogue for a connection. Returns + * `{ models }` on success or `null` on any failure (missing token, non-2xx, + * bad shape, timeout) so callers fall back to the static registry catalogue. + */ +export async function resolveClinepassModels(credentials: { + apiKey?: string | null; + accessToken?: string | null; +}): Promise<{ models: ClinepassModel[] } | null> { + const isApiKey = Boolean(credentials?.apiKey); + const token = isApiKey ? credentials.apiKey : credentials?.accessToken; + if (!token) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const headers = buildModelListHeaders(token, isApiKey); + const response = await fetch(CLINEPASS_MODELS_ENDPOINT, { + method: "GET", + headers, + signal: controller.signal, + }); + if (!response.ok) return null; + + const json = await response.json(); + const rawList = Array.isArray(json) ? json : json?.data; + const models = filterClinepassModels(rawList); + return models.length ? { models } : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/open-sse/utils/clinepassEnvelope.ts b/open-sse/utils/clinepassEnvelope.ts new file mode 100644 index 0000000000..925bb4efeb --- /dev/null +++ b/open-sse/utils/clinepassEnvelope.ts @@ -0,0 +1,59 @@ +// ClinePass upstream wraps non-streaming JSON responses in a {success, data} +// envelope (errors use {success: false, error}). Detect and unwrap; pass the +// payload through untouched for every other provider / shape. + +export interface ClinepassEnvelopeError { + message: string; + status: number | null; +} + +export interface ClinepassEnvelopeResult { + body: unknown; + error: ClinepassEnvelopeError | null; +} + +/** + * Unwrap a ClinePass {success, data} envelope. + * + * - Non-clinepass provider, non-object, array, or object without a `success` + * key → pass through untouched ({ body, error: null }). + * - { success: false, ... } → { body: null, error: { message, status } } with + * the upstream error string extracted (never a local stack — the caller must + * still route it through sanitizeErrorMessage before emitting a response). + * - { success: true, data: {...} } → unwrap to `data`. + */ +export function unwrapClinepassEnvelope( + body: unknown, + provider: string | null | undefined +): ClinepassEnvelopeResult { + if (provider !== "clinepass") return { body, error: null }; + if (!body || typeof body !== "object" || Array.isArray(body)) return { body, error: null }; + + const record = body as Record; + if (!("success" in record)) return { body, error: null }; + + if (record.success === false) { + const rawError = record.error; + const message = + typeof rawError === "string" + ? rawError + : (rawError && typeof rawError === "object" + ? ((rawError as Record).message as string | undefined) + : undefined) || + (typeof record.message === "string" ? record.message : undefined) || + "Upstream error"; + const statusCode = typeof record.statusCode === "number" ? record.statusCode : null; + return { body: null, error: { message, status: statusCode } }; + } + + if ( + record.success === true && + "data" in record && + record.data !== null && + typeof record.data === "object" + ) { + return { body: record.data, error: null }; + } + + return { body, error: null }; +} diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 17bbef99d4..9afc237715 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,4 +1,5 @@ import { CORS_HEADERS } from "./cors.ts"; +import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts"; import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; @@ -230,7 +231,14 @@ export async function parseUpstreamError(response: Response, provider: string | const parsed = JSON.parse(text); // Handle array responses (e.g., from some Gemini APIs) const json = (Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : parsed) || {}; - message = json.error?.message || json.message || json.error || text; + // ClinePass wraps upstream errors in a {success:false, error} envelope. + // Extract the upstream error string (an upstream JSON field, not a local + // stack) — still routed through sanitizeErrorMessage/buildErrorBody by + // every consumer below (Rule #12). + const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider); + message = clinepassEnvError + ? clinepassEnvError.message + : json.error?.message || json.message || json.error || text; errorCode = json.error?.code || json.code; errorType = json.error?.type || json.type; } catch { @@ -497,7 +505,8 @@ export function formatProviderError( const message = error.message || "Unknown error"; // Expose low-level cause (e.g. UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT) for diagnosing fetch failures const cause = (error as { cause?: unknown }).cause; - const causeObj = cause && typeof cause === "object" ? (cause as Record) : undefined; + const causeObj = + cause && typeof cause === "object" ? (cause as Record) : undefined; const causeCode = typeof causeObj?.code === "string" ? causeObj.code : undefined; const causeMsg = typeof causeObj?.message === "string" ? causeObj.message : undefined; const causeStr = diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index ab13717427..4cffe5772b 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -1,6 +1,7 @@ import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts"; import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; +import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts"; import { normalizeOpenAiLikeModelsResponse } from "./normalizers"; export type ProviderModelsConfigEntry = { @@ -246,6 +247,16 @@ export const PROVIDER_MODELS_CONFIG: Record = authPrefix: "Bearer ", parseResponse: (data) => data.data || data.models || [], }, + // ClinePass (BYOK apikey gateway) — same host as OAuth `cline`, but only the + // `cline-pass/*` namespace is surfaced (filterClinepassModels). + clinepass: { + url: "https://api.cline.bot/api/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => filterClinepassModels(Array.isArray(data) ? data : data?.data), + }, cohere: { url: "https://api.cohere.com/v2/models", method: "GET", diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 0fca742cb5..540805bf54 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -28,6 +28,20 @@ export const APIKEY_PROVIDERS_GATEWAYS = { "Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint.", apiHint: "Create or copy an API key from Command Code, then paste it here as a Bearer token.", }, + clinepass: { + id: "clinepass", + alias: "clinepass", + name: "ClinePass", + icon: "vpn_key", + color: "#5B9BD5", + textIcon: "CP", + passthroughModels: true, + website: "https://cline.bot", + notice: { + text: "ClinePass is Cline's paid BYOK gateway ($9.99/mo). Bring your own Cline API key; requests hit api.cline.bot with the cline-pass/* model namespace.", + apiKeyUrl: "https://app.cline.bot/settings/api-keys", + }, + }, openrouter: { id: "openrouter", alias: "openrouter", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 3dfce0ca56..f64cee4d49 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -772,6 +772,35 @@ "stream": "https://api.cline.bot/api/v1/chat/completions" } }, + "clinepass": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + } + }, + "url": { + "nonStream": "https://api.cline.bot/api/v1/chat/completions", + "stream": "https://api.cline.bot/api/v1/chat/completions" + } + }, "cloudflare-ai": { "format": "openai", "headers": { diff --git a/tests/unit/clinepass-provider.test.ts b/tests/unit/clinepass-provider.test.ts new file mode 100644 index 0000000000..1706e1cc56 --- /dev/null +++ b/tests/unit/clinepass-provider.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); +const { unwrapClinepassEnvelope } = await import("../../open-sse/utils/clinepassEnvelope.ts"); +const { filterClinepassModels } = await import("../../open-sse/services/clinepassModels.ts"); +const { parseUpstreamError, buildErrorBody } = await import("../../open-sse/utils/error.ts"); + +// ── Provider metadata (Zod-validated APIKEY catalog) ───────────────────────── +test("ClinePass is registered as an API-key provider with the canonical identity", () => { + const cp = APIKEY_PROVIDERS.clinepass; + assert.ok(cp, "APIKEY_PROVIDERS.clinepass must be defined"); + assert.equal(cp.id, "clinepass"); + assert.equal(cp.alias, "clinepass"); + assert.equal(cp.name, "ClinePass"); + assert.equal(cp.website, "https://cline.bot"); + assert.equal( + (cp as { notice?: { apiKeyUrl?: string } }).notice?.apiKeyUrl, + "https://app.cline.bot/settings/api-keys" + ); +}); + +test("ClinePass registry entry uses OpenAI format with bearer apikey auth + Cline headers", () => { + const entry = providerRegistry.clinepass; + assert.ok(entry, "providerRegistry.clinepass must be defined"); + assert.equal(entry.id, "clinepass"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, "https://api.cline.bot/api/v1/chat/completions"); + assert.equal(entry.extraHeaders?.["HTTP-Referer"], "https://cline.bot"); + assert.equal(entry.extraHeaders?.["X-Title"], "Cline"); +}); + +test("ClinePass models are cline-pass/* and deepseek entries flag reasoning", () => { + const models = providerRegistry.clinepass.models; + const ids = models.map((m: { id: string }) => m.id); + assert.ok(ids.length >= 8, "expect a non-trivial seed list"); + assert.equal(new Set(ids).size, ids.length, "model ids must be unique"); + for (const id of ids) { + assert.ok(id.startsWith("cline-pass/"), `${id} must be in the cline-pass/ namespace`); + } + const deepseek = models.filter((m: { id: string }) => m.id.includes("deepseek")); + assert.ok(deepseek.length >= 2, "expect the two DeepSeek V4 entries"); + for (const m of deepseek) { + assert.equal((m as { supportsReasoning?: boolean }).supportsReasoning, true); + } +}); + +// ── Envelope unwrap ────────────────────────────────────────────────────────── +test("unwrapClinepassEnvelope: success unwraps to data", () => { + const inner = { id: "chatcmpl-1", choices: [] }; + const { body, error } = unwrapClinepassEnvelope({ success: true, data: inner }, "clinepass"); + assert.equal(error, null); + assert.deepEqual(body, inner); +}); + +test("unwrapClinepassEnvelope: {success:false} yields an error", () => { + const { body, error } = unwrapClinepassEnvelope( + { success: false, error: "empty response content", statusCode: 502 }, + "clinepass" + ); + assert.equal(body, null); + assert.ok(error); + assert.equal(error?.message, "empty response content"); + assert.equal(error?.status, 502); +}); + +test("unwrapClinepassEnvelope: nested error.message extracted", () => { + const { error } = unwrapClinepassEnvelope( + { success: false, error: { message: "quota exceeded" } }, + "clinepass" + ); + assert.equal(error?.message, "quota exceeded"); +}); + +test("unwrapClinepassEnvelope: non-clinepass provider passes through untouched", () => { + const payload = { success: false, error: "boom" }; + const { body, error } = unwrapClinepassEnvelope(payload, "openai"); + assert.equal(error, null); + assert.deepEqual(body, payload); +}); + +test("unwrapClinepassEnvelope: non-object / array / no-success passthrough", () => { + assert.deepEqual(unwrapClinepassEnvelope("plain", "clinepass"), { body: "plain", error: null }); + assert.deepEqual(unwrapClinepassEnvelope([1, 2], "clinepass"), { body: [1, 2], error: null }); + const bare = { id: "x" }; + assert.deepEqual(unwrapClinepassEnvelope(bare, "clinepass"), { body: bare, error: null }); +}); + +// ── Model filter ───────────────────────────────────────────────────────────── +test("filterClinepassModels keeps only cline-pass/* ids", () => { + const out = filterClinepassModels([ + { id: "cline-pass/glm-5.2", name: "GLM" }, + { id: "openai/gpt-5.5" }, + { id: "cline-pass/deepseek-v4-pro" }, + { notId: true }, + ]); + assert.deepEqual(out, [ + { id: "cline-pass/glm-5.2", name: "GLM" }, + { id: "cline-pass/deepseek-v4-pro", name: "cline-pass/deepseek-v4-pro" }, + ]); + assert.deepEqual(filterClinepassModels("not-array"), []); +}); + +// ── Error sanitization (Rule #12 — no stack leak) ──────────────────────────── +test("parseUpstreamError unwraps clinepass envelope error without leaking a stack", async () => { + const upstream = new Response( + JSON.stringify({ success: false, error: "upstream at /srv/x.js:1:1 failed" }), + { status: 502, headers: { "content-type": "application/json" } } + ); + const parsed = await parseUpstreamError(upstream, "clinepass"); + const body = buildErrorBody(502, parsed.message) as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "sanitized error must not include a stack frame"); +}); diff --git a/tests/unit/clinepass-thinking-budget.test.ts b/tests/unit/clinepass-thinking-budget.test.ts new file mode 100644 index 0000000000..db49bac583 --- /dev/null +++ b/tests/unit/clinepass-thinking-budget.test.ts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +// DefaultExecutor.ensureThinkingBudget — clinepass-gated max_tokens floor for +// reasoning models (prevents empty content when the budget is undersized). + +test("bumps undersized max_tokens to 4096 for a clinepass reasoning model", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-pro", + reasoning_effort: "high", + max_tokens: 512, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 4096); +}); + +test("sets max_tokens floor when absent for a reasoning model", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-flash", + reasoning_effort: "medium", + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-flash"); + assert.equal(body.max_tokens, 4096); +}); + +test("leaves an already-sufficient budget untouched", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-pro", + reasoning_effort: "high", + max_tokens: 8000, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 8000); +}); + +test("no-op when reasoning is disabled", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/deepseek-v4-pro", + max_tokens: 100, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 100); +}); + +test("no-op for a non-reasoning clinepass model", () => { + const executor = new DefaultExecutor("clinepass"); + const body = { + model: "cline-pass/glm-5.2", + reasoning_effort: "high", + max_tokens: 100, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/glm-5.2"); + assert.equal(body.max_tokens, 100); +}); + +test("no-op for a non-clinepass provider (gate)", () => { + const executor = new DefaultExecutor("openrouter"); + const body = { + model: "cline-pass/deepseek-v4-pro", + reasoning_effort: "high", + max_tokens: 100, + } as Record; + + executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro"); + assert.equal(body.max_tokens, 100); +}); diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index eb4e425673..601d03f72e 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -31,12 +31,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 158 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 159 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 158); - assert.equal(new Set(keys).size, 158, "duplicate keys after spread-merge"); + assert.equal(keys.length, 159); + assert.equal(new Set(keys).size, 159, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 158. + // strict partition (every provider in exactly one), so the sum must be exactly 159. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -56,7 +56,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 158 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 158, "families must partition all 158 providers"); + assert.equal(famTotal, 159, "families must partition all 159 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {