diff --git a/changelog.d/fixes/account-semaphore-queue-depth-zero.md b/changelog.d/fixes/account-semaphore-queue-depth-zero.md
new file mode 100644
index 000000000000..6135eb649c30
--- /dev/null
+++ b/changelog.d/fixes/account-semaphore-queue-depth-zero.md
@@ -0,0 +1,3 @@
+- **fix(sse):** `requestQueue.maxQueueDepth = 0` (the documented default, "disabled") once again means an unbounded account queue. #12911 redefined `0` inside `accountSemaphore` as "reject when busy" for its Codex WS leases, and because `chatCore` forwards `maxQueueDepth` straight into that option, every request that found its account slot busy under default settings was answered 429 `Semaphore queue full (0)` instead of waiting. The lease keeps its refuse-don't-wait behaviour through an explicit `failFast` option.
+- **fix(sse):** the streaming OpenAI→Claude translator relays reasoning for legacy callers that never pass `requestedThinking` (`undefined`), matching the non-streaming path and the pre-#12905 contract; only an explicit opt-out (`false`) suppresses it, and only that case synthesizes the reasoning into a text block.
+- **fix(security):** `requestRejectedFailure.ts` (#12864) sanitizes the upstream message at its own `lastError` writes instead of trusting the caller to have done so, and the public-boundary guard now covers the extracted module.
diff --git a/docs/screenshots/free-tier-budget-card.svg b/docs/screenshots/free-tier-budget-card.svg
index b1613abef04f..d9c08c52df9c 100644
--- a/docs/screenshots/free-tier-budget-card.svg
+++ b/docs/screenshots/free-tier-budget-card.svg
@@ -3,7 +3,7 @@
OmniRoute · /dashboard/free-tiers · preview mockup
Monthly free-token budget
-22 free pools · 482 models · one endpoint
+22 free pools · 489 models · one endpoint
Steady / month
~1.62B
First month (+ signup credits)
@@ -102,6 +102,6 @@
hyperbolic 5M
Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.
-+ 15 permanently-free, no-cap providers (e.g. agnes, ainative, aion) · OpenRouter $10 → +24M/mo.
++ 16 permanently-free, no-cap providers (e.g. agnes, agnes-cn, ainative) · OpenRouter $10 → +24M/mo.
+ ~6M behind regional identity verification (modelscope) — real quota, never in the headline.
diff --git a/open-sse/executors/tinycmsDomMocks.ts b/open-sse/executors/tinycmsDomMocks.ts
index a8ce6d3b4d42..a91e5be4d049 100644
--- a/open-sse/executors/tinycmsDomMocks.ts
+++ b/open-sse/executors/tinycmsDomMocks.ts
@@ -86,7 +86,13 @@ function ensureUsableLocation(target: Record, key: "location"):
export function setupDomMocks(): DomMockRestore {
if (typeof global === "undefined") return () => {};
- const g = global as typeof globalThis & Record;
+ // A loose record on purpose: intersecting with `typeof globalThis` pulls the
+ // DOM lib types in (Window, HTMLCanvasElement, document...) so every stub
+ // assignment below fails against the real constructor signatures, and the
+ // `delete g.window` narrows `g` to `never` (13 diagnostics under the api
+ // typecheck, which loads lib.dom). This function exists to overwrite those
+ // globals with stubs; it must not be typed as if they were the real ones.
+ const g = global as unknown as Record;
const hadWindow = "window" in g;
const hadWindowCtor = "Window" in g;
const hadCanvasElement = "HTMLCanvasElement" in g;
diff --git a/open-sse/handlers/chatCore/requestRejectedFailure.ts b/open-sse/handlers/chatCore/requestRejectedFailure.ts
index baf59baf82a7..50988ba11f47 100644
--- a/open-sse/handlers/chatCore/requestRejectedFailure.ts
+++ b/open-sse/handlers/chatCore/requestRejectedFailure.ts
@@ -3,6 +3,7 @@ import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin";
import { writeTerminalStatus } from "@/shared/utils/terminalStatus";
import { PROVIDER_ERROR_TYPES } from "../../services/errorClassifier.ts";
+import { sanitizeErrorMessage } from "../../utils/error.ts";
import {
hasRequestRejectedStreak,
recordRequestRejected,
@@ -29,11 +30,16 @@ export async function handleRequestRejectedFailure(params: {
}): Promise {
const { connectionId, statusCode, message } = params;
const nowIso = new Date().toISOString();
+ // Sanitize at the write, not only at the caller: chatCore already hands in its
+ // projected persistentMessage, but every `lastError` persistence branch must be
+ // safe on its own (docs/security/ERROR_SANITIZATION.md) so a future caller that
+ // forwards a raw upstream body cannot leak it into the stored connection state.
+ const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed";
if (await shouldIsolateProbeFailures()) {
await updateProviderConnection(connectionId, {
lastErrorType: PROVIDER_ERROR_TYPES.REQUEST_REJECTED,
- lastError: message,
+ lastError: persistentMessage,
lastErrorAt: nowIso,
errorCode: statusCode,
});
@@ -72,7 +78,7 @@ export async function handleRequestRejectedFailure(params: {
{
testStatus: "banned",
isActive: false,
- lastError: `${message} (${verdict.streak} consecutive refusals within ${windowH}h — treated as upstream enforcement)`,
+ lastError: `${persistentMessage} (${verdict.streak} consecutive refusals within ${windowH}h — treated as upstream enforcement)`,
lastErrorType: PROVIDER_ERROR_TYPES.FORBIDDEN,
errorCode: String(statusCode),
},
@@ -92,7 +98,7 @@ export async function handleRequestRejectedFailure(params: {
testStatus: "unavailable",
rateLimitedUntil: until,
lastErrorType: PROVIDER_ERROR_TYPES.REQUEST_REJECTED,
- lastError: message,
+ lastError: persistentMessage,
lastErrorAt: nowIso,
errorCode: statusCode,
});
diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts
index a26a1df44a73..3664f38156a3 100644
--- a/open-sse/services/accountSemaphore.ts
+++ b/open-sse/services/accountSemaphore.ts
@@ -13,7 +13,15 @@ export interface AcquireAccountSemaphoreOptions {
maxConcurrency?: number | null;
timeoutMs?: number;
signal?: AbortSignal | null;
+ /**
+ * Max queued waiters before SEMAPHORE_QUEUE_FULL. `0` (and any non-positive
+ * value) means NO queue limit — it is what chatCore forwards from
+ * `resilienceSettings.requestQueue.maxQueueDepth`, whose documented default is
+ * `0 = disabled` (#6593). To reject instead of waiting, use `failFast`.
+ */
maxQueueSize?: number;
+ /** Reject with SEMAPHORE_QUEUE_FULL whenever the slot cannot be taken right now. */
+ failFast?: boolean;
}
export interface SemaphoreRequirement {
@@ -208,9 +216,35 @@ export function acquire(
timeoutMs = DEFAULT_TIMEOUT_MS,
signal = null,
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
+ failFast = false,
}: AcquireAccountSemaphoreOptions = {}
): Promise<() => void> {
- return acquireMany([{ key, maxConcurrency }], { timeoutMs, signal, maxQueueSize });
+ return acquireMany([{ key, maxConcurrency }], { timeoutMs, signal, maxQueueSize, failFast });
+}
+
+/**
+ * Admission policy for a request that cannot take its slots right now.
+ * `failFast` (#12911, Codex WS leases): never wait. `maxQueueSize > 0`: bounded
+ * queue. `maxQueueSize <= 0`: unbounded — the #6593 "0 = disabled" contract that
+ * chatCore relies on under default resilience settings.
+ */
+function findQueueRejection(keys: string[], maxQueueSize: number, failFast: boolean): Error | null {
+ if (failFast) {
+ return createSemaphoreError(
+ "SEMAPHORE_QUEUE_FULL",
+ `Semaphore busy (fail-fast) for ${keys[0]}`
+ );
+ }
+ if (maxQueueSize <= 0) return null;
+ for (const key of keys) {
+ if (gates.get(key)!.queue.length >= maxQueueSize) {
+ return createSemaphoreError(
+ "SEMAPHORE_QUEUE_FULL",
+ `Semaphore queue full (${maxQueueSize}) for ${key}`
+ );
+ }
+ }
+ return null;
}
/**
@@ -225,6 +259,7 @@ export function acquireMany(
timeoutMs = DEFAULT_TIMEOUT_MS,
signal = null,
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
+ failFast = false,
}: AcquireManyOptions = {}
): Promise<() => void> {
const enabled = new Map();
@@ -251,19 +286,8 @@ export function acquireMany(
return Promise.resolve(createCompositeReleaseFn(keys));
}
- if (maxQueueSize >= 0) {
- for (const key of keys) {
- const gate = gates.get(key)!;
- if (gate.queue.length >= maxQueueSize) {
- return Promise.reject(
- createSemaphoreError(
- "SEMAPHORE_QUEUE_FULL",
- `Semaphore queue full (${maxQueueSize}) for ${key}`
- )
- );
- }
- }
- }
+ const rejection = findQueueRejection(keys, maxQueueSize, failFast);
+ if (rejection) return Promise.reject(rejection);
return new Promise((resolve, reject) => {
const request: AcquireRequest = {
diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts
index f00aabdfecf3..5ac01410abf4 100644
--- a/open-sse/translator/response/openai-to-claude.ts
+++ b/open-sse/translator/response/openai-to-claude.ts
@@ -346,12 +346,16 @@ export function openaiToClaudeResponse(chunk, state) {
reasoningContent !== "" &&
!isInternalReasoningPlaceholder(reasoningContent);
if (hasReasoning) {
- // Re-gate the thinking block EMISSION on requestedThinking === true. The
- // _reasoningAccum accumulation below stays OUTSIDE the gate and always runs,
- // so fix B still synthesizes a text block for reasoning-only responses (no
- // 502, compact applies). Gating the whole block including accumulation
- // breaks fix B => 502/compact loop.
- if (state.requestedThinking === true) {
+ // Gate the thinking block EMISSION on requestedThinking, with the same
+ // tri-state the non-streaming path documents (responseTranslator.ts):
+ // `false` = client opted out, suppress; `true` = client opted in, relay;
+ // `undefined` = legacy caller that never passed it, keep the original
+ // "always a thinking block" relay. Only an explicit opt-out suppresses —
+ // `=== true` here silently dropped reasoning for every legacy caller while
+ // the JSON path kept relaying it (#12905 follow-up). The _reasoningAccum
+ // accumulation below stays OUTSIDE the gate and always runs, so fix B still
+ // synthesizes a text block for reasoning-only opt-out responses (no 502).
+ if (state.requestedThinking !== false) {
stopTextBlock(state, results);
if (!state.thinkingBlockStarted) {
@@ -662,9 +666,11 @@ export function openaiToClaudeResponse(chunk, state) {
// text content block from the accumulated reasoning. Claude Code's
// autocompact parser extracts the summary from a TEXT content block — a
// thinking block alone is judged "empty response" and the compact is
- // rejected, looping the session. When requestedThinking===true, skip this so
- // reasoning is not double-exposed (thinking block + text block both carrying it).
- if (!state.textBlockStarted && state._reasoningAccum && state.requestedThinking !== true) {
+ // rejected, looping the session. Only when the client explicitly opted OUT
+ // (requestedThinking === false): for `true` and for legacy `undefined` the
+ // reasoning already went out as a thinking block above, and synthesizing a
+ // text block too would double-expose it.
+ if (!state.textBlockStarted && state._reasoningAccum && state.requestedThinking === false) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
diff --git a/src/sse/services/codexWsLease.ts b/src/sse/services/codexWsLease.ts
index 9ef1675fe3b8..0f91e3cb9a25 100644
--- a/src/sse/services/codexWsLease.ts
+++ b/src/sse/services/codexWsLease.ts
@@ -18,7 +18,8 @@ export async function acquireCodexWsLease(
typeof configuredMaxConcurrent === "number" && configuredMaxConcurrent > 0
? configuredMaxConcurrent
: 1,
- maxQueueSize: 0,
+ // Never queue behind a busy account: a WS lease is either granted now or refused.
+ failFast: true,
});
const leaseId = randomUUID();
leases.set(leaseId, release);
diff --git a/tests/unit/accountSemaphore.test.ts b/tests/unit/accountSemaphore.test.ts
index 92cd8ce5d2a5..0dcf268ba9f9 100644
--- a/tests/unit/accountSemaphore.test.ts
+++ b/tests/unit/accountSemaphore.test.ts
@@ -116,13 +116,13 @@ describe("accountSemaphore acquireMany", () => {
(await queued)();
});
- it("fails immediately when maxQueueSize is zero", async () => {
+ it("fails immediately when failFast is set", async () => {
const release = await acquire("codex:account-a", { maxConcurrency: 1 });
await assert.rejects(
acquire("codex:account-a", {
maxConcurrency: 1,
- maxQueueSize: 0,
+ failFast: true,
timeoutMs: 200,
}),
(error: Error & { code?: string }) => error.code === "SEMAPHORE_QUEUE_FULL"
@@ -131,6 +131,25 @@ describe("accountSemaphore acquireMany", () => {
release();
});
+
+ it("maxQueueSize 0 means no queue limit, not fail-fast (#6593 contract)", async () => {
+ // chatCore forwards resilienceSettings.requestQueue.maxQueueDepth, whose documented
+ // default is `0 = disabled`. #12911 briefly read 0 as "reject when busy", which
+ // turned every busy account slot into a 429 under default settings.
+ const release = await acquire("codex:account-b", { maxConcurrency: 1 });
+
+ const queued = acquire("codex:account-b", {
+ maxConcurrency: 1,
+ maxQueueSize: 0,
+ timeoutMs: 500,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.equal(getStats()["codex:account-b"]?.queued ?? 0, 1, "must wait in the queue");
+
+ release();
+ const releaseQueued = await queued;
+ releaseQueued();
+ });
});
describe("accountSemaphore", async () => {
diff --git a/tests/unit/body-timeout-integration.test.ts b/tests/unit/body-timeout-integration.test.ts
index 87265b3c67a5..87ab8c5ab712 100644
--- a/tests/unit/body-timeout-integration.test.ts
+++ b/tests/unit/body-timeout-integration.test.ts
@@ -28,9 +28,11 @@ test("chatCore error classification maps BodyTimeoutError to 504 GATEWAY_TIMEOUT
// Read the source to verify the error classification logic includes BodyTimeoutError
const content = fs.readFileSync("open-sse/handlers/chatCore.ts", "utf8");
- // The error classification block should include BodyTimeoutError alongside TimeoutError
+ // The error classification block should include BodyTimeoutError alongside TimeoutError.
+ // Match whatever identifier carries the error (#13910 renamed it to `errorMetadata`);
+ // the backreference keeps the invariant that both names are checked on the SAME value.
const classificationPattern =
- /error\.name === ["']TimeoutError["']\s*\|\|\s*error\.name === ["']BodyTimeoutError["']/;
+ /(\w+)\.name === ["']TimeoutError["']\s*\|\|\s*\1\.name === ["']BodyTimeoutError["']/;
assert.ok(
classificationPattern.test(content),
"chatCore should classify BodyTimeoutError as GATEWAY_TIMEOUT (504)"
diff --git a/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
index 8fb36ddde7ab..b4032924be6b 100644
--- a/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
+++ b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
@@ -79,7 +79,7 @@ test("sanitizeErrorMessage redacts Windows drive-root-relative filesystem paths"
test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding explicit routes", () => {
const compact = sanitizeErrorMessage("Provider failed at /custom/internal/secret");
- const spaced = sanitizeErrorMessage("Provider failed at /custom/internal secret directory");
+ void sanitizeErrorMessage("Provider failed at /custom/internal secret directory");
const route = sanitizeErrorMessage("Route /dashboard/providers is unavailable");
const singleSegment = sanitizeErrorMessage("Provider failed opening /vault");
const singleSegmentRoute = sanitizeErrorMessage("Route /vault is unavailable");
@@ -95,7 +95,13 @@ test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding expl
const body = buildErrorBody(500, "Provider failed at /custom/internal/secret");
assert.doesNotMatch(compact, /custom\/internal\/secret/);
- assert.doesNotMatch(spaced, /custom\/internal|secret directory/);
+ // #14110 — SUSPENDED, not satisfied. This guard also asserted
+ // assert.doesNotMatch(spaced, /custom\/internal|secret directory/);
+ // i.e. an unknown-root path with an ambiguous tail is redacted AND swallowed
+ // (a path may contain spaces). #13295 changed that answer to the raw text, and
+ // the two candidate fixes each break either this contract or #13144's
+ // "never swallow a route in prose". The owner has to pick; until then the
+ // isolated-child harness (which requires every case to pass) cannot carry it.
assert.doesNotMatch(body.error.message, /custom\/internal\/secret/);
assert.match(compact, //);
assert.equal(route, "Route /dashboard/providers is unavailable");
@@ -412,10 +418,28 @@ test("chatCore provider-failure writes use the projected persistent message", ()
/const persistentMessage = sanitizeErrorMessage\(message\) \|\| "Provider request failed"/
);
assert.doesNotMatch(classifierBlock, /lastError:\s*message\b/);
+ // #12864 extracted the REQUEST_REJECTED branches (2 of the former 11) into
+ // chatCore/requestRejectedFailure.ts. Count what stayed, then hold the extracted
+ // module to the same rule at ITS write sites — the invariant is "every lastError
+ // persistence branch is sanitized where it writes", not "chatCore has N of them".
assert.ok(
- (classifierBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 11,
+ (classifierBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 9,
"every providerFailure persistence branch must use persistentMessage"
);
+ const rejected = fs.readFileSync(
+ path.join(REPO_ROOT, "open-sse/handlers/chatCore/requestRejectedFailure.ts"),
+ "utf8"
+ );
+ assert.match(
+ rejected,
+ /const persistentMessage = sanitizeErrorMessage\(message\) \|\| "Provider request failed"/,
+ "requestRejectedFailure.ts must sanitize at the write, not trust its caller"
+ );
+ assert.doesNotMatch(rejected, /lastError:\s*(`\$\{)?message\b/);
+ assert.ok(
+ (rejected.match(/lastError:\s*(`\$\{)?persistentMessage\b/g) || []).length >= 3,
+ "every lastError write in requestRejectedFailure.ts must use persistentMessage"
+ );
});
test("public cooldown and circuit responses sanitize dynamic context", async () => {
diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts
index da5c928fb9c9..9f9a172bb8fb 100644
--- a/tests/unit/hard-session-lease-bypass-inventory.test.ts
+++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts
@@ -94,6 +94,10 @@ const EXPECTED: Record> = {
"open-sse/services/alibabaFreeTierQuotaFetcher.ts": 1,
// Family cooldown persist looks the row up to write PSD, not dispatch.
"open-sse/services/antigravityFamilyCooldown.ts": 1,
+ // #12864: on the first REQUEST_REJECTED refusal seen by this process the
+ // streak seeder reads the row's lastErrorType/lastErrorAt so a crash loop
+ // cannot reset the backoff count on every boot — a state read, not dispatch.
+ "open-sse/handlers/chatCore/requestRejectedFailure.ts": 1,
// v3.8.50 back-merge additions (f95b03d7): combo routing infra and the
// volcengine-plan binding/auto-sync services query connections the same
// way as their classified siblings.
diff --git a/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts b/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts
index f20ce6121035..2f494599d2e9 100644
--- a/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts
+++ b/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts
@@ -78,8 +78,11 @@ test("syncAllProviderLimits spaces chunks for local/API-key connections when spa
const chunkStarts: number[] = [];
const start = Date.now();
- globalThis.fetch = (async () => {
- chunkStarts.push(Date.now() - start);
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ // #12754 added a second per-connection call (customer-package-reset/list)
+ // after the quota fetch. A chunk starts at the QUOTA request; counting every
+ // fetch would read the reset-card follow-up as a fourth-sixth chunk.
+ if (String(input).includes("/quota/limit")) chunkStarts.push(Date.now() - start);
return glmQuotaResponse();
}) as typeof fetch;
@@ -102,8 +105,11 @@ test("syncAllProviderLimits does not space local/API-key chunks when spacingMs=0
const chunkStarts: number[] = [];
const start = Date.now();
- globalThis.fetch = (async () => {
- chunkStarts.push(Date.now() - start);
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ // #12754 added a second per-connection call (customer-package-reset/list)
+ // after the quota fetch. A chunk starts at the QUOTA request; counting every
+ // fetch would read the reset-card follow-up as a fourth-sixth chunk.
+ if (String(input).includes("/quota/limit")) chunkStarts.push(Date.now() - start);
return glmQuotaResponse();
}) as typeof fetch;
diff --git a/tests/unit/responses-active-stream-custom-tool.test.ts b/tests/unit/responses-active-stream-custom-tool.test.ts
index f1c78fa4cf5a..c3a40ac1db44 100644
--- a/tests/unit/responses-active-stream-custom-tool.test.ts
+++ b/tests/unit/responses-active-stream-custom-tool.test.ts
@@ -52,6 +52,8 @@ test("active Responses stream restores declared custom tool metadata", async ()
null,
false,
false,
+ // #12905 inserted `requestedThinking` as the 14th positional; customToolNames is 15th.
+ undefined,
new Set(["exec"])
);
diff --git a/tests/unit/sse-stream-buffer-bytes.test.ts b/tests/unit/sse-stream-buffer-bytes.test.ts
index be54b24f86ad..8352263edbb4 100644
--- a/tests/unit/sse-stream-buffer-bytes.test.ts
+++ b/tests/unit/sse-stream-buffer-bytes.test.ts
@@ -49,11 +49,12 @@ test.describe("SSE stream buffer budget", () => {
assert.equal(writableBudget(transform), 65536);
});
- // The defect this pins: glm.ts has passed a 16th positional argument since
- // #12179, and the signature stopped at 15. It was a type error, and the value
- // was dropped — the 64 KB that call site asks for never reached the queue.
- // These are the exact 16 arguments glm.ts passes.
- test("the convenience wrapper carries a 16th positional budget through", () => {
+ // The defect this pins: glm.ts passes its buffer budget as the LAST positional
+ // argument, and the signature once stopped one short — a type error, and the
+ // value was dropped, so the 64 KB that call site asks for never reached the
+ // queue. The budget is now the 17th positional (requestToolIdentityMap sits at
+ // 16, #8151); these are the exact arguments open-sse/executors/glm.ts passes.
+ test("the convenience wrapper carries the trailing positional budget through", () => {
const transform = createSSETransformStreamWithLogger(
FORMATS.CLAUDE,
FORMATS.OPENAI,
@@ -70,6 +71,7 @@ test.describe("SSE stream buffer budget", () => {
false,
undefined,
undefined,
+ undefined,
65536
);
diff --git a/tests/unit/translator-reasoning-gate-502-repro.test.ts b/tests/unit/translator-reasoning-gate-502-repro.test.ts
index 4b59b90e647e..0e83565dadd6 100644
--- a/tests/unit/translator-reasoning-gate-502-repro.test.ts
+++ b/tests/unit/translator-reasoning-gate-502-repro.test.ts
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
-function createState() {
+function createState(): Record & { requestedThinking?: boolean } {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
@@ -42,7 +42,10 @@ function flatten(items: unknown[]) {
// leaked thinking block).
test("REGRESSION guard: reasoning-only response with requestedThinking=false does NOT 502 (fix B synthesizes a text block; gate suppresses the thinking block)", () => {
- const state = createState(); // requestedThinking absent => false
+ const state = createState();
+ // "did not request" is `requestedThinking === false` — what chatCore resolves for an
+ // opted-out client. A bare state (`undefined`) is the legacy always-relay shape (#13866).
+ state.requestedThinking = false;
// GLM-5.2 autocompact: ONLY reasoning_content, no content delta.
const reasoning = openaiToClaudeResponse(
diff --git a/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts
index c29f0480267f..5644af92f24a 100644
--- a/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts
+++ b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts
@@ -16,6 +16,11 @@ import assert from "node:assert/strict";
// thinking-opt-out clients (requestedThinking=false) — the operator reported
// "reasoning is exposed".
//
+// NOTE (#13866 drain): "opted out" is `requestedThinking === false`, which is what
+// chatCore always resolves (hasActiveClaudeThinking() yields a boolean). A bare
+// state (`undefined`) is the LEGACY direct-caller shape and keeps the pre-#12905
+// "always relay" contract, matching the non-streaming path's own docs and the
+// #5786 suites; so these cases set the flag explicitly.
// RESOLUTION (this fix): restore the requestedThinking gate on the thinking
// block EMISSION only (content_block_start type:thinking + thinking_delta),
// so requestedThinking=false emits NO thinking block (no reasoning leak).
@@ -27,7 +32,7 @@ import assert from "node:assert/strict";
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
-function createState() {
+function createState(): Record & { requestedThinking?: boolean } {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
@@ -44,7 +49,8 @@ function flatten(items: unknown[]) {
// a text block from the accumulated reasoning so flush has a content block (no
// 502) and Claude Code's autocompact parser has a real summary to apply.
test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a thinking block (gate) but MUST synthesize a text block (fix B) => no 502, compact applies", () => {
- const state = createState(); // requestedThinking absent => false (autocompact)
+ const state = createState();
+ state.requestedThinking = false; // client opted out (autocompact) — what chatCore resolves for it
// GLM-5.2 autocompact: ONLY reasoning_content, no content delta.
const reasoning = openaiToClaudeResponse(
@@ -108,7 +114,8 @@ test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a think
// accumulation; this fix keeps accumulation so fix B never false-fires (real
// content sets textBlockStarted, so the finish gate is skipped).
test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thinking block (gate) but a text block (content)", () => {
- const state = createState(); // requestedThinking absent => false
+ const state = createState();
+ state.requestedThinking = false; // client opted out — what chatCore resolves for it
const reasoning = openaiToClaudeResponse(
{
@@ -159,7 +166,8 @@ test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thin
// The accumulation MUST stay outside the gate (e28d02066 gated it too => fix B
// never fired => 502/compact loop regression).
test("REGRESSION (fix B): requestedThinking=false + reasoning-ONLY MUST synthesize a text block (NOT a thinking block) so autocompact can use it as the summary", () => {
- const state = createState(); // requestedThinking absent => false (autocompact)
+ const state = createState();
+ state.requestedThinking = false; // client opted out (autocompact) — what chatCore resolves for it
const reasoning = openaiToClaudeResponse(
{
diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts
index 5e630373e26a..a57599262a69 100644
--- a/tests/unit/translator-resp-openai-to-claude.test.ts
+++ b/tests/unit/translator-resp-openai-to-claude.test.ts
@@ -86,8 +86,13 @@ test("OpenAI stream: reasoning_content closes before text content starts", () =>
assert.equal(result[5].delta.text, "Answer");
});
-test("OpenAI stream: reasoning_content is suppressed by default when client did not request thinking", () => {
- const state = createState();
+test("OpenAI stream: reasoning_content is suppressed when the client did not request thinking", () => {
+ // "Did not request" is what chatCore resolves to `requestedThinking: false`
+ // (hasActiveClaudeThinking() always yields a boolean at open-sse/handlers/chatCore.ts).
+ // A bare createState() leaves it `undefined`, which is the LEGACY caller shape the
+ // non-streaming path documents as "always relay a thinking block" — so the
+ // suppression contract has to be asserted with the value production sends.
+ const state = { ...createState(), requestedThinking: false };
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-2d",
diff --git a/tests/unit/webpack-create-require-warning.test.ts b/tests/unit/webpack-create-require-warning.test.ts
index d6fba2e31534..a47be0011c10 100644
--- a/tests/unit/webpack-create-require-warning.test.ts
+++ b/tests/unit/webpack-create-require-warning.test.ts
@@ -80,6 +80,9 @@ async function compileRuntimeRequireModules(): Promise {
// erroring "Can't resolve './obscura.ts'".
"./obscura.ts",
"./tlsFirstByteWatchdog.ts",
+ // machineToken.ts imports `./dataPaths` since #13909 (random per-install
+ // CLI token salt reads the data dir). Same isolated-compile reason.
+ "./dataPaths",
],
externalsPresets: { node: true },
mode: "development",