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