From ebc69216673eea9e85e3538bae36f3f59eb7dc7a Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:37:38 +0600 Subject: [PATCH 01/39] fix(combo): continue reasoning effort fallback --- docs-site/src/content/docs/guides/combos.md | 4 +- src/combos/failover.ts | 39 ++++++++++++++++++++ src/server/responses/adapter-dispatch.ts | 13 +++---- src/server/responses/passthrough-dispatch.ts | 16 ++++---- structure/runtime.md | 4 +- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 304e2826c01..27f9eeb7d91 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -682,7 +682,7 @@ provider, see [Fallback off](/guides/fallback-off/). | Confirmed context-window overflow before output, including HTTP 400 | Try the next untried eligible target without cooling this target; another model may have a larger window. Exhaustion returns the first confirmed HTTP 400 context-length error; a context refusal sent with another status still advances but is not retained. | | Client cancellation (499), `origin_rejected`, cyber-policy refusal, or other invalid request | Stop and return the error; another target would not make the request valid. | | Exact HTTP 400 saying a model is not supported when using Codex with a ChatGPT account | Try the next declared target before output commitment. The message must be the whole refusal in `detail`, `error.message`, or bare text; competing `detail` and `error` envelopes stay terminal. This does not quarantine the account or cool every model on the provider. | -| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | Hop before output commitment without cooling; see request-local target compatibility below. | +| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | For an effort refusal, try the next lower published rung on the same target while the send budget allows; if no rung works, hop before output commitment without cooling. Other listed mismatches hop without cooling; see request-local target compatibility below. | | First tool call of a Responses turn run by an in-process adapter (`runTurn`) that the current request did not declare, before any output or replay-unsafe side effect | Cool the target and hop with the same tool catalog. After visible output or a replay-unsafe side effect the refusal is final. Chat Completions and Anthropic Messages requests are unchanged. | | Any other unclassified error | Stop and return the error. | @@ -810,7 +810,7 @@ instead of growing memory without a bound. When routing Claude Code to the canonical ChatGPT Codex backend, OpenCodex removes the unsupported top-level `user` metadata field without changing the session/cache key, input messages, tool schemas, or safety identifiers. Public Responses API and noncanonical forward gateways keep that field. -A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. This is a mismatch for that request, not evidence that the target is unhealthy, so it records no cooldown. This compatibility recovery does not silently change `none` into a different effort or broaden this exception to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection. +A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection. For the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}`, it first retries the same target at the next lower published effort rung, one rung per refusal and within the shared send budget. If no lower rung succeeds, the combo advances to another target without cooling the rejected one. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. These are mismatches for this request, not evidence that the target is unhealthy. Direct requests retain their single effort-downgrade repair. Compatibility recovery does not silently change `none` into a different effort or broaden the combo hop to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection. ## Tool calls stay on one target diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 7dbafe75fab..4a7a4a03f01 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -382,6 +382,44 @@ function normalizedFailureCode(code?: string | null): string { return code?.trim().toLowerCase().replaceAll("-", "_") ?? ""; } +/** + * xAI can reject a model/account effort rung with this exact vendor envelope. It is target-local + * evidence for a combo: the same model may accept a lower rung, or a later target may accept the + * request. Keep the match exact so unrelated invalid arguments stay terminal. + */ +function isExactInvalidReasoningEffortRefusal(status: number, message: string, code?: string | null): boolean { + if (status !== 400 || message.length > 16_384) return false; + const outerCode = normalizedFailureCode(code); + if (!["", "invalid_request_error", "invalid_argument"].includes(outerCode)) return false; + let text = message.trim(); + for (let depth = 0; depth < 3; depth += 1) { + if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length).trim(); + let payload: unknown; + try { payload = JSON.parse(text); } catch { return false; } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const root = payload as Record; + if (normalizedFailureCode(typeof root.code === "string" ? root.code : undefined) === "invalid_argument" + && root.error === "Invalid reasoning effort." + && (root.type === undefined || root.type === "invalid_request_error") + && (root.param === undefined || root.param === "reasoning_effort" || root.param === "reasoning.effort")) return true; + const error = root.error; + if (!error || typeof error !== "object" || Array.isArray(error)) return false; + const record = error as Record; + const errorCode = normalizedFailureCode(typeof record.code === "string" ? record.code : undefined); + if (record.type === "upstream_error" && typeof record.message === "string" + && record.message.startsWith("Provider error 400: ") + && ["", "invalid_request_error", "invalid_argument"].includes(errorCode)) { + text = record.message; + continue; + } + return record.type === "invalid_request_error" + && record.message === "Invalid reasoning effort." + && ["", "invalid_request_error", "invalid_argument"].includes(errorCode) + && (record.param === undefined || record.param === "reasoning_effort" || record.param === "reasoning.effort"); + } + return false; +} + /** * A spent account window, by structured code or upstream prose. Status is deliberately not * consulted: the ChatGPT Codex backend reports a depleted plan window as HTTP 502 @@ -509,6 +547,7 @@ const PROVIDER_SCOPED_FAILURE_CODES = new Set([ * Image rejection requires `param: input` and an exact model-scoped prefix. */ function isRequestLocalTargetIncompatibility(status: number, message: string, code?: string | null): boolean { + if (isExactInvalidReasoningEffortRefusal(status, message, code)) return true; if (status !== 400 || message.length > 16_384) return false; const genericCodes = new Set(["", "invalid_request_error", "unsupported_parameter", "unsupported_value"]); if (!genericCodes.has(normalizedFailureCode(code))) return false; diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 59e048c0d38..6a80625173f 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -617,9 +617,8 @@ export async function prepareAdapterExchange( const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false }; let oauth401ReplayAttempted = transportState.oauth401ReplayAttempted; let antigravityAuthRotationAttempted = false; - // At most one reasoning-effort downgrade per request. This sits outside the recovery loop - // below for the same reason the two guards above do: a guard declared inside it is reset by - // every `continue recovery`, which would let one turn walk the whole ladder down. + // Direct requests get one reasoning-effort downgrade. Combo candidates may walk down the + // published ladder one refused rung at a time; the request send budget bounds those retries. const reasoningEffortDowngradeGuard: { attempted: boolean } = { attempted: false }; // At most one Anthropic fast-mode downgrade per request, for the same reason. const anthropicFastDowngradeGuard: { attempted: boolean } = { attempted: false }; @@ -1832,12 +1831,12 @@ export async function prepareAdapterExchange( continue recovery; } } - // Reasoning-effort downgrade, mirroring the passthroughRecovery loop above: learn the - // refused rung, then replay once at the next published one. - if (!reasoningEffortDowngradeGuard.attempted) { + // Learn a refused rung and retry at the next published one. Direct requests are limited + // to one repair; combo candidates may continue down the finite ladder within the send budget. + if (options.comboAttempt || !reasoningEffortDowngradeGuard.attempted) { const rejectionText = await reasoningEffortRejectionText( upstreamResponse, - reasoningEffortDowngradeGuard.attempted, + false, upstream.signal, ); const downgrade = rejectionText === undefined diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 4f1d1edee9a..a312a6bfb43 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1051,7 +1051,8 @@ export async function preparePassthroughExchange( } const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; - // At most one reasoning-effort downgrade per request. + // Direct requests get one reasoning-effort downgrade. Combo candidates may walk down the + // published ladder one refused rung at a time; the request send budget bounds those retries. const reasoningEffortDowngradeGuard: { attempted: boolean } = { attempted: false }; /** Rebind the refreshed wire before transferring this request to the Chat pipeline. */ function bindRefreshedAdapter(refreshedAdapter: typeof transportState.adapter) { @@ -1812,15 +1813,14 @@ export async function preparePassthroughExchange( continue passthroughRecovery; } } - // Reasoning-effort downgrade: a rung the catalog still advertises can be refused upstream -- - // the metadata records the model's ladder, not this account's entitlement (a Muse Code - // subscription gates max on muse-spark-1.3-contributor, for example). Learn the refusal so - // later turns clamp before dispatch, then replay once at the next lower published rung - // instead of failing the turn; requestedEffort/effectiveEffort keep both values in usage. - if (!reasoningEffortDowngradeGuard.attempted) { + // A rung the catalog advertises can still be refused upstream -- metadata describes the + // model's ladder, not this account's entitlement. Learn each refusal so later turns clamp + // before dispatch. Direct requests repair once; combo candidates may retry the next lower + // published rung until it works, the ladder ends, or the send budget is spent. + if (options.comboAttempt || !reasoningEffortDowngradeGuard.attempted) { const rejectionText = await reasoningEffortRejectionText( upstreamResponse, - reasoningEffortDowngradeGuard.attempted, + false, upstream.signal, ); const downgrade = rejectionText === undefined diff --git a/structure/runtime.md b/structure/runtime.md index f3c2b17aacf..905369733fa 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -492,9 +492,9 @@ Translated audio/file admission follows the [final-adapter input contract](adapt `src/adapters/openai-responses.ts` omits top-level `user` and `safety_identifier` at the canonical ChatGPT Codex forward destination. The backend rejects both with its strict-allowlist `{"detail":"Unsupported parameter: …"}` envelope, and canonical forwarding removes `safety_identifier` regardless of which client supplied it — third-party clients (droid's registry-matched BYOK) attach it from the model name alone, but any Responses client may send the public OpenAI-platform parameter explicitly. Claude translation retains its original identity and prompt-cache key; public API and noncanonical gateways retain their `user` and `safety_identifier` fields. Input roles, tool-schema properties and original replay bodies are not changed. -`src/combos/failover.ts` treats four intact HTTP 400 invalid-request envelopes as request-local incompatibilities: exactly `Unsupported parameter: user`; `unsupported_value` naming `reasoning.effort` or `reasoning_effort` with an explicit unsupported-value message; `param: input` with a bounded model-scoped `does not support image inputs` message; and the exact null-code `gpt-6-astra` function-tool routing mismatch that tells an existing Responses request to use `/v1/responses`, allowing only the bare model name or its strict `YYYY-MM-DD` deployment suffix. A null provider code is accepted only for the two observed envelopes that require it. Only the exact proxy wrapper is unwrapped, within three envelopes and 16,384 characters; conflicting codes, malformed/truncated envelopes and reflected JSON do not gain hop permission. +`src/combos/failover.ts` treats four intact HTTP 400 invalid-request envelopes as request-local incompatibilities: exactly `Unsupported parameter: user`; `unsupported_value` naming `reasoning.effort` or `reasoning_effort` with an explicit unsupported-value message; `param: input` with a bounded model-scoped `does not support image inputs` message; and the exact null-code `gpt-6-astra` function-tool routing mismatch that tells an existing Responses request to use `/v1/responses`, allowing only the bare model name or its strict `YYYY-MM-DD` deployment suffix. It also accepts the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}` and the matching structured `invalid_request_error` message. A combo first retries that target at each next lower published reasoning rung, one step per refusal and within the shared send budget; if no lower rung works, it advances to another eligible target without cooling the refused one. For the vendor envelope, the inner code and exact message remain required even when an outer provider code is absent. Only the exact proxy wrapper is unwrapped, within three envelopes and 16,384 characters; conflicting codes, malformed/truncated envelopes and reflected JSON do not gain hop permission. -A `response_format` capability refusal is a fifth envelope, kept separate because it needs one code and one frame the four above do not admit. The refusal must name `response_format` AND state that it is unavailable or unsupported; a message that merely names the field, such as an invalid-schema complaint, stays terminal, because replaying a malformed request at every later target is the outcome this distinction exists to avoid. `param` may be absent or explicitly null and a param naming another field fails closed. Its code set is the shared generic one plus `invalid_parameter_error`, held separately so the `user` and image branches are not widened by it. It also unwraps a single `data:` SSE frame on a one-line body — the reported gateway answers on the stream, so the error object is never extracted and the structured code arrives undefined — while a multi-event body is left alone. The next target receives the same request with `response_format` intact: no field is dropped and the output contract the caller asked for is unchanged. Traversal stays finite because combo excludes each attempted target. This verdict records no cooldown, and cancellation, origin/cyber-policy rejection and the non-replayable post-send codes are all tested before it (#4903). +A `response_format` capability refusal is a sixth envelope, kept separate because it needs one code and one frame the earlier cases do not admit. The refusal must name `response_format` AND state that it is unavailable or unsupported; a message that merely names the field, such as an invalid-schema complaint, stays terminal, because replaying a malformed request at every later target is the outcome this distinction exists to avoid. `param` may be absent or explicitly null and a param naming another field fails closed. Its code set is the shared generic one plus `invalid_parameter_error`, held separately so the `user` and image branches are not widened by it. It also unwraps a single `data:` SSE frame on a one-line body — the reported gateway answers on the stream, so the error object is never extracted and the structured code arrives undefined — while a multi-event body is left alone. The next target receives the same request with `response_format` intact: no field is dropped and the output contract the caller asked for is unchanged. Traversal stays finite because combo excludes each attempted target. This verdict records no cooldown, and cancellation, origin/cyber-policy rejection and the non-replayable post-send codes are all tested before it (#4903). The combo may advance to its next eligible unattempted target before output commitment. It records no target/provider cooldown for these request-local mismatches and does not silently drop reasoning controls or raise `none` to a supported rung. Cancellation, origin/cyber-policy rejection, non-replayable post-send errors and the existing streaming commit boundary stay authoritative. Apart from the definite context overflow below, other invalid requests remain terminal. From b2c99b5fe97964107479a975712a25a855281fe9 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:10:00 +0600 Subject: [PATCH 02/39] test(combo): cover reasoning effort ladder fallback --- ...sponses-reasoning-effort-downgrade.test.ts | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index e609d2f6e80..7cd9d709550 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -3,9 +3,11 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses } from "../../src/server/responses/core"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { resetReasoningMetadataCachesForTests } from "../../src/providers/reasoning-metadata"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; +import { chatSuccess } from "../helpers/combo-failover-upstream"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; /** @@ -25,6 +27,7 @@ const REFUSAL = JSON.stringify({ message: "Error from provider (Console Go): Upstream request failed: [invalid_request_error] reasoning_effort max requires an active Muse Code subscription for model muse-spark-1.3-contributor.", }, }); +const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); let testDir = ""; @@ -82,12 +85,12 @@ function effortOf(body: Record | undefined): unknown { return body.reasoning_effort; } -function request(stream = false): Request { +function request(stream = false, model = "first/" + MODEL): Request { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "first/" + MODEL, + model, stream, store: false, reasoning: { effort: "max" }, @@ -101,6 +104,8 @@ function success(): Response { } beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); testDir = mkdtempSync(join(tmpdir(), "ocx-reasoning-downgrade-")); process.env.OPENCODEX_HOME = testDir; // Direct handler dispatches need the writer lease that startServer normally holds. @@ -113,6 +118,8 @@ afterEach(() => { releaseSpendHome?.(); releaseSpendHome = undefined; globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); resetReasoningMetadataCachesForTests(); if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = originalOpenCodexHome; @@ -231,4 +238,44 @@ describe("rejected reasoning rungs", () => { expect(effortOf(outbound[1])).toBe("high"); expect(response.ok).toBe(false); }); + + for (const adapter of ["openai-chat", "openai-responses"] as const) { + test(`combo tries every lower effort before falling back (${adapter})`, async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return url.host === "fallback.example" + ? chatSuccess("fallback served the turn", MODEL) + : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("fallback served the turn"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + { host: "opencode.ai", effort: "low" }, + { host: "fallback.example", effort: "max" }, + ]); + }); + } }); From 02fd10467f05f8dcb6a8daf31de6c1d4e67e40c7 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:23:56 +0600 Subject: [PATCH 03/39] fix(combo): unwrap data: refusal and preserve 400 on unbudgeted effort retry --- src/combos/failover.ts | 1 + src/server/responses/adapter-dispatch.ts | 10 +++++--- src/server/responses/passthrough-dispatch.ts | 25 ++++++++++++++++--- ...uter-combo-failover-classification.test.ts | 11 ++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 4a7a4a03f01..9dccf0f9f2b 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -394,6 +394,7 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c let text = message.trim(); for (let depth = 0; depth < 3; depth += 1) { if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length).trim(); + if (text.startsWith("data:") && !text.includes("\n")) text = text.slice("data:".length).trim(); let payload: unknown; try { payload = JSON.parse(text); } catch { return false; } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 6a80625173f..9a185eda8c9 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1852,9 +1852,13 @@ export async function prepareAdapterExchange( parsed.options.reasoning = downgrade.effort; // The same-target cache keys on parsed identity, so a mutated effort needs a token bump. invalidateSameTargetRequest(); - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - const result = await rebuildAndRefetch("reasoning-effort-downgrade"); - if ("failed" in result) return result.failed; + const failedResponse = await retainFailureResponse(); + const result = await rebuildAndRefetch("reasoning-effort-downgrade", undefined, failedResponse); + if ("failed" in result) { + if (result.failed !== failedResponse) return result.failed; + break recovery; + } + try { void failedResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } upstreamResponse = result; continue recovery; } diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index a312a6bfb43..75ef4828432 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1832,13 +1832,30 @@ export async function preparePassthroughExchange( rejectionText, }); if (downgrade && !configuredTransientSendBudgetExhausted()) { + const allowance = recoverySendAllowance( + transientSendAttempts(), + recoveryClassFor("reasoning-effort-downgrade"), + `${route.providerName}|${route.modelId}|reasoning-effort-downgrade`, + { allowFinalRecoveryReserve: transientSendPolicy() === null }, + ); + if (allowance.attempts < 1) { + allowance.permit?.release(); + return upstreamResponse; + } + sendBudgetState.pendingHopPermit = allowance.permit; reasoningEffortDowngradeGuard.attempted = true; parsed.options.reasoning = downgrade.effort; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - const result = await rebuildAndRefetch("reasoning-effort-downgrade"); - if ("failed" in result) return result.failed; - upstreamResponse = result; - continue passthroughRecovery; + try { + const result = await rebuildAndRefetch("reasoning-effort-downgrade"); + sendBudgetState.pendingHopPermit = undefined; + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue passthroughRecovery; + } finally { + if (sendBudgetState.pendingHopPermit === allowance.permit) sendBudgetState.pendingHopPermit = undefined; + allowance.permit?.release(); + } } } diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 2a3d7ca7f6a..64f7546ad55 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -524,6 +524,17 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureDecision(400, message, { code: "unsupported_value" })).toBe("hop"); expect(comboFailureCooldownScope(400, message, { code: "unsupported_value" })).toBe("none"); }); + test("exact reasoning effort refusal hops when wrapped in single-line data: prefix", () => { + const payload = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); + for (const msg of [ + payload, + `data: ${payload}`, + `Provider error 400: data: ${payload}`, + ]) { + expect(comboFailureDecision(400, msg)).toBe("hop"); + expect(comboFailureCooldownScope(400, msg)).toBe("none"); + } + }); }); describe("image rejection classifier bounds", () => { From 807a788bec4b2097994fac3357f030fcbd14e177 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:03:16 +0600 Subject: [PATCH 04/39] test(combo): cover unfunded downgrade send budget exhaustion and fallback hop --- ...sponses-reasoning-effort-downgrade.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 7cd9d709550..58f693a0779 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -277,5 +277,47 @@ describe("rejected reasoning rungs", () => { { host: "fallback.example", effort: "max" }, ]); }); + + test(`unfunded downgrade stops walking and hops to fallback when send budget exhausts (${adapter})`, async () => { + // With 4+ rungs (minimal, low, medium, high, max), walking the full ladder would need 5 sends. + // With COMBO_TARGET_BASE_SENDS = 3, per-target sends exhaust after 3 sends to opencode.ai. + // The unfunded downgrade returns the 400, and the combo hops to the fallback target. + writeSnapshot(["minimal", "low", "medium", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return url.host === "fallback.example" + ? chatSuccess("fallback served the turn", MODEL) + : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("fallback served the turn"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + { host: "opencode.ai", effort: "medium" }, + { host: "opencode.ai", effort: "low" }, + { host: "fallback.example", effort: "max" }, + ]); + }); } }); From 0077d86373df934f100bc554a28c1693151add47 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:54:55 +0600 Subject: [PATCH 05/39] fix(responses): update passthrough rawBody reasoning effort on downgrade retry and assert target cooldown --- src/server/responses/passthrough-dispatch.ts | 12 ++++ ...sponses-reasoning-effort-downgrade.test.ts | 62 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index a6913c98a8e..70346a60635 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1,3 +1,4 @@ +import { isPlainObject } from "../../lib/plain-data"; import { clientFacingModelText } from "../../lib/error-destination"; import { markProxyError } from "../../lib/client-error-body"; import { CodexInstallationIdUnavailableError } from "../../codex/installation-id"; @@ -1860,6 +1861,17 @@ export async function preparePassthroughExchange( sendBudgetState.pendingHopPermit = allowance.permit; reasoningEffortDowngradeGuard.attempted = true; parsed.options.reasoning = downgrade.effort; + if (isPlainObject(parsed._rawBody)) { + const rawBody = parsed._rawBody as Record; + const rawReasoning = isPlainObject(rawBody.reasoning) + ? { ...(rawBody.reasoning as Record), effort: downgrade.effort } + : { effort: downgrade.effort }; + parsed._rawBody = { + ...rawBody, + reasoning: rawReasoning, + ...("reasoning_effort" in rawBody ? { reasoning_effort: downgrade.effort } : {}), + }; + } try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } try { const result = await rebuildAndRefetch("reasoning-effort-downgrade"); diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 58f693a0779..607169517cc 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses } from "../../src/server/responses/core"; -import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCooldown } from "../../src/combos"; import { resetReasoningMetadataCachesForTests } from "../../src/providers/reasoning-metadata"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; @@ -311,6 +311,7 @@ describe("rejected reasoning rungs", () => { expect(response.status).toBe(200); expect(result).toContain("fallback served the turn"); + expect(isComboTargetInCooldown("effort", { provider: "first", model: MODEL })).toBe(false); expect(attempts).toEqual([ { host: "opencode.ai", effort: "max" }, { host: "opencode.ai", effort: "high" }, @@ -320,4 +321,63 @@ describe("rejected reasoning rungs", () => { ]); }); } + + test("passthrough retry applies downgraded effort for custom destination outside metadata list", async () => { + const CUSTOM_MODEL = "custom-external-reasoner"; + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("custom destination served high effort", CUSTOM_MODEL); + }) as typeof fetch; + const comboConfig = { + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://custom.example/v1", + authMode: "key", + apiKey: "custom-key", + reasoningEfforts: ["low", "high", "max"], + }, + fallback: { + adapter: "openai-chat", + baseUrl: "https://fallback.example/v1", + authMode: "key", + apiKey: "fallback-key", + }, + }, + combos: { + custom_effort: { + strategy: "failover", + targets: [{ provider: "custom", model: CUSTOM_MODEL }, { provider: "fallback", model: CUSTOM_MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "combo/custom_effort", + store: false, + reasoning: { effort: "max" }, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }], + }), + }); + + const response = await handleResponses(req, comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("custom destination served high effort"); + expect(attempts).toEqual([ + { host: "custom.example", effort: "max" }, + { host: "custom.example", effort: "high" }, + ]); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + }); }); From 670df5b56a8352cedb383103eb9a2541958a9548 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:17:23 +0900 Subject: [PATCH 06/39] fix(combo): keep effort ladder retries inside dispatch caps and budget contracts Exact-head CI at 0077d863 failed four checks on the merge with dev: adapter-dispatch.ts (2001) and passthrough-dispatch.ts (2028) crossed the 2,000-line owner limit, the passthrough downgrade leg reserved a recovery allowance twice, and a direct request with a configured total of 1 returned the upstream 400 instead of the budget 429. - Move refusal planning (refusedReasoningEffortDowngrade) and the passthrough raw-body rung rewrite (withRawBodyReasoningEffort) into core-opaque-recovery.ts; both dispatch files shrink below dev. - Keep the original refusal only for combo attempts, so an unfunded combo rung still reaches combo failover as the 400 while direct requests keep the existing exhaustion response. - Passthrough checks rung funding inside rebuildAndRefetch, so the leg takes exactly one recoverySendAllowance. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- src/server/responses/adapter-dispatch.ts | 48 +++++-------- src/server/responses/core-opaque-recovery.ts | 32 ++++++++- src/server/responses/passthrough-dispatch.ts | 72 +++++--------------- 3 files changed, 67 insertions(+), 85 deletions(-) diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 8a47ec96448..91d92faf19f 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -96,7 +96,7 @@ import { attemptOpaqueBlobRecovery, consoleGoUploadRejectionBody, CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, - reasoningEffortRejectionText, + refusedReasoningEffortDowngrade, anthropicFastRefused, } from "./core-opaque-recovery"; import { createAnthropicThinkingRecovery } from "./anthropic-thinking-recovery"; @@ -105,7 +105,6 @@ import { isTransientConsoleGoUploadRejection, enrichOpenCodeZenUpstreamMessage, } from "../../providers/opencode-zen-rate-limit"; -import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { consumeComboFailure } from "./core-combo-failure"; import { streamingContextOverflowResponse, jsonContextOverflowResponse, lateAttributionLimitResponse } from "./context-overflow"; import { OllamaLateAttributionBudgetError } from "../../adapters/ollama-native"; @@ -1831,35 +1830,24 @@ export async function prepareAdapterExchange( } // Learn a refused rung and retry at the next published one. Direct requests are limited // to one repair; combo candidates may continue down the finite ladder within the send budget. - if (options.comboAttempt || !reasoningEffortDowngradeGuard.attempted) { - const rejectionText = await reasoningEffortRejectionText( - upstreamResponse, - false, - upstream.signal, - ); - const downgrade = rejectionText === undefined - ? undefined - : planReasoningEffortDowngrade({ - provider: route.provider, - modelId: parsed.modelId, - requested: parsed.options.reasoning, - rejectionText, - }); - if (downgrade) { - reasoningEffortDowngradeGuard.attempted = true; - parsed.options.reasoning = downgrade.effort; - // The same-target cache keys on parsed identity, so a mutated effort needs a token bump. - invalidateSameTargetRequest(); - const failedResponse = await retainFailureResponse(); - const result = await rebuildAndRefetch("reasoning-effort-downgrade", undefined, failedResponse); - if ("failed" in result) { - if (result.failed !== failedResponse) return result.failed; - break recovery; - } - try { void failedResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - upstreamResponse = result; - continue recovery; + const downgrade = await refusedReasoningEffortDowngrade(upstreamResponse, upstream.signal, reasoningEffortDowngradeGuard, + options.comboAttempt, { provider: route.provider, modelId: parsed.modelId, requested: parsed.options.reasoning }); + if (downgrade) { + reasoningEffortDowngradeGuard.attempted = true; + parsed.options.reasoning = downgrade.effort; + // The same-target cache keys on parsed identity, so a mutated effort needs a token bump. + invalidateSameTargetRequest(); + // A combo keeps the refusal, so an unfunded rung still reaches combo failover as the original 400. + const refusal = options.comboAttempt ? await retainFailureResponse() : undefined; + if (!refusal) try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuildAndRefetch("reasoning-effort-downgrade", undefined, refusal); + if ("failed" in result) { + if (result.failed !== refusal) return result.failed; + break recovery; } + if (refusal) try { void refusal.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + upstreamResponse = result; + continue recovery; } break; } diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 0a829ebee94..3e6e348cce6 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -1,6 +1,7 @@ import { ENCRYPTED_FUNCTION_OUTPUT_REJECTION, upstreamErrorMessageFromPayload } from "../../lib/errors"; import { readBoundedResponseBody } from "../../lib/bounded-body"; -import { isReasoningEffortRejection } from "../../providers/reasoning-metadata"; +import { isPlainObject } from "../../lib/plain-data"; +import { isReasoningEffortRejection, planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { isAnthropicFastRefusal } from "../../providers/anthropic-fast"; import type { AdapterRequest } from "../../adapters/base"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; @@ -64,6 +65,35 @@ export function outboundResponsesInput(bodyText: string | undefined): unknown[] } } +/** + * The next published rung after an upstream reasoning-effort refusal, or undefined when no replay + * applies. A direct request repairs once. A combo candidate may walk the finite ladder one refused + * rung at a time: each refusal is learned, so a rung is never offered twice, and the request send + * budget bounds the walk (#8173). The caller sets `guard.attempted` when it actually replays. + */ +export async function refusedReasoningEffortDowngrade( + response: Response, + signal: AbortSignal, + guard: { attempted: boolean }, + comboAttempt: boolean | undefined, + plan: Omit[0], "rejectionText">, +): Promise> { + if (guard.attempted && !comboAttempt) return undefined; + const rejectionText = await reasoningEffortRejectionText(response, false, signal); + return rejectionText === undefined ? undefined : planReasoningEffortDowngrade({ ...plan, rejectionText }); +} + +/** + * The passthrough rebuild serializes `_rawBody`, so a downgraded rung has to be written there as + * well or the replay resends the refused effort. `reasoning_effort` is kept in step only when the + * caller sent it. + */ +export function withRawBodyReasoningEffort(rawBody: unknown, effort: string): unknown { + if (!isPlainObject(rawBody)) return rawBody; + const reasoning = isPlainObject(rawBody.reasoning) ? { ...rawBody.reasoning, effort } : { effort }; + return { ...rawBody, reasoning, ...("reasoning_effort" in rawBody ? { reasoning_effort: effort } : {}) }; +} + export function outboundResponsesBodyCarriesEncryptedFunctionOutput(bodyText: string | undefined): boolean { const input = outboundResponsesInput(bodyText); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 70346a60635..c4288821bae 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1,4 +1,3 @@ -import { isPlainObject } from "../../lib/plain-data"; import { clientFacingModelText } from "../../lib/error-destination"; import { markProxyError } from "../../lib/client-error-body"; import { CodexInstallationIdUnavailableError } from "../../codex/installation-id"; @@ -166,7 +165,7 @@ import { resetStreamedOpaqueBlobLogContext, consoleGoUploadRejectionBody, CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, - reasoningEffortRejectionText, + refusedReasoningEffortDowngrade, withRawBodyReasoningEffort, } from "./core-opaque-recovery"; import type { RequestLogContext } from "../request-log"; import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight"; @@ -174,7 +173,6 @@ import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit"; -import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { createNativeContinuationDispatch } from "./native-continuation-send"; import { guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion"; @@ -1099,6 +1097,7 @@ export async function preparePassthroughExchange( let rateLimitRetries = 0; const rebuildAndRefetch = async ( recovery: AttemptRecoveryKind, + preserveFailureResponse?: Response, ): Promise => { const retryAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy), @@ -1149,6 +1148,8 @@ export async function preparePassthroughExchange( { allowFinalRecoveryReserve: transientSendPolicy() === null }, ); if (recovery === "empty-completion") allowance.attempts = Math.min(1, allowance.attempts); + // An unfunded combo rung keeps the upstream refusal for combo failover instead of a budget 429. + if (preserveFailureResponse && allowance.attempts < 1) { request.releaseBodyObservation?.(); return { failed: preserveFailureResponse }; } const recoveryAccounting = copilotSendAccounting(allowance.permit); try { return await fetchWithTransientRetry( @@ -1833,57 +1834,20 @@ export async function preparePassthroughExchange( // model's ladder, not this account's entitlement. Learn each refusal so later turns clamp // before dispatch. Direct requests repair once; combo candidates may retry the next lower // published rung until it works, the ladder ends, or the send budget is spent. - if (options.comboAttempt || !reasoningEffortDowngradeGuard.attempted) { - const rejectionText = await reasoningEffortRejectionText( - upstreamResponse, - false, - upstream.signal, - ); - const downgrade = rejectionText === undefined - ? undefined - : planReasoningEffortDowngrade({ - provider: route.provider, - modelId: parsed.modelId, - requested: parsed.options.reasoning, - rejectionText, - }); - if (downgrade && !configuredTransientSendBudgetExhausted()) { - const allowance = recoverySendAllowance( - transientSendAttempts(), - recoveryClassFor("reasoning-effort-downgrade"), - `${route.providerName}|${route.modelId}|reasoning-effort-downgrade`, - { allowFinalRecoveryReserve: transientSendPolicy() === null }, - ); - if (allowance.attempts < 1) { - allowance.permit?.release(); - return upstreamResponse; - } - sendBudgetState.pendingHopPermit = allowance.permit; - reasoningEffortDowngradeGuard.attempted = true; - parsed.options.reasoning = downgrade.effort; - if (isPlainObject(parsed._rawBody)) { - const rawBody = parsed._rawBody as Record; - const rawReasoning = isPlainObject(rawBody.reasoning) - ? { ...(rawBody.reasoning as Record), effort: downgrade.effort } - : { effort: downgrade.effort }; - parsed._rawBody = { - ...rawBody, - reasoning: rawReasoning, - ...("reasoning_effort" in rawBody ? { reasoning_effort: downgrade.effort } : {}), - }; - } - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - try { - const result = await rebuildAndRefetch("reasoning-effort-downgrade"); - sendBudgetState.pendingHopPermit = undefined; - if ("failed" in result) return result.failed; - upstreamResponse = result; - continue passthroughRecovery; - } finally { - if (sendBudgetState.pendingHopPermit === allowance.permit) sendBudgetState.pendingHopPermit = undefined; - allowance.permit?.release(); - } - } + const downgrade = await refusedReasoningEffortDowngrade(upstreamResponse, upstream.signal, reasoningEffortDowngradeGuard, + options.comboAttempt, { provider: route.provider, modelId: parsed.modelId, requested: parsed.options.reasoning }); + if (downgrade && !configuredTransientSendBudgetExhausted()) { + reasoningEffortDowngradeGuard.attempted = true; + parsed.options.reasoning = downgrade.effort; + parsed._rawBody = withRawBodyReasoningEffort(parsed._rawBody, downgrade.effort); + // A combo keeps the refusal, so an unfunded rung still reaches combo failover as the original 400. + const refusal = options.comboAttempt ? upstreamResponse : undefined; + if (!refusal) try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuildAndRefetch("reasoning-effort-downgrade", refusal); + if ("failed" in result) return result.failed; + if (refusal) try { void refusal.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + upstreamResponse = result; + continue passthroughRecovery; } // The WebSocket row of the same table (#4191). A Codex socket that closed or failed under its From f5f27edb01514357e9f7445f419baea6ac7032f7 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:03:04 +0900 Subject: [PATCH 07/39] test(combo): pin the unfunded effort rung on a real five-rung ladder The unfunded-downgrade cases used "minimal", which is not a ranked rung, so the walk ended when the ladder ran out and the kept-refusal path was never reached. Use low..max so the fifth rung is unfunded, and add a variant where the fallback also refuses: the client must get the original 400 (no send-budget 429) and the first target must not cool. Mutation check: dropping the kept refusal in both dispatchers fails all four cases. The combos guide now says the ladder walk applies to any recognized effort refusal while the hop needs one of the listed shapes. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- docs-site/src/content/docs/guides/combos.md | 2 +- ...sponses-reasoning-effort-downgrade.test.ts | 27 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 27f9eeb7d91..6f68834bef4 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -810,7 +810,7 @@ instead of growing memory without a bound. When routing Claude Code to the canonical ChatGPT Codex backend, OpenCodex removes the unsupported top-level `user` metadata field without changing the session/cache key, input messages, tool schemas, or safety identifiers. Public Responses API and noncanonical forward gateways keep that field. -A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection. For the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}`, it first retries the same target at the next lower published effort rung, one rung per refusal and within the shared send budget. If no lower rung succeeds, the combo advances to another target without cooling the rejected one. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. These are mismatches for this request, not evidence that the target is unhealthy. Direct requests retain their single effort-downgrade repair. Compatibility recovery does not silently change `none` into a different effort or broaden the combo hop to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection. +A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection, and after the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}`. When a combo target refuses the requested reasoning effort, the combo first retries that target at the next lower published effort rung, one rung per refusal and within the shared send budget; this applies to any recognized effort refusal. If no lower rung succeeds, a refusal in one of the shapes above advances to another target without cooling the rejected one, keeping that target's original 400 when the next rung has no send left; other effort refusals stay terminal. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. These are mismatches for this request, not evidence that the target is unhealthy. Direct requests retain their single effort-downgrade repair. Compatibility recovery does not silently change `none` into a different effort or broaden the combo hop to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection. ## Tool calls stay on one target diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 607169517cc..4c5b0fe9466 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -278,17 +278,20 @@ describe("rejected reasoning rungs", () => { ]); }); - test(`unfunded downgrade stops walking and hops to fallback when send budget exhausts (${adapter})`, async () => { - // With 4+ rungs (minimal, low, medium, high, max), walking the full ladder would need 5 sends. - // With COMBO_TARGET_BASE_SENDS = 3, per-target sends exhaust after 3 sends to opencode.ai. - // The unfunded downgrade returns the 400, and the combo hops to the fallback target. - writeSnapshot(["minimal", "low", "medium", "high", "max"]); + test.each([ + ["serves the turn", true], + ["also refuses", false], + ])(`unfunded downgrade keeps the refusal and hops to fallback, which %s (${adapter})`, async (_label, fallbackServes) => { + // Five ranked rungs need five sends to walk, but the first target's send scope holds one back + // for the declared fallback, so the fifth rung (low) is unfunded. The combo must keep that + // target's original 400 -- no send-budget 429 and no cooldown -- and hop to the fallback. + writeSnapshot(["low", "medium", "high", "xhigh", "max"]); const attempts: Array<{ host: string; effort: unknown }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); const body = JSON.parse(String(init?.body)) as Record; attempts.push({ host: url.host, effort: effortOf(body) }); - return url.host === "fallback.example" + return url.host === "fallback.example" && fallbackServes ? chatSuccess("fallback served the turn", MODEL) : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); }) as typeof fetch; @@ -309,14 +312,20 @@ describe("rejected reasoning rungs", () => { const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); const result = await response.text(); - expect(response.status).toBe(200); - expect(result).toContain("fallback served the turn"); + if (fallbackServes) { + expect(response.status, result).toBe(200); + expect(result).toContain("fallback served the turn"); + } else { + expect(response.status, result).toBe(400); + expect(result).toContain("Invalid reasoning effort."); + expect(result).not.toContain("request_send_budget_exhausted"); + } expect(isComboTargetInCooldown("effort", { provider: "first", model: MODEL })).toBe(false); expect(attempts).toEqual([ { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "xhigh" }, { host: "opencode.ai", effort: "high" }, { host: "opencode.ai", effort: "medium" }, - { host: "opencode.ai", effort: "low" }, { host: "fallback.example", effort: "max" }, ]); }); From 4b3660b96651d48550bcc1095342c86677a97ce3 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:19:18 +0600 Subject: [PATCH 08/39] fix(combo): stop effort downgrade on policy refusal and reject malformed codes --- src/combos/failover.ts | 13 ++- src/lib/hard-refusal.ts | 91 +++++++++++++++++++ src/providers/reasoning-metadata.ts | 4 + src/server/responses/core-opaque-recovery.ts | 8 +- ...sponses-reasoning-effort-downgrade.test.ts | 85 ++++++++++++++++- ...uter-combo-failover-classification.test.ts | 16 ++++ 6 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 src/lib/hard-refusal.ts diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 9dccf0f9f2b..f1766509c8a 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -1,5 +1,6 @@ import { parseResetCooldownMs } from "../codex/routing"; import { classifyError, isCyberPolicyCode } from "../lib/errors"; +import { isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; import { isNonReplayableUpstreamCode } from "../lib/upstream-retry"; import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal"; import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal"; @@ -389,8 +390,10 @@ function normalizedFailureCode(code?: string | null): string { */ function isExactInvalidReasoningEffortRefusal(status: number, message: string, code?: string | null): boolean { if (status !== 400 || message.length > 16_384) return false; + if (code !== undefined && code !== null && typeof code !== "string") return false; const outerCode = normalizedFailureCode(code); if (!["", "invalid_request_error", "invalid_argument"].includes(outerCode)) return false; + if (isHardPolicyOrOriginRefusal(message)) return false; let text = message.trim(); for (let depth = 0; depth < 3; depth += 1) { if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length).trim(); @@ -399,14 +402,20 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c try { payload = JSON.parse(text); } catch { return false; } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; const root = payload as Record; - if (normalizedFailureCode(typeof root.code === "string" ? root.code : undefined) === "invalid_argument" + if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; + if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; + if (root.param !== undefined && root.param !== null && typeof root.param !== "string") return false; + if (normalizedFailureCode(root.code as string | undefined) === "invalid_argument" && root.error === "Invalid reasoning effort." && (root.type === undefined || root.type === "invalid_request_error") && (root.param === undefined || root.param === "reasoning_effort" || root.param === "reasoning.effort")) return true; const error = root.error; if (!error || typeof error !== "object" || Array.isArray(error)) return false; const record = error as Record; - const errorCode = normalizedFailureCode(typeof record.code === "string" ? record.code : undefined); + if (record.code !== undefined && record.code !== null && typeof record.code !== "string") return false; + if (record.type !== undefined && record.type !== null && typeof record.type !== "string") return false; + if (record.param !== undefined && record.param !== null && typeof record.param !== "string") return false; + const errorCode = normalizedFailureCode(record.code as string | undefined); if (record.type === "upstream_error" && typeof record.message === "string" && record.message.startsWith("Provider error 400: ") && ["", "invalid_request_error", "invalid_argument"].includes(errorCode)) { diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts new file mode 100644 index 00000000000..716c785235b --- /dev/null +++ b/src/lib/hard-refusal.ts @@ -0,0 +1,91 @@ +import { + isCyberPolicyCode, + isCyberPolicyMessage, + isTerminalRefusalCode, + safetyRefusalCodeFromMessage, +} from "./errors"; +import { isNonReplayableUpstreamCode } from "./upstream-retry"; + +/** + * Detect a hard policy or origin refusal across known upstream envelope shapes and diagnostic text. + * When an error is a hard refusal (e.g. cyber_policy, origin_rejected, bio_policy), recovery must + * stop immediately without retrying or walking any parameter ladders (#8173). + */ +export function isHardPolicyOrOriginRefusal(text: string | undefined): boolean { + if (!text) return false; + if (isCyberPolicyMessage(text)) return true; + if (/origin_rejected/i.test(text)) return true; + + let unescaped = text.trim(); + for (let depth = 0; depth < 3; depth += 1) { + if (unescaped.startsWith("Provider error 400: ")) { + unescaped = unescaped.slice("Provider error 400: ".length).trim(); + } + if (unescaped.startsWith("data:") && !unescaped.includes("\n")) { + unescaped = unescaped.slice("data:".length).trim(); + } + } + + try { + const parsed = JSON.parse(unescaped) as Record; + const candidates = [ + parsed.error, + (parsed.response as Record | undefined)?.error, + (parsed.response as Record | undefined)?.last_error, + parsed.last_error, + parsed, + ]; + + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const rec = candidate as Record; + + const code = typeof rec.code === "string" ? rec.code.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (code) { + if ( + isCyberPolicyCode(code) + || code === "origin_rejected" + || isTerminalRefusalCode(code) + || isNonReplayableUpstreamCode(code) + ) { + return true; + } + } + + const type = typeof rec.type === "string" ? rec.type.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (type) { + if ( + isCyberPolicyCode(type) + || type === "origin_rejected" + || isTerminalRefusalCode(type) + ) { + return true; + } + } + + if (typeof rec.message === "string") { + if ( + isCyberPolicyMessage(rec.message) + || /origin_rejected/i.test(rec.message) + || safetyRefusalCodeFromMessage(rec.message) + ) { + return true; + } + } + + if (typeof rec.error === "string") { + if ( + isCyberPolicyMessage(rec.error) + || /origin_rejected/i.test(rec.error) + || safetyRefusalCodeFromMessage(rec.error) + ) { + return true; + } + } + } + } catch { + /* non-JSON payload handled by text matches above */ + } + + return false; +} diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 6c3cef10005..3f80c7147ec 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -26,6 +26,8 @@ import { join } from "node:path"; // and leaves COMBO_NAMESPACE in its temporal dead zone for entry points that start at combos/types.ts. import { atomicWriteFile } from "../config/atomic-write"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; +import { isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; +export { isHardPolicyOrOriginRefusal }; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; @@ -455,6 +457,7 @@ const GENERIC_ERROR_TYPE = /invalid_request_error/gi; */ export function isReasoningEffortRejection(text: string | undefined): boolean { if (!text) return false; + if (isHardPolicyOrOriginRefusal(text)) return false; if (/unsupported.{0,24}effort/i.test(text)) return true; // An upstream that names the offending parameter has already said which one it means. if (/["']?param["']?\s*[:=]\s*["']?(?:reasoning[._ ]effort|reasoning)/i.test(text)) return true; @@ -478,6 +481,7 @@ export function planReasoningEffortDowngrade(args: { requested?: string; rejectionText?: string; }): { effort: string; recorded: boolean } | undefined { + if (args.rejectionText && isHardPolicyOrOriginRefusal(args.rejectionText)) return undefined; const requested = args.requested === "ultra" ? "max" : args.requested; if (!requested || !RANKED.includes(requested)) return undefined; const recorded = recordUnsupportedReasoningEffort(args.provider, args.modelId, requested, args.rejectionText); diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 3e6e348cce6..0172f08fdd2 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -1,7 +1,7 @@ import { ENCRYPTED_FUNCTION_OUTPUT_REJECTION, upstreamErrorMessageFromPayload } from "../../lib/errors"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { isPlainObject } from "../../lib/plain-data"; -import { isReasoningEffortRejection, planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; +import { isHardPolicyOrOriginRefusal, isReasoningEffortRejection, planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { isAnthropicFastRefusal } from "../../providers/anthropic-fast"; import type { AdapterRequest } from "../../adapters/base"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; @@ -80,7 +80,9 @@ export async function refusedReasoningEffortDowngrade( ): Promise> { if (guard.attempted && !comboAttempt) return undefined; const rejectionText = await reasoningEffortRejectionText(response, false, signal); - return rejectionText === undefined ? undefined : planReasoningEffortDowngrade({ ...plan, rejectionText }); + return rejectionText === undefined || isHardPolicyOrOriginRefusal(rejectionText) + ? undefined + : planReasoningEffortDowngrade({ ...plan, rejectionText }); } /** @@ -395,7 +397,7 @@ export async function reasoningEffortRejectionText( if (response.status !== 400 && response.status !== 403) return undefined; try { const body = await readBoundedResponseBody(response.clone(), { signal }); - if (!body.displaySafe || body.truncated) return undefined; + if (!body.displaySafe || body.truncated || isHardPolicyOrOriginRefusal(body.text)) return undefined; return isReasoningEffortRejection(body.text) ? body.text : undefined; } catch { return undefined; diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 4c5b0fe9466..92566ec9c2d 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses } from "../../src/server/responses/core"; import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCooldown } from "../../src/combos"; -import { resetReasoningMetadataCachesForTests } from "../../src/providers/reasoning-metadata"; +import { isReasoningEffortLearnedUnsupported, resetReasoningMetadataCachesForTests } from "../../src/providers/reasoning-metadata"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { chatSuccess } from "../helpers/combo-failover-upstream"; @@ -27,6 +27,14 @@ const REFUSAL = JSON.stringify({ message: "Error from provider (Console Go): Upstream request failed: [invalid_request_error] reasoning_effort max requires an active Muse Code subscription for model muse-spark-1.3-contributor.", }, }); +const POLICY_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + code: "cyber_policy", + param: "reasoning_effort", + message: "Request blocked by upstream cybersecurity policy.", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -389,4 +397,79 @@ describe("rejected reasoning rungs", () => { ]); expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); }); + + test("stops before walking effort ladder on policy refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before walking effort ladder on policy refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("combo stops immediately on policy refusal carrying reasoning_effort without walking ladder", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("cyber_policy"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 64f7546ad55..f7c98ec70dd 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -535,6 +535,22 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureCooldownScope(400, msg)).toBe("none"); } }); + test("rejects malformed error codes before granting a combo hop for reasoning effort", () => { + for (const malformedCode of [42, true, {}, []]) { + const payloadWithCode = JSON.stringify({ + error: { type: "invalid_request_error", code: malformedCode, message: "Invalid reasoning effort." }, + }); + expect(comboFailureDecision(400, payloadWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, payloadWithCode)).toBe("target"); + + const rootWithCode = JSON.stringify({ + code: malformedCode, + error: "Invalid reasoning effort.", + }); + expect(comboFailureDecision(400, rootWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, rootWithCode)).toBe("target"); + } + }); }); describe("image rejection classifier bounds", () => { From d05fd2e52a9f01730e81be452046783d71d43221 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:39:31 +0600 Subject: [PATCH 09/39] fix(combo): parse nested refusals before effort retry and reject conflicting outer codes --- src/combos/failover.ts | 32 +++++++++--- src/lib/hard-refusal.ts | 41 ++++++++-------- ...sponses-reasoning-effort-downgrade.test.ts | 49 +++++++++++++++++++ ...uter-combo-failover-classification.test.ts | 16 ++++++ 4 files changed, 110 insertions(+), 28 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index f1766509c8a..0aa8be0308c 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -388,11 +388,14 @@ function normalizedFailureCode(code?: string | null): string { * evidence for a combo: the same model may accept a lower rung, or a later target may accept the * request. Keep the match exact so unrelated invalid arguments stay terminal. */ +const EXACT_EFFORT_COMPATIBLE_CODES = new Set(["", "invalid_request_error", "invalid_argument"]); +const EXACT_EFFORT_COMPATIBLE_TYPES = new Set(["", "invalid_request_error", "upstream_error"]); + function isExactInvalidReasoningEffortRefusal(status: number, message: string, code?: string | null): boolean { if (status !== 400 || message.length > 16_384) return false; if (code !== undefined && code !== null && typeof code !== "string") return false; const outerCode = normalizedFailureCode(code); - if (!["", "invalid_request_error", "invalid_argument"].includes(outerCode)) return false; + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(outerCode)) return false; if (isHardPolicyOrOriginRefusal(message)) return false; let text = message.trim(); for (let depth = 0; depth < 3; depth += 1) { @@ -405,7 +408,11 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; if (root.param !== undefined && root.param !== null && typeof root.param !== "string") return false; - if (normalizedFailureCode(root.code as string | undefined) === "invalid_argument" + const rootCode = normalizedFailureCode(root.code as string | undefined); + const rootType = normalizedFailureCode(root.type as string | undefined); + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(rootCode)) return false; + if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(rootType)) return false; + if (rootCode === "invalid_argument" && root.error === "Invalid reasoning effort." && (root.type === undefined || root.type === "invalid_request_error") && (root.param === undefined || root.param === "reasoning_effort" || root.param === "reasoning.effort")) return true; @@ -416,15 +423,18 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (record.type !== undefined && record.type !== null && typeof record.type !== "string") return false; if (record.param !== undefined && record.param !== null && typeof record.param !== "string") return false; const errorCode = normalizedFailureCode(record.code as string | undefined); + const errorType = normalizedFailureCode(record.type as string | undefined); + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode)) return false; + if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(errorType)) return false; if (record.type === "upstream_error" && typeof record.message === "string" && record.message.startsWith("Provider error 400: ") - && ["", "invalid_request_error", "invalid_argument"].includes(errorCode)) { + && EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode)) { text = record.message; continue; } return record.type === "invalid_request_error" && record.message === "Invalid reasoning effort." - && ["", "invalid_request_error", "invalid_argument"].includes(errorCode) + && EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode) && (record.param === undefined || record.param === "reasoning_effort" || record.param === "reasoning.effort"); } return false; @@ -567,7 +577,13 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co let payload: unknown; try { payload = JSON.parse(text); } catch { return false; } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; - const error = (payload as Record).error; + const root = payload as Record; + if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; + if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; + const rootCode = normalizedFailureCode(root.code as string | undefined); + if (!genericCodes.has(rootCode)) return false; + if (root.type !== undefined && root.type !== null && root.type !== "invalid_request_error" && root.type !== "upstream_error") return false; + const error = root.error; if (!error || typeof error !== "object" || Array.isArray(error)) return false; const e = error as Record; if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false; @@ -942,9 +958,9 @@ export function comboFailureDecision( if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } - // Quota prose inside a request-validation status (400/422) is not authoritative without a - // structured code: the body can quote prompt or parameter text back (#7625). - if (error.code === "insufficient_quota" && (status === 400 || status === 422)) return "stop"; + // Quota or permission prose inside a request-validation status (400/422) is not authoritative without a + // structured code: the body can quote prompt or parameter text back (#7625, #8173). + if (["insufficient_quota", "permission_denied"].includes(error.code ?? "") && (status === 400 || status === 422)) return "stop"; // 402 (payment required) and 425 (too early) are provider-state signals, not verdicts about // the request: another combo target can still serve it. if ([401, 402, 403, 404, 408, 425, 429].includes(status) || status >= 500) return "hop"; diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index 716c785235b..e10395f30cd 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -6,18 +6,25 @@ import { } from "./errors"; import { isNonReplayableUpstreamCode } from "./upstream-retry"; +const SERIALIZED_HARD_CODE = + /\bcode\s*[:=]\s*["']?(?:cyber_policy|origin_rejected|bio_policy|misalignment_policy_violation|invalid_prompt|upstream_no_response|upstream_closed_before_response|upstream_reset_replay_refused)\b/i; + /** * Detect a hard policy or origin refusal across known upstream envelope shapes and diagnostic text. * When an error is a hard refusal (e.g. cyber_policy, origin_rejected, bio_policy), recovery must * stop immediately without retrying or walking any parameter ladders (#8173). + * + * Traverses bounded nested wrappers (e.g. Provider error 400, data:, nested error JSON) so wrapped + * policy refusals are recognized rather than treated as prose. */ -export function isHardPolicyOrOriginRefusal(text: string | undefined): boolean { - if (!text) return false; +export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0): boolean { + if (!text || depth > 4) return false; if (isCyberPolicyMessage(text)) return true; if (/origin_rejected/i.test(text)) return true; + if (SERIALIZED_HARD_CODE.test(text)) return true; let unescaped = text.trim(); - for (let depth = 0; depth < 3; depth += 1) { + for (let d = 0; d < 3; d += 1) { if (unescaped.startsWith("Provider error 400: ")) { unescaped = unescaped.slice("Provider error 400: ".length).trim(); } @@ -63,23 +70,17 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined): boolean { } } - if (typeof rec.message === "string") { - if ( - isCyberPolicyMessage(rec.message) - || /origin_rejected/i.test(rec.message) - || safetyRefusalCodeFromMessage(rec.message) - ) { - return true; - } - } - - if (typeof rec.error === "string") { - if ( - isCyberPolicyMessage(rec.error) - || /origin_rejected/i.test(rec.error) - || safetyRefusalCodeFromMessage(rec.error) - ) { - return true; + for (const field of ["message", "error", "details", "body", "reason"] as const) { + const val = rec[field]; + if (typeof val === "string") { + if ( + isCyberPolicyMessage(val) + || /origin_rejected/i.test(val) + || safetyRefusalCodeFromMessage(val) + || isHardPolicyOrOriginRefusal(val, depth + 1) + ) { + return true; + } } } } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 92566ec9c2d..8e980861dc0 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -35,6 +35,13 @@ const POLICY_REFUSAL = JSON.stringify({ message: "Request blocked by upstream cybersecurity policy.", }, }); +const NESTED_POLICY_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: 'Provider error 400: {"error":{"code":"bio_policy","message":"Request refused"}}', + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -472,4 +479,46 @@ describe("rejected reasoning rungs", () => { { host: "opencode.ai", effort: "max" }, ]); }); + + test("stops before walking effort ladder on nested structured refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before walking effort ladder on nested structured refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index f7c98ec70dd..d59a08e3fac 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -551,6 +551,22 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureCooldownScope(400, rootWithCode)).toBe("target"); } }); + test("rejects conflicting root and nested codes before granting a combo hop for reasoning effort", () => { + for (const conflictingCode of ["permission_denied", "context_length_exceeded", "origin_rejected", "unknown_terminal_code"]) { + const payloadWithCode = JSON.stringify({ + error: { type: "invalid_request_error", code: conflictingCode, message: "Invalid reasoning effort." }, + }); + expect(comboFailureDecision(400, payloadWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, payloadWithCode)).toBe("target"); + + const rootWithCode = JSON.stringify({ + code: conflictingCode, + error: { type: "invalid_request_error", message: "Invalid reasoning effort." }, + }); + expect(comboFailureDecision(400, rootWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, rootWithCode)).toBe("target"); + } + }); }); describe("image rejection classifier bounds", () => { From a9faecae35c05b96043f4201dcf2eb47b9d522d0 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:48:05 +0600 Subject: [PATCH 10/39] fix(combo): pin root code classification against dev and verify combo stops on nested refusal --- src/combos/failover.ts | 6 ++-- ...sponses-reasoning-effort-downgrade.test.ts | 33 +++++++++++++++++++ ...uter-combo-failover-classification.test.ts | 12 ++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 0aa8be0308c..472153b482c 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -958,9 +958,9 @@ export function comboFailureDecision( if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } - // Quota or permission prose inside a request-validation status (400/422) is not authoritative without a - // structured code: the body can quote prompt or parameter text back (#7625, #8173). - if (["insufficient_quota", "permission_denied"].includes(error.code ?? "") && (status === 400 || status === 422)) return "stop"; + // Quota prose inside a request-validation status (400/422) is not authoritative without a + // structured code: the body can quote prompt or parameter text back (#7625). + if (error.code === "insufficient_quota" && (status === 400 || status === 422)) return "stop"; // 402 (payment required) and 425 (too early) are provider-state signals, not verdicts about // the request: another combo target can still serve it. if ([401, 402, 403, 404, 408, 425, 429].includes(status) || status >= 500) return "hop"; diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 8e980861dc0..5704cda1278 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -521,4 +521,37 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + + test("combo stops immediately on nested policy refusal without walking ladder", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(NESTED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("bio_policy"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index d59a08e3fac..64a471f3238 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -552,7 +552,7 @@ describe("bounded optional-control error envelopes", () => { } }); test("rejects conflicting root and nested codes before granting a combo hop for reasoning effort", () => { - for (const conflictingCode of ["permission_denied", "context_length_exceeded", "origin_rejected", "unknown_terminal_code"]) { + for (const conflictingCode of ["context_length_exceeded", "origin_rejected", "unknown_terminal_code"]) { const payloadWithCode = JSON.stringify({ error: { type: "invalid_request_error", code: conflictingCode, message: "Invalid reasoning effort." }, }); @@ -567,6 +567,16 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureCooldownScope(400, rootWithCode)).toBe("target"); } }); + test("pins root code behavior against reasoning effort matcher (dev regression)", () => { + for (const code of ["insufficient_quota", "model_not_found", "rate_limit_exceeded"]) { + const body = JSON.stringify({ code, error: { type: "invalid_request_error", message: "Invalid reasoning effort." } }); + expect(comboFailureDecision(400, body)).toBe("stop"); + expect(comboFailureCooldownScope(400, body)).toBe("target"); + } + const permBody = JSON.stringify({ code: "permission_denied", error: { type: "invalid_request_error", message: "Invalid reasoning effort." } }); + expect(comboFailureDecision(400, permBody)).toBe("hop"); + expect(comboFailureCooldownScope(400, permBody)).toBe("target"); + }); }); describe("image rejection classifier bounds", () => { From 3ecc0d80f1c3470456a516e4a5134a9c1decabd0 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:53:46 +0600 Subject: [PATCH 11/39] fix(combo): traverse object envelopes for policy refusals and reject conflicting codes before replay --- src/lib/hard-refusal.ts | 22 +++-- src/providers/reasoning-metadata.ts | 72 +++++++++++++- src/server/responses/core-opaque-recovery.ts | 11 ++- ...sponses-reasoning-effort-downgrade.test.ts | 99 +++++++++++++++++++ 4 files changed, 194 insertions(+), 10 deletions(-) diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index e10395f30cd..2ff8a26f45b 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -35,16 +35,24 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) try { const parsed = JSON.parse(unescaped) as Record; - const candidates = [ + const queue: unknown[] = [ + parsed, parsed.error, (parsed.response as Record | undefined)?.error, (parsed.response as Record | undefined)?.last_error, parsed.last_error, - parsed, ]; - - for (const candidate of candidates) { - if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + let examined = 0; + while (queue.length > 0 && examined < 32) { + const candidate = queue.shift(); + examined += 1; + if (!candidate || typeof candidate !== "object") continue; + if (Array.isArray(candidate)) { + for (const item of candidate) { + if (item && typeof item === "object") queue.push(item); + } + continue; + } const rec = candidate as Record; const code = typeof rec.code === "string" ? rec.code.trim().toLowerCase().replaceAll("-", "_") : undefined; @@ -70,7 +78,7 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) } } - for (const field of ["message", "error", "details", "body", "reason"] as const) { + for (const field of ["message", "error", "details", "body", "reason", "last_error", "response"] as const) { const val = rec[field]; if (typeof val === "string") { if ( @@ -81,6 +89,8 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) ) { return true; } + } else if (val && typeof val === "object") { + queue.push(val); } } } diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 3f80c7147ec..9892bd2e760 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -447,6 +447,75 @@ const REJECTION_WINDOW = 48; */ const GENERIC_ERROR_TYPE = /invalid_request_error/gi; +const EFFORT_COMPATIBLE_CODES = new Set([ + "", + "invalid_request_error", + "invalid_argument", + "unsupported_parameter", + "unsupported_value", + "invalid_parameter_error", +]); +const EFFORT_COMPATIBLE_TYPES = new Set([ + "", + "invalid_request_error", + "upstream_error", +]); + +/** + * Rejects error envelopes that carry a conflicting code (e.g. permission_denied, insufficient_quota, + * rate_limit_exceeded, model_not_found, malformed non-string code) so they stay terminal or proceed + * to combo failover without false reasoning-effort learning or same-target ladder retries (#8173). + */ +export function hasConflictingReasoningEffortCode(text: string | undefined): boolean { + if (!text) return false; + let unescaped = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (unescaped.startsWith("Provider error 400: ")) { + unescaped = unescaped.slice("Provider error 400: ".length).trim(); + } + if (unescaped.startsWith("data:") && !unescaped.includes("\n")) { + unescaped = unescaped.slice("data:".length).trim(); + } + } + if (!unescaped.startsWith("{")) { + return /\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(unescaped); + } + try { + const parsed = JSON.parse(unescaped); + const queue = [parsed]; + let examined = 0; + while (queue.length > 0 && examined < 32) { + const candidate = queue.shift(); + examined += 1; + if (!candidate || typeof candidate !== "object") continue; + if (Array.isArray(candidate)) { + for (const item of candidate) { + if (item && typeof item === "object") queue.push(item); + } + continue; + } + const rec = candidate as Record; + if (rec.code !== undefined && rec.code !== null) { + if (typeof rec.code !== "string") return true; + const code = rec.code.trim().toLowerCase().replaceAll("-", "_"); + if (!EFFORT_COMPATIBLE_CODES.has(code)) return true; + } + if (rec.type !== undefined && rec.type !== null) { + if (typeof rec.type !== "string") return true; + const type = rec.type.trim().toLowerCase().replaceAll("-", "_"); + if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; + } + for (const field of ["error", "details", "response", "last_error"] as const) { + const val = rec[field]; + if (val && typeof val === "object") queue.push(val); + } + } + } catch { + return false; + } + return false; +} + /** * Evidence test for a rejection body: does it blame reasoning effort? * @@ -458,6 +527,7 @@ const GENERIC_ERROR_TYPE = /invalid_request_error/gi; export function isReasoningEffortRejection(text: string | undefined): boolean { if (!text) return false; if (isHardPolicyOrOriginRefusal(text)) return false; + if (hasConflictingReasoningEffortCode(text)) return false; if (/unsupported.{0,24}effort/i.test(text)) return true; // An upstream that names the offending parameter has already said which one it means. if (/["']?param["']?\s*[:=]\s*["']?(?:reasoning[._ ]effort|reasoning)/i.test(text)) return true; @@ -481,7 +551,7 @@ export function planReasoningEffortDowngrade(args: { requested?: string; rejectionText?: string; }): { effort: string; recorded: boolean } | undefined { - if (args.rejectionText && isHardPolicyOrOriginRefusal(args.rejectionText)) return undefined; + if (args.rejectionText && (isHardPolicyOrOriginRefusal(args.rejectionText) || hasConflictingReasoningEffortCode(args.rejectionText))) return undefined; const requested = args.requested === "ultra" ? "max" : args.requested; if (!requested || !RANKED.includes(requested)) return undefined; const recorded = recordUnsupportedReasoningEffort(args.provider, args.modelId, requested, args.rejectionText); diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 0172f08fdd2..899b0da438e 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -1,7 +1,12 @@ import { ENCRYPTED_FUNCTION_OUTPUT_REJECTION, upstreamErrorMessageFromPayload } from "../../lib/errors"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { isPlainObject } from "../../lib/plain-data"; -import { isHardPolicyOrOriginRefusal, isReasoningEffortRejection, planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; +import { + hasConflictingReasoningEffortCode, + isHardPolicyOrOriginRefusal, + isReasoningEffortRejection, + planReasoningEffortDowngrade, +} from "../../providers/reasoning-metadata"; import { isAnthropicFastRefusal } from "../../providers/anthropic-fast"; import type { AdapterRequest } from "../../adapters/base"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; @@ -80,7 +85,7 @@ export async function refusedReasoningEffortDowngrade( ): Promise> { if (guard.attempted && !comboAttempt) return undefined; const rejectionText = await reasoningEffortRejectionText(response, false, signal); - return rejectionText === undefined || isHardPolicyOrOriginRefusal(rejectionText) + return rejectionText === undefined || isHardPolicyOrOriginRefusal(rejectionText) || hasConflictingReasoningEffortCode(rejectionText) ? undefined : planReasoningEffortDowngrade({ ...plan, rejectionText }); } @@ -397,7 +402,7 @@ export async function reasoningEffortRejectionText( if (response.status !== 400 && response.status !== 403) return undefined; try { const body = await readBoundedResponseBody(response.clone(), { signal }); - if (!body.displaySafe || body.truncated || isHardPolicyOrOriginRefusal(body.text)) return undefined; + if (!body.displaySafe || body.truncated || isHardPolicyOrOriginRefusal(body.text) || hasConflictingReasoningEffortCode(body.text)) return undefined; return isReasoningEffortRejection(body.text) ? body.text : undefined; } catch { return undefined; diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 5704cda1278..9a3c6b256c8 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -42,6 +42,21 @@ const NESTED_POLICY_REFUSAL = JSON.stringify({ message: 'Provider error 400: {"error":{"code":"bio_policy","message":"Request refused"}}', }, }); +const OBJECT_DETAILS_POLICY_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + param: "reasoning_effort", + details: { code: "bio_policy", message: "Request refused" }, + }, +}); +const CONFLICTING_CODE_REFUSAL = JSON.stringify({ + code: "permission_denied", + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -554,4 +569,88 @@ describe("rejected reasoning rungs", () => { { host: "opencode.ai", effort: "max" }, ]); }); + + test("stops before walking effort ladder on nested object details refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(OBJECT_DETAILS_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before walking effort ladder on nested object details refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(OBJECT_DETAILS_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting root code (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting root code (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); }); From f2438d21f219eaf119f8258990e5846c4551c2a2 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:05:27 +0600 Subject: [PATCH 12/39] fix(combo): inspect serialized provider wrappers for conflicting codes and fail closed on unexhausted queue --- src/providers/reasoning-metadata.ts | 58 ++++++++- ...sponses-reasoning-effort-downgrade.test.ts | 112 +++++++++++++++++- 2 files changed, 164 insertions(+), 6 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 9892bd2e760..e7bd92ff7b5 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -461,6 +461,39 @@ const EFFORT_COMPATIBLE_TYPES = new Set([ "upstream_error", ]); +function extractJsonEnvelope(text: string): unknown | undefined { + let s = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (s.startsWith("Provider error 400: ")) { + s = s.slice("Provider error 400: ".length).trim(); + } else if (s.startsWith("Provider error: ")) { + s = s.slice("Provider error: ".length).trim(); + } + if (s.startsWith("data:") && !s.includes("\n")) { + s = s.slice("data:".length).trim(); + } + } + const firstBrace = s.indexOf("{"); + const lastBrace = s.lastIndexOf("}"); + if (firstBrace !== -1 && lastBrace > firstBrace) { + try { + return JSON.parse(s.slice(firstBrace, lastBrace + 1)); + } catch { + // ignore + } + } + const firstBracket = s.indexOf("["); + const lastBracket = s.lastIndexOf("]"); + if (firstBracket !== -1 && lastBracket > firstBracket) { + try { + return JSON.parse(s.slice(firstBracket, lastBracket + 1)); + } catch { + // ignore + } + } + return undefined; +} + /** * Rejects error envelopes that carry a conflicting code (e.g. permission_denied, insufficient_quota, * rate_limit_exceeded, model_not_found, malformed non-string code) so they stay terminal or proceed @@ -477,12 +510,17 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo unescaped = unescaped.slice("data:".length).trim(); } } - if (!unescaped.startsWith("{")) { + let parsed: unknown; + try { + parsed = JSON.parse(unescaped); + } catch { + parsed = extractJsonEnvelope(unescaped); + } + if (!parsed || typeof parsed !== "object") { return /\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(unescaped); } try { - const parsed = JSON.parse(unescaped); - const queue = [parsed]; + const queue: unknown[] = [parsed]; let examined = 0; while (queue.length > 0 && examined < 32) { const candidate = queue.shift(); @@ -505,11 +543,21 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo const type = rec.type.trim().toLowerCase().replaceAll("-", "_"); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } - for (const field of ["error", "details", "response", "last_error"] as const) { + for (const field of ["message", "error", "details", "response", "last_error", "body"] as const) { const val = rec[field]; - if (val && typeof val === "object") queue.push(val); + if (val && typeof val === "object") { + queue.push(val); + } else if (typeof val === "string") { + const inner = extractJsonEnvelope(val); + if (inner && typeof inner === "object") { + queue.push(inner); + } + } } } + if (queue.length > 0) { + return true; + } } catch { return false; } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 9a3c6b256c8..f50cd089d72 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses } from "../../src/server/responses/core"; import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCooldown } from "../../src/combos"; -import { isReasoningEffortLearnedUnsupported, resetReasoningMetadataCachesForTests } from "../../src/providers/reasoning-metadata"; +import { + hasConflictingReasoningEffortCode, + isReasoningEffortLearnedUnsupported, + resetReasoningMetadataCachesForTests, +} from "../../src/providers/reasoning-metadata"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { chatSuccess } from "../helpers/combo-failover-upstream"; @@ -57,6 +61,18 @@ const CONFLICTING_CODE_REFUSAL = JSON.stringify({ message: "Invalid reasoning effort.", }, }); +const SERIALIZED_WRAPPER_CONFLICTING_REFUSAL = JSON.stringify({ + type: "upstream_error", + param: "reasoning_effort", + message: 'Provider error 400: {"code":"permission_denied","error":"Invalid reasoning effort."}', +}); +const UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + details: [ + ...Array.from({ length: 32 }, (_, i) => ({ id: i, note: "ordinary candidate object" })), + { code: "permission_denied", message: "Denied after candidate limit" }, + ], +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -653,4 +669,98 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + + test("hasConflictingReasoningEffortCode unit checks", () => { + expect(hasConflictingReasoningEffortCode(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(VENDOR_EFFORT_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(JSON.stringify({ + param: "reasoning_effort", + details: [{ id: 1 }, { id: 2 }], + }))).toBe(false); + }); + + test("stops before effort replay on serialized provider wrapper conflicting code (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized provider wrapper conflicting code (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when candidate queue leaves unexamined objects (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("reasoning_effort"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when candidate queue leaves unexamined objects (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("reasoning_effort"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); }); From a64e0a79ba178ebd8a0c152dd8c7c0e5f2fa8319 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:21:00 +0600 Subject: [PATCH 13/39] fix(combo): reject competing carriers, fail closed on incomplete wrappers, and ignore echoed request content --- src/combos/failover.ts | 9 ++ src/lib/hard-refusal.ts | 113 ++++++++++-------- src/providers/reasoning-metadata.ts | 16 ++- src/server/responses/core-opaque-recovery.ts | 4 +- ...sponses-reasoning-effort-downgrade.test.ts | 102 ++++++++++++++++ ...uter-combo-failover-classification.test.ts | 9 ++ 6 files changed, 198 insertions(+), 55 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 472153b482c..8cc3d42934d 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -412,6 +412,15 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c const rootType = normalizedFailureCode(root.type as string | undefined); if (!EXACT_EFFORT_COMPATIBLE_CODES.has(rootCode)) return false; if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(rootType)) return false; + if (root.response !== undefined && root.response !== null) return false; + if (root.last_error !== undefined && root.last_error !== null) return false; + if (root.details !== undefined && root.details !== null && typeof root.details === "object") { + const detailsRec = root.details as Record; + if (detailsRec.code !== undefined && detailsRec.code !== null) { + if (typeof detailsRec.code !== "string") return false; + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(normalizedFailureCode(detailsRec.code))) return false; + } + } if (rootCode === "invalid_argument" && root.error === "Invalid reasoning effort." && (root.type === undefined || root.type === "invalid_request_error") diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index 2ff8a26f45b..debb01bb1d3 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -19,9 +19,6 @@ const SERIALIZED_HARD_CODE = */ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0): boolean { if (!text || depth > 4) return false; - if (isCyberPolicyMessage(text)) return true; - if (/origin_rejected/i.test(text)) return true; - if (SERIALIZED_HARD_CODE.test(text)) return true; let unescaped = text.trim(); for (let d = 0; d < 3; d += 1) { @@ -33,70 +30,82 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) } } + // Restrict structured policy/origin checks to authoritative diagnostic carriers + // so echoed user request content (e.g. request.input, prompt, echoed body) does not + // suppress valid recoveries (#8173). try { const parsed = JSON.parse(unescaped) as Record; - const queue: unknown[] = [ - parsed, - parsed.error, - (parsed.response as Record | undefined)?.error, - (parsed.response as Record | undefined)?.last_error, - parsed.last_error, - ]; - let examined = 0; - while (queue.length > 0 && examined < 32) { - const candidate = queue.shift(); - examined += 1; - if (!candidate || typeof candidate !== "object") continue; - if (Array.isArray(candidate)) { - for (const item of candidate) { - if (item && typeof item === "object") queue.push(item); - } - continue; - } - const rec = candidate as Record; - - const code = typeof rec.code === "string" ? rec.code.trim().toLowerCase().replaceAll("-", "_") : undefined; - if (code) { - if ( - isCyberPolicyCode(code) - || code === "origin_rejected" - || isTerminalRefusalCode(code) - || isNonReplayableUpstreamCode(code) - ) { - return true; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const queue: unknown[] = [ + parsed, + parsed.error, + (parsed.response as Record | undefined)?.error, + (parsed.response as Record | undefined)?.last_error, + parsed.last_error, + parsed.details, + ]; + let examined = 0; + while (queue.length > 0 && examined < 32) { + const candidate = queue.shift(); + examined += 1; + if (!candidate || typeof candidate !== "object") continue; + if (Array.isArray(candidate)) { + for (const item of candidate) { + if (item && typeof item === "object") queue.push(item); + } + continue; } - } + const rec = candidate as Record; - const type = typeof rec.type === "string" ? rec.type.trim().toLowerCase().replaceAll("-", "_") : undefined; - if (type) { - if ( - isCyberPolicyCode(type) - || type === "origin_rejected" - || isTerminalRefusalCode(type) - ) { - return true; + const code = typeof rec.code === "string" ? rec.code.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (code) { + if ( + isCyberPolicyCode(code) + || code === "origin_rejected" + || isTerminalRefusalCode(code) + || isNonReplayableUpstreamCode(code) + ) { + return true; + } } - } - for (const field of ["message", "error", "details", "body", "reason", "last_error", "response"] as const) { - const val = rec[field]; - if (typeof val === "string") { + const type = typeof rec.type === "string" ? rec.type.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (type) { if ( - isCyberPolicyMessage(val) - || /origin_rejected/i.test(val) - || safetyRefusalCodeFromMessage(val) - || isHardPolicyOrOriginRefusal(val, depth + 1) + isCyberPolicyCode(type) + || type === "origin_rejected" + || isTerminalRefusalCode(type) ) { return true; } - } else if (val && typeof val === "object") { - queue.push(val); + } + + // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason) + for (const field of ["message", "error", "details", "last_error", "response"] as const) { + const val = rec[field]; + if (typeof val === "string") { + if ( + isCyberPolicyMessage(val) + || /origin_rejected/i.test(val) + || safetyRefusalCodeFromMessage(val) + || isHardPolicyOrOriginRefusal(val, depth + 1) + ) { + return true; + } + } else if (val && typeof val === "object") { + queue.push(val); + } } } + return false; } } catch { - /* non-JSON payload handled by text matches above */ + /* non-JSON payload handled by text matches below */ } + if (isCyberPolicyMessage(text)) return true; + if (/origin_rejected/i.test(text)) return true; + if (SERIALIZED_HARD_CODE.test(text)) return true; + return false; } diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index e7bd92ff7b5..5241e554241 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -494,6 +494,17 @@ function extractJsonEnvelope(text: string): unknown | undefined { return undefined; } +function isIncompleteStructuredWrapper(text: string): boolean { + const s = text.trim(); + if (/^(?:Provider error (?:400:\s*)?|data:\s*)\{/i.test(s)) { + return extractJsonEnvelope(s) === undefined; + } + if (s.startsWith("{") && !s.endsWith("}")) { + return true; + } + return false; +} + /** * Rejects error envelopes that carry a conflicting code (e.g. permission_denied, insufficient_quota, * rate_limit_exceeded, model_not_found, malformed non-string code) so they stay terminal or proceed @@ -517,6 +528,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo parsed = extractJsonEnvelope(unescaped); } if (!parsed || typeof parsed !== "object") { + if (isIncompleteStructuredWrapper(unescaped)) return true; return /\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(unescaped); } try { @@ -543,7 +555,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo const type = rec.type.trim().toLowerCase().replaceAll("-", "_"); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } - for (const field of ["message", "error", "details", "response", "last_error", "body"] as const) { + for (const field of ["message", "error", "details", "response", "last_error"] as const) { const val = rec[field]; if (val && typeof val === "object") { queue.push(val); @@ -551,6 +563,8 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo const inner = extractJsonEnvelope(val); if (inner && typeof inner === "object") { queue.push(inner); + } else if (isIncompleteStructuredWrapper(val)) { + return true; } } } diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 899b0da438e..a8346c32bbe 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -1,9 +1,9 @@ import { ENCRYPTED_FUNCTION_OUTPUT_REJECTION, upstreamErrorMessageFromPayload } from "../../lib/errors"; import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { isHardPolicyOrOriginRefusal } from "../../lib/hard-refusal"; import { isPlainObject } from "../../lib/plain-data"; import { hasConflictingReasoningEffortCode, - isHardPolicyOrOriginRefusal, isReasoningEffortRejection, planReasoningEffortDowngrade, } from "../../providers/reasoning-metadata"; @@ -85,7 +85,7 @@ export async function refusedReasoningEffortDowngrade( ): Promise> { if (guard.attempted && !comboAttempt) return undefined; const rejectionText = await reasoningEffortRejectionText(response, false, signal); - return rejectionText === undefined || isHardPolicyOrOriginRefusal(rejectionText) || hasConflictingReasoningEffortCode(rejectionText) + return rejectionText === undefined ? undefined : planReasoningEffortDowngrade({ ...plan, rejectionText }); } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index f50cd089d72..0bb545a3ba8 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -73,6 +73,20 @@ const UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL = JSON.stringify({ { code: "permission_denied", message: "Denied after candidate limit" }, ], }); +const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + message: 'Provider error 400: {"code":"permission_denied"', +}); +const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ + error: { + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, + request: { + input: "origin_rejected", + reason: "testing cybersecurity policy", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -763,4 +777,92 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + + test("stops before effort replay on incomplete structured wrapper (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete structured wrapper (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return chatSuccess(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 64a471f3238..42da0a388c5 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -577,6 +577,15 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureDecision(400, permBody)).toBe("hop"); expect(comboFailureCooldownScope(400, permBody)).toBe("target"); }); + + test("rejects competing error carriers before granting a target-local combo hop for reasoning effort", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + response: { code: "permission_denied" }, + }); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); }); describe("image rejection classifier bounds", () => { From 27770ef6021cddb31e4e8f09c7426551ad463b58 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:38:48 +0600 Subject: [PATCH 14/39] fix(combo): fail closed on incomplete outer objects and reject competing diagnostic arrays --- src/combos/failover.ts | 50 ++++++++++++++++--- src/providers/reasoning-metadata.ts | 50 +++++++++++++++++-- ...sponses-reasoning-effort-downgrade.test.ts | 43 ++++++++++++++++ ...uter-combo-failover-classification.test.ts | 10 ++++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 8cc3d42934d..259f800b3b0 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -391,6 +391,41 @@ function normalizedFailureCode(code?: string | null): string { const EXACT_EFFORT_COMPATIBLE_CODES = new Set(["", "invalid_request_error", "invalid_argument"]); const EXACT_EFFORT_COMPATIBLE_TYPES = new Set(["", "invalid_request_error", "upstream_error"]); +function hasIncompatibleDiagnosticCarrier(val: unknown): boolean { + if (val === undefined || val === null) return false; + if (Array.isArray(val)) { + for (const item of val.slice(0, 32)) { + if (hasIncompatibleDiagnosticCarrier(item)) return true; + } + return false; + } + if (typeof val === "object") { + const rec = val as Record; + if (rec.code !== undefined && rec.code !== null) { + if (typeof rec.code !== "string") return true; + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(normalizedFailureCode(rec.code))) return true; + } + if (rec.type !== undefined && rec.type !== null) { + if (typeof rec.type !== "string") return true; + if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(normalizedFailureCode(rec.type))) return true; + } + if (rec.response !== undefined && rec.response !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.response)) return true; + } + if (rec.last_error !== undefined && rec.last_error !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.last_error)) return true; + } + if (rec.details !== undefined && rec.details !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.details)) return true; + } + if (rec.errors !== undefined && rec.errors !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.errors)) return true; + } + return false; + } + return false; +} + function isExactInvalidReasoningEffortRefusal(status: number, message: string, code?: string | null): boolean { if (status !== 400 || message.length > 16_384) return false; if (code !== undefined && code !== null && typeof code !== "string") return false; @@ -402,7 +437,7 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length).trim(); if (text.startsWith("data:") && !text.includes("\n")) text = text.slice("data:".length).trim(); let payload: unknown; - try { payload = JSON.parse(text); } catch { return false; } + try { payload = JSON.parse(text); } catch { return false; }; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; const root = payload as Record; if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; @@ -414,13 +449,8 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(rootType)) return false; if (root.response !== undefined && root.response !== null) return false; if (root.last_error !== undefined && root.last_error !== null) return false; - if (root.details !== undefined && root.details !== null && typeof root.details === "object") { - const detailsRec = root.details as Record; - if (detailsRec.code !== undefined && detailsRec.code !== null) { - if (typeof detailsRec.code !== "string") return false; - if (!EXACT_EFFORT_COMPATIBLE_CODES.has(normalizedFailureCode(detailsRec.code))) return false; - } - } + if (hasIncompatibleDiagnosticCarrier(root.details)) return false; + if (hasIncompatibleDiagnosticCarrier(root.errors)) return false; if (rootCode === "invalid_argument" && root.error === "Invalid reasoning effort." && (root.type === undefined || root.type === "invalid_request_error") @@ -435,6 +465,10 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c const errorType = normalizedFailureCode(record.type as string | undefined); if (!EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode)) return false; if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(errorType)) return false; + if (record.response !== undefined && record.response !== null) return false; + if (record.last_error !== undefined && record.last_error !== null) return false; + if (hasIncompatibleDiagnosticCarrier(record.details)) return false; + if (hasIncompatibleDiagnosticCarrier(record.errors)) return false; if (record.type === "upstream_error" && typeof record.message === "string" && record.message.startsWith("Provider error 400: ") && EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode)) { diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 5241e554241..a15dac45087 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -495,13 +495,54 @@ function extractJsonEnvelope(text: string): unknown | undefined { } function isIncompleteStructuredWrapper(text: string): boolean { - const s = text.trim(); - if (/^(?:Provider error (?:400:\s*)?|data:\s*)\{/i.test(s)) { - return extractJsonEnvelope(s) === undefined; + let s = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (s.startsWith("Provider error 400: ")) { + s = s.slice("Provider error 400: ".length).trim(); + } else if (s.startsWith("Provider error: ")) { + s = s.slice("Provider error: ".length).trim(); + } + if (s.startsWith("data:") && !s.includes("\n")) { + s = s.slice("data:".length).trim(); + } } - if (s.startsWith("{") && !s.endsWith("}")) { + if (s.startsWith("{") || s.startsWith("[")) { + try { + JSON.parse(s); + return false; + } catch { + return true; + } + } + if (/^(?:Provider error (?:400:\s*)?|data:\s*)[{\[]/i.test(text.trim())) { return true; } + const firstBrace = s.indexOf("{"); + if (firstBrace !== -1) { + let openBraces = 0; + let inString = false; + let escape = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (!inString) { + if (ch === "{") openBraces += 1; + else if (ch === "}") openBraces -= 1; + } + } + if (openBraces !== 0) return true; + } return false; } @@ -512,6 +553,7 @@ function isIncompleteStructuredWrapper(text: string): boolean { */ export function hasConflictingReasoningEffortCode(text: string | undefined): boolean { if (!text) return false; + if (isIncompleteStructuredWrapper(text)) return true; let unescaped = text.trim(); for (let d = 0; d < 3; d += 1) { if (unescaped.startsWith("Provider error 400: ")) { diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 0bb545a3ba8..ee2b0fa14ba 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -77,6 +77,7 @@ const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ param: "reasoning_effort", message: 'Provider error 400: {"code":"permission_denied"', }); +const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ error: { param: "reasoning_effort", @@ -865,4 +866,46 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); }); + + test("stops before effort replay on incomplete outer object ending with inner brace (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete outer object ending with inner brace (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 42da0a388c5..b9f75719ce2 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -586,6 +586,16 @@ describe("bounded optional-control error envelopes", () => { }); expect(comboFailureCooldownScope(400, payload)).toBe("target"); }); + + test("rejects competing diagnostic arrays before granting a target-local combo hop for reasoning effort", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: [{ code: "unknown_terminal_code" }], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); }); describe("image rejection classifier bounds", () => { From fd217db82182e204788656bc064db8cb7f9bea6b Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:49:00 +0600 Subject: [PATCH 15/39] fix(combo): fail closed on oversized diagnostic arrays and inspect errors carriers --- src/combos/failover.ts | 19 ++-- src/lib/hard-refusal.ts | 3 +- src/providers/reasoning-metadata.ts | 3 +- ...sponses-reasoning-effort-downgrade.test.ts | 92 +++++++++++++++++++ ...uter-combo-failover-classification.test.ts | 20 ++++ 5 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 259f800b3b0..c2fb8f94834 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -391,11 +391,13 @@ function normalizedFailureCode(code?: string | null): string { const EXACT_EFFORT_COMPATIBLE_CODES = new Set(["", "invalid_request_error", "invalid_argument"]); const EXACT_EFFORT_COMPATIBLE_TYPES = new Set(["", "invalid_request_error", "upstream_error"]); -function hasIncompatibleDiagnosticCarrier(val: unknown): boolean { +function hasIncompatibleDiagnosticCarrier(val: unknown, depth = 0): boolean { if (val === undefined || val === null) return false; + if (depth > 8) return true; if (Array.isArray(val)) { - for (const item of val.slice(0, 32)) { - if (hasIncompatibleDiagnosticCarrier(item)) return true; + if (val.length > 32) return true; + for (const item of val) { + if (hasIncompatibleDiagnosticCarrier(item, depth + 1)) return true; } return false; } @@ -409,17 +411,20 @@ function hasIncompatibleDiagnosticCarrier(val: unknown): boolean { if (typeof rec.type !== "string") return true; if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(normalizedFailureCode(rec.type))) return true; } + if (rec.error !== undefined && rec.error !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.error, depth + 1)) return true; + } if (rec.response !== undefined && rec.response !== null) { - if (hasIncompatibleDiagnosticCarrier(rec.response)) return true; + if (hasIncompatibleDiagnosticCarrier(rec.response, depth + 1)) return true; } if (rec.last_error !== undefined && rec.last_error !== null) { - if (hasIncompatibleDiagnosticCarrier(rec.last_error)) return true; + if (hasIncompatibleDiagnosticCarrier(rec.last_error, depth + 1)) return true; } if (rec.details !== undefined && rec.details !== null) { - if (hasIncompatibleDiagnosticCarrier(rec.details)) return true; + if (hasIncompatibleDiagnosticCarrier(rec.details, depth + 1)) return true; } if (rec.errors !== undefined && rec.errors !== null) { - if (hasIncompatibleDiagnosticCarrier(rec.errors)) return true; + if (hasIncompatibleDiagnosticCarrier(rec.errors, depth + 1)) return true; } return false; } diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index debb01bb1d3..8b9e42007ac 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -43,6 +43,7 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) (parsed.response as Record | undefined)?.last_error, parsed.last_error, parsed.details, + parsed.errors, ]; let examined = 0; while (queue.length > 0 && examined < 32) { @@ -81,7 +82,7 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) } // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason) - for (const field of ["message", "error", "details", "last_error", "response"] as const) { + for (const field of ["message", "error", "details", "last_error", "response", "errors"] as const) { const val = rec[field]; if (typeof val === "string") { if ( diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index a15dac45087..5b02d6bd181 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -581,6 +581,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo examined += 1; if (!candidate || typeof candidate !== "object") continue; if (Array.isArray(candidate)) { + if (candidate.length > 32) return true; for (const item of candidate) { if (item && typeof item === "object") queue.push(item); } @@ -597,7 +598,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo const type = rec.type.trim().toLowerCase().replaceAll("-", "_"); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } - for (const field of ["message", "error", "details", "response", "last_error"] as const) { + for (const field of ["message", "error", "details", "response", "last_error", "errors"] as const) { const val = rec[field]; if (val && typeof val === "object") { queue.push(val); diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index ee2b0fa14ba..6f5c86f2e6e 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -78,6 +78,14 @@ const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ message: 'Provider error 400: {"code":"permission_denied"', }); const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; +const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: [{ code: "bio_policy" }], +}); +const ERRORS_CONFLICTING_CODE_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: [{ code: "permission_denied" }], +}); const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ error: { param: "reasoning_effort", @@ -908,4 +916,88 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + + test("stops before effort replay on hard policy refusal in errors carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on hard policy refusal in errors carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting code in errors carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting code in errors carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index b9f75719ce2..a798c0c50b6 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -596,6 +596,26 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureDecision(400, payload)).toBe("stop"); expect(comboFailureCooldownScope(400, payload)).toBe("target"); }); + + test("fails closed when diagnostic array exceeds bounded scan budget", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: [...Array(32).fill({}), { code: "unknown_terminal_code" }], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("rejects nested error carriers in details before granting combo hop", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: { error: { code: "unknown_terminal_code" } }, + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); }); describe("image rejection classifier bounds", () => { From cdf68c76b57108097c4de25b6132772eb51f7e1c Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:03:45 +0600 Subject: [PATCH 16/39] fix(combo): inspect serialized diagnostics in arrays and limit effort evidence to authoritative carriers --- src/combos/failover.ts | 11 ++ src/lib/hard-refusal.ts | 22 ++- src/providers/reasoning-metadata.ts | 175 ++++++++++++++++-- ...sponses-reasoning-effort-downgrade.test.ts | 143 ++++++++++++++ ...uter-combo-failover-classification.test.ts | 20 ++ 5 files changed, 355 insertions(+), 16 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index c2fb8f94834..cb2f191e3d2 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -401,6 +401,17 @@ function hasIncompatibleDiagnosticCarrier(val: unknown, depth = 0): boolean { } return false; } + if (typeof val === "string") { + try { + const parsed = JSON.parse(val); + if (parsed && typeof parsed === "object") { + return hasIncompatibleDiagnosticCarrier(parsed, depth + 1); + } + return true; + } catch { + return true; + } + } if (typeof val === "object") { const rec = val as Record; if (rec.code !== undefined && rec.code !== null) { diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index 8b9e42007ac..a27ab53cf7d 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -51,8 +51,28 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) examined += 1; if (!candidate || typeof candidate !== "object") continue; if (Array.isArray(candidate)) { + if (candidate.length > 32) return true; for (const item of candidate) { - if (item && typeof item === "object") queue.push(item); + if (item && typeof item === "object") { + queue.push(item); + } else if (typeof item === "string") { + if ( + isCyberPolicyMessage(item) + || /origin_rejected/i.test(item) + || safetyRefusalCodeFromMessage(item) + || isHardPolicyOrOriginRefusal(item, depth + 1) + ) { + return true; + } + try { + const parsedItem = JSON.parse(item); + if (parsedItem && typeof parsedItem === "object") { + queue.push(parsedItem); + } + } catch { + /* non-JSON string */ + } + } } continue; } diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 5b02d6bd181..203276f35c0 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -583,7 +583,29 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo if (Array.isArray(candidate)) { if (candidate.length > 32) return true; for (const item of candidate) { - if (item && typeof item === "object") queue.push(item); + if (item && typeof item === "object") { + queue.push(item); + } else if (typeof item === "string") { + const inner = extractJsonEnvelope(item); + if (inner && typeof inner === "object") { + queue.push(inner); + } else if (isIncompleteStructuredWrapper(item)) { + return true; + } else { + try { + const parsedItem = JSON.parse(item); + if (parsedItem && typeof parsedItem === "object") { + queue.push(parsedItem); + } else { + return true; + } + } catch { + return true; + } + } + } else { + return true; + } } continue; } @@ -621,21 +643,15 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo return false; } -/** - * Evidence test for a rejection body: does it blame reasoning effort? - * - * The parameter name on its own is not evidence. A 400 that refuses `max_tokens` may still - * echo the whole request body back, `reasoning_effort` included, and treating that as a - * refusal spends this request's one downgrade replay on a rung the upstream never objected to - * — and persists a false refusal that clamps every later turn for thirty days. - */ -export function isReasoningEffortRejection(text: string | undefined): boolean { - if (!text) return false; - if (isHardPolicyOrOriginRefusal(text)) return false; - if (hasConflictingReasoningEffortCode(text)) return false; +const EFFORT_PARAM_NAMES = new Set([ + "reasoning_effort", + "reasoning.effort", + "reasoning", + "reasoning_parameters", +]); + +function diagnosticTextBlamesReasoningEffort(text: string): boolean { if (/unsupported.{0,24}effort/i.test(text)) return true; - // An upstream that names the offending parameter has already said which one it means. - if (/["']?param["']?\s*[:=]\s*["']?(?:reasoning[._ ]effort|reasoning)/i.test(text)) return true; const scanned = text.replace(GENERIC_ERROR_TYPE, " "); const term = /reasoning\.effort|reasoning_effort|reasoning effort|thinking budget|reasoning_parameters/gi; for (let match = term.exec(scanned); match; match = term.exec(scanned)) { @@ -646,6 +662,135 @@ export function isReasoningEffortRejection(text: string | undefined): boolean { return false; } +function diagnosticRecordBlamesReasoningEffort(rec: Record): boolean { + if (typeof rec.param === "string") { + const param = rec.param.trim().toLowerCase(); + if (EFFORT_PARAM_NAMES.has(param)) return true; + return false; + } + if (typeof rec.message === "string" && diagnosticTextBlamesReasoningEffort(rec.message)) { + return true; + } + if (typeof rec.error === "string" && diagnosticTextBlamesReasoningEffort(rec.error)) { + return true; + } + return false; +} + +/** + * Evidence test for a rejection body: does it blame reasoning effort? + * + * Restricts inspections to authoritative diagnostic carriers (error, errors, details, last_error, response) + * so echoed request context (e.g. request.param: "reasoning_effort") does not falsely classify unrelated 400s + * (such as temperature or max_tokens errors) as effort refusals (#8173). + */ +export function isReasoningEffortRejection(text: string | undefined): boolean { + if (!text) return false; + if (isHardPolicyOrOriginRefusal(text)) return false; + if (hasConflictingReasoningEffortCode(text)) return false; + + let unescaped = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (unescaped.startsWith("Provider error 400: ")) { + unescaped = unescaped.slice("Provider error 400: ".length).trim(); + } + if (unescaped.startsWith("data:") && !unescaped.includes("\n")) { + unescaped = unescaped.slice("data:".length).trim(); + } + } + + let parsed: unknown; + try { + parsed = JSON.parse(unescaped); + } catch { + parsed = extractJsonEnvelope(unescaped); + } + + if (parsed && typeof parsed === "object") { + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record)) return true; + } else if (typeof item === "string") { + try { + const inner = JSON.parse(item); + if (inner && typeof inner === "object" && diagnosticRecordBlamesReasoningEffort(inner as Record)) return true; + } catch { + if (diagnosticTextBlamesReasoningEffort(item)) return true; + } + } + } + return false; + } + + const root = parsed as Record; + + if (root.error !== undefined && root.error !== null) { + if (typeof root.error === "object" && !Array.isArray(root.error)) { + if (diagnosticRecordBlamesReasoningEffort(root.error as Record)) return true; + } else if (typeof root.error === "string") { + if (diagnosticTextBlamesReasoningEffort(root.error)) return true; + } + } + + if (Array.isArray(root.errors)) { + for (const item of root.errors) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record)) return true; + } else if (typeof item === "string") { + try { + const inner = JSON.parse(item); + if (inner && typeof inner === "object" && diagnosticRecordBlamesReasoningEffort(inner as Record)) return true; + } catch { + if (diagnosticTextBlamesReasoningEffort(item)) return true; + } + } + } + } + + if (Array.isArray(root.details)) { + for (const item of root.details) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record)) return true; + } else if (typeof item === "string") { + try { + const inner = JSON.parse(item); + if (inner && typeof inner === "object" && diagnosticRecordBlamesReasoningEffort(inner as Record)) return true; + } catch { + if (diagnosticTextBlamesReasoningEffort(item)) return true; + } + } + } + } else if (root.details && typeof root.details === "object") { + if (diagnosticRecordBlamesReasoningEffort(root.details as Record)) return true; + } + + if (root.last_error && typeof root.last_error === "object") { + if (diagnosticRecordBlamesReasoningEffort(root.last_error as Record)) return true; + } + + if (root.response && typeof root.response === "object") { + const resp = root.response as Record; + if (resp.error && typeof resp.error === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.error as Record)) return true; + } + if (resp.last_error && typeof resp.last_error === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.last_error as Record)) return true; + } + } + + if (root.error === undefined || typeof root.error !== "object") { + if (diagnosticRecordBlamesReasoningEffort(root)) return true; + } else if (typeof root.param === "string" && EFFORT_PARAM_NAMES.has(root.param.trim().toLowerCase())) { + return true; + } + + return false; + } + + return diagnosticTextBlamesReasoningEffort(unescaped); +} + /** * Plan a single-rung downgrade for a rejected request: records the refusal (so later turns * clamp before dispatch) and returns the next lower rung the model does publish. diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 6f5c86f2e6e..036fbaf9fbb 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -82,10 +82,27 @@ const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ param: "reasoning_effort", errors: [{ code: "bio_policy" }], }); +const SERIALIZED_ARRAY_BIO_POLICY_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: ['{"code":"bio_policy"}'], +}); const ERRORS_CONFLICTING_CODE_REFUSAL = JSON.stringify({ param: "reasoning_effort", errors: [{ code: "permission_denied" }], }); +const SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: ['{"code":"permission_denied"}'], +}); +const ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL = JSON.stringify({ + error: { + param: "temperature", + message: "Invalid temperature.", + }, + request: { + param: "reasoning_effort", + }, +}); const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ error: { param: "reasoning_effort", @@ -1000,4 +1017,130 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + + test("stops before effort replay on serialized hard policy refusal inside errors array (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized hard policy refusal inside errors array (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized conflicting code inside errors array (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized conflicting code inside errors array (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on unrelated refusal with echoed request param (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on unrelated refusal with echoed request param (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index a798c0c50b6..74d58c06190 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -616,6 +616,26 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureDecision(400, payload)).toBe("stop"); expect(comboFailureCooldownScope(400, payload)).toBe("target"); }); + + test("rejects serialized diagnostic entries in details before granting combo hop", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: ['{"code":"unknown_terminal_code"}'], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("fails closed on unclassifiable string entries in details array", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: ["unrecognized_plain_text_error"], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); }); describe("image rejection classifier bounds", () => { From 0a207b159681fa84e1f7a170fe268f4665051e67 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:11:33 +0600 Subject: [PATCH 17/39] fix(combo): parse serialized diagnostic messages and inspect detail carrier --- src/combos/failover.ts | 5 + src/lib/hard-refusal.ts | 3 +- src/providers/reasoning-metadata.ts | 118 +++++++++++++----- ...sponses-reasoning-effort-downgrade.test.ts | 98 +++++++++++++++ 4 files changed, 189 insertions(+), 35 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index cb2f191e3d2..8a1d21f6445 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -434,6 +434,9 @@ function hasIncompatibleDiagnosticCarrier(val: unknown, depth = 0): boolean { if (rec.details !== undefined && rec.details !== null) { if (hasIncompatibleDiagnosticCarrier(rec.details, depth + 1)) return true; } + if (rec.detail !== undefined && rec.detail !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.detail, depth + 1)) return true; + } if (rec.errors !== undefined && rec.errors !== null) { if (hasIncompatibleDiagnosticCarrier(rec.errors, depth + 1)) return true; } @@ -466,6 +469,7 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (root.response !== undefined && root.response !== null) return false; if (root.last_error !== undefined && root.last_error !== null) return false; if (hasIncompatibleDiagnosticCarrier(root.details)) return false; + if (hasIncompatibleDiagnosticCarrier(root.detail)) return false; if (hasIncompatibleDiagnosticCarrier(root.errors)) return false; if (rootCode === "invalid_argument" && root.error === "Invalid reasoning effort." @@ -484,6 +488,7 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (record.response !== undefined && record.response !== null) return false; if (record.last_error !== undefined && record.last_error !== null) return false; if (hasIncompatibleDiagnosticCarrier(record.details)) return false; + if (hasIncompatibleDiagnosticCarrier(record.detail)) return false; if (hasIncompatibleDiagnosticCarrier(record.errors)) return false; if (record.type === "upstream_error" && typeof record.message === "string" && record.message.startsWith("Provider error 400: ") diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index a27ab53cf7d..49a9e66e514 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -43,6 +43,7 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) (parsed.response as Record | undefined)?.last_error, parsed.last_error, parsed.details, + parsed.detail, parsed.errors, ]; let examined = 0; @@ -102,7 +103,7 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) } // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason) - for (const field of ["message", "error", "details", "last_error", "response", "errors"] as const) { + for (const field of ["message", "error", "details", "detail", "last_error", "response", "errors"] as const) { const val = rec[field]; if (typeof val === "string") { if ( diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 203276f35c0..941fa64eef4 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -620,7 +620,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo const type = rec.type.trim().toLowerCase().replaceAll("-", "_"); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } - for (const field of ["message", "error", "details", "response", "last_error", "errors"] as const) { + for (const field of ["message", "error", "details", "detail", "response", "last_error", "errors"] as const) { const val = rec[field]; if (val && typeof val === "object") { queue.push(val); @@ -662,17 +662,44 @@ function diagnosticTextBlamesReasoningEffort(text: string): boolean { return false; } -function diagnosticRecordBlamesReasoningEffort(rec: Record): boolean { +function diagnosticRecordBlamesReasoningEffort(rec: Record, depth = 0): boolean { + if (depth > 4) return false; if (typeof rec.param === "string") { const param = rec.param.trim().toLowerCase(); if (EFFORT_PARAM_NAMES.has(param)) return true; return false; } - if (typeof rec.message === "string" && diagnosticTextBlamesReasoningEffort(rec.message)) { - return true; + if (typeof rec.message === "string") { + if (rec.message.includes("{") || rec.message.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.message, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.message)) return true; } - if (typeof rec.error === "string" && diagnosticTextBlamesReasoningEffort(rec.error)) { - return true; + if (typeof rec.error === "string") { + if (rec.error.includes("{") || rec.error.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.error, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.error)) return true; + } + if (typeof rec.detail === "string") { + if (rec.detail.includes("{") || rec.detail.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.detail, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.detail)) return true; + } else if (Array.isArray(rec.detail)) { + for (const item of rec.detail) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } else if (rec.detail && typeof rec.detail === "object") { + if (diagnosticRecordBlamesReasoningEffort(rec.detail as Record, depth + 1)) return true; } return false; } @@ -680,12 +707,12 @@ function diagnosticRecordBlamesReasoningEffort(rec: Record): bo /** * Evidence test for a rejection body: does it blame reasoning effort? * - * Restricts inspections to authoritative diagnostic carriers (error, errors, details, last_error, response) + * Restricts inspections to authoritative diagnostic carriers (error, errors, details, detail, last_error, response) * so echoed request context (e.g. request.param: "reasoning_effort") does not falsely classify unrelated 400s * (such as temperature or max_tokens errors) as effort refusals (#8173). */ -export function isReasoningEffortRejection(text: string | undefined): boolean { - if (!text) return false; +export function isReasoningEffortRejection(text: string | undefined, depth = 0): boolean { + if (!text || depth > 4) return false; if (isHardPolicyOrOriginRefusal(text)) return false; if (hasConflictingReasoningEffortCode(text)) return false; @@ -710,13 +737,12 @@ export function isReasoningEffortRejection(text: string | undefined): boolean { if (Array.isArray(parsed)) { for (const item of parsed) { if (item && typeof item === "object") { - if (diagnosticRecordBlamesReasoningEffort(item as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; } else if (typeof item === "string") { - try { - const inner = JSON.parse(item); - if (inner && typeof inner === "object" && diagnosticRecordBlamesReasoningEffort(inner as Record)) return true; - } catch { - if (diagnosticTextBlamesReasoningEffort(item)) return true; + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; } } } @@ -727,22 +753,25 @@ export function isReasoningEffortRejection(text: string | undefined): boolean { if (root.error !== undefined && root.error !== null) { if (typeof root.error === "object" && !Array.isArray(root.error)) { - if (diagnosticRecordBlamesReasoningEffort(root.error as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(root.error as Record, depth + 1)) return true; } else if (typeof root.error === "string") { - if (diagnosticTextBlamesReasoningEffort(root.error)) return true; + if (root.error.includes("{") || root.error.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.error, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.error)) { + return true; + } } } if (Array.isArray(root.errors)) { for (const item of root.errors) { if (item && typeof item === "object") { - if (diagnosticRecordBlamesReasoningEffort(item as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; } else if (typeof item === "string") { - try { - const inner = JSON.parse(item); - if (inner && typeof inner === "object" && diagnosticRecordBlamesReasoningEffort(inner as Record)) return true; - } catch { - if (diagnosticTextBlamesReasoningEffort(item)) return true; + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; } } } @@ -751,36 +780,57 @@ export function isReasoningEffortRejection(text: string | undefined): boolean { if (Array.isArray(root.details)) { for (const item of root.details) { if (item && typeof item === "object") { - if (diagnosticRecordBlamesReasoningEffort(item as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; } else if (typeof item === "string") { - try { - const inner = JSON.parse(item); - if (inner && typeof inner === "object" && diagnosticRecordBlamesReasoningEffort(inner as Record)) return true; - } catch { - if (diagnosticTextBlamesReasoningEffort(item)) return true; + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; } } } } else if (root.details && typeof root.details === "object") { - if (diagnosticRecordBlamesReasoningEffort(root.details as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(root.details as Record, depth + 1)) return true; + } + + if (typeof root.detail === "string") { + if (root.detail.includes("{") || root.detail.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.detail, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.detail)) { + return true; + } + } else if (Array.isArray(root.detail)) { + for (const item of root.detail) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } else if (root.detail && typeof root.detail === "object") { + if (diagnosticRecordBlamesReasoningEffort(root.detail as Record, depth + 1)) return true; } if (root.last_error && typeof root.last_error === "object") { - if (diagnosticRecordBlamesReasoningEffort(root.last_error as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(root.last_error as Record, depth + 1)) return true; } if (root.response && typeof root.response === "object") { const resp = root.response as Record; if (resp.error && typeof resp.error === "object") { - if (diagnosticRecordBlamesReasoningEffort(resp.error as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(resp.error as Record, depth + 1)) return true; } if (resp.last_error && typeof resp.last_error === "object") { - if (diagnosticRecordBlamesReasoningEffort(resp.last_error as Record)) return true; + if (diagnosticRecordBlamesReasoningEffort(resp.last_error as Record, depth + 1)) return true; } } if (root.error === undefined || typeof root.error !== "object") { - if (diagnosticRecordBlamesReasoningEffort(root)) return true; + if (diagnosticRecordBlamesReasoningEffort(root, depth + 1)) return true; } else if (typeof root.param === "string" && EFFORT_PARAM_NAMES.has(root.param.trim().toLowerCase())) { return true; } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 036fbaf9fbb..b994fbea4ec 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -103,6 +103,18 @@ const ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL = JSON.stringify({ param: "reasoning_effort", }, }); +const SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL = JSON.stringify({ + error: { + type: "upstream_error", + message: `Provider error 400: ${JSON.stringify({ + error: { param: "temperature", message: "Invalid temperature." }, + request: { param: "reasoning_effort" }, + })}`, + }, +}); +const DETAIL_REASONING_EFFORT_REFUSAL = JSON.stringify({ + detail: "Unsupported reasoning effort", +}); const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ error: { param: "reasoning_effort", @@ -1143,4 +1155,90 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + + test("stops before effort replay on serialized wrapper containing echoed request param (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized wrapper containing echoed request param (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); }); From d1b5b3537283fe8012f77de73d1f98a3da87bbaa Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:49:53 +0600 Subject: [PATCH 18/39] fix(combo): reconcile competing diagnostic parameters and preserve detail array text --- src/providers/reasoning-metadata.ts | 57 +++++++---- ...sponses-reasoning-effort-downgrade.test.ts | 96 +++++++++++++++++++ 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 941fa64eef4..d5682998750 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -546,10 +546,18 @@ function isIncompleteStructuredWrapper(text: string): boolean { return false; } +const EFFORT_PARAM_NAMES = new Set([ + "reasoning_effort", + "reasoning.effort", + "reasoning", + "reasoning_parameters", +]); + /** * Rejects error envelopes that carry a conflicting code (e.g. permission_denied, insufficient_quota, - * rate_limit_exceeded, model_not_found, malformed non-string code) so they stay terminal or proceed - * to combo failover without false reasoning-effort learning or same-target ladder retries (#8173). + * rate_limit_exceeded, model_not_found, malformed non-string code) or competing diagnostic parameters + * (e.g. reasoning_effort at root with an inner error identifying temperature) so they stay terminal + * or proceed to combo failover without false reasoning-effort learning or same-target ladder retries (#8173). */ export function hasConflictingReasoningEffortCode(text: string | undefined): boolean { if (!text) return false; @@ -576,6 +584,8 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo try { const queue: unknown[] = [parsed]; let examined = 0; + let hasEffortParam = false; + let hasNonEffortParam = false; while (queue.length > 0 && examined < 32) { const candidate = queue.shift(); examined += 1; @@ -586,21 +596,29 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo if (item && typeof item === "object") { queue.push(item); } else if (typeof item === "string") { + if (item.length > 16_384) return true; const inner = extractJsonEnvelope(item); if (inner && typeof inner === "object") { queue.push(inner); } else if (isIncompleteStructuredWrapper(item)) { return true; } else { - try { - const parsedItem = JSON.parse(item); - if (parsedItem && typeof parsedItem === "object") { - queue.push(parsedItem); - } else { + const trimmed = item.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsedItem = JSON.parse(item); + if (parsedItem && typeof parsedItem === "object") { + queue.push(parsedItem); + } else { + return true; + } + } catch { + return true; + } + } else { + if (/\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(trimmed)) { return true; } - } catch { - return true; } } } else { @@ -610,6 +628,18 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo continue; } const rec = candidate as Record; + if (rec.param !== undefined && rec.param !== null) { + if (typeof rec.param !== "string") return true; + const param = rec.param.trim().toLowerCase(); + if (param.length > 0) { + if (EFFORT_PARAM_NAMES.has(param)) { + hasEffortParam = true; + } else { + hasNonEffortParam = true; + } + } + } + if (hasEffortParam && hasNonEffortParam) return true; if (rec.code !== undefined && rec.code !== null) { if (typeof rec.code !== "string") return true; const code = rec.code.trim().toLowerCase().replaceAll("-", "_"); @@ -643,13 +673,6 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo return false; } -const EFFORT_PARAM_NAMES = new Set([ - "reasoning_effort", - "reasoning.effort", - "reasoning", - "reasoning_parameters", -]); - function diagnosticTextBlamesReasoningEffort(text: string): boolean { if (/unsupported.{0,24}effort/i.test(text)) return true; const scanned = text.replace(GENERIC_ERROR_TYPE, " "); @@ -831,8 +854,6 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): if (root.error === undefined || typeof root.error !== "object") { if (diagnosticRecordBlamesReasoningEffort(root, depth + 1)) return true; - } else if (typeof root.param === "string" && EFFORT_PARAM_NAMES.has(root.param.trim().toLowerCase())) { - return true; } return false; diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index b994fbea4ec..49250d0027d 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -115,6 +115,16 @@ const SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL = JSON.stringify({ const DETAIL_REASONING_EFFORT_REFUSAL = JSON.stringify({ detail: "Unsupported reasoning effort", }); +const DETAIL_ARRAY_REASONING_EFFORT_REFUSAL = JSON.stringify({ + detail: ["Unsupported reasoning effort"], +}); +const CONFLICTING_PARAM_UNRELATED_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + error: { + param: "temperature", + message: "Invalid temperature.", + }, +}); const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ error: { param: "reasoning_effort", @@ -1241,4 +1251,90 @@ describe("rejected reasoning rungs", () => { expect(response.status).toBe(200); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); }); + + test("stops before effort replay on conflicting diagnostic parameters (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting diagnostic parameters (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); }); From 559ff61cfb44d8843b93e9889842ea545a6b74fd Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:06:43 +0600 Subject: [PATCH 19/39] fix(combo): reconcile non-effort parameters across carriers and preserve string details/last_error recovery --- src/combos/failover.ts | 24 ++- src/providers/reasoning-metadata.ts | 81 ++++++++-- ...sponses-reasoning-effort-downgrade.test.ts | 143 ++++++++++++++++++ ...uter-combo-failover-classification.test.ts | 13 ++ 4 files changed, 247 insertions(+), 14 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 8a1d21f6445..c7de638c46c 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -414,6 +414,11 @@ function hasIncompatibleDiagnosticCarrier(val: unknown, depth = 0): boolean { } if (typeof val === "object") { const rec = val as Record; + if (rec.param !== undefined && rec.param !== null) { + if (typeof rec.param !== "string") return true; + const param = rec.param.trim().toLowerCase(); + if (param.length > 0 && param !== "reasoning_effort" && param !== "reasoning.effort") return true; + } if (rec.code !== undefined && rec.code !== null) { if (typeof rec.code !== "string") return true; if (!EXACT_EFFORT_COMPATIBLE_CODES.has(normalizedFailureCode(rec.code))) return true; @@ -461,7 +466,11 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c const root = payload as Record; if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; - if (root.param !== undefined && root.param !== null && typeof root.param !== "string") return false; + if (root.param !== undefined && root.param !== null) { + if (typeof root.param !== "string") return false; + const param = root.param.trim().toLowerCase(); + if (param !== "reasoning_effort" && param !== "reasoning.effort") return false; + } const rootCode = normalizedFailureCode(root.code as string | undefined); const rootType = normalizedFailureCode(root.type as string | undefined); if (!EXACT_EFFORT_COMPATIBLE_CODES.has(rootCode)) return false; @@ -480,7 +489,14 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c const record = error as Record; if (record.code !== undefined && record.code !== null && typeof record.code !== "string") return false; if (record.type !== undefined && record.type !== null && typeof record.type !== "string") return false; - if (record.param !== undefined && record.param !== null && typeof record.param !== "string") return false; + if (record.param !== undefined && record.param !== null) { + if (typeof record.param !== "string") return false; + const param = record.param.trim().toLowerCase(); + if (param !== "reasoning_effort" && param !== "reasoning.effort") return false; + } + if (root.param !== undefined && root.param !== null && record.param !== undefined && record.param !== null) { + if (root.param.trim().toLowerCase() !== record.param.trim().toLowerCase()) return false; + } const errorCode = normalizedFailureCode(record.code as string | undefined); const errorType = normalizedFailureCode(record.type as string | undefined); if (!EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode)) return false; @@ -644,6 +660,7 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co const root = payload as Record; if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; + if (root.param !== undefined && root.param !== null && typeof root.param !== "string") return false; const rootCode = normalizedFailureCode(root.code as string | undefined); if (!genericCodes.has(rootCode)) return false; if (root.type !== undefined && root.type !== null && root.type !== "invalid_request_error" && root.type !== "upstream_error") return false; @@ -651,6 +668,9 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co if (!error || typeof error !== "object" || Array.isArray(error)) return false; const e = error as Record; if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false; + if (root.param !== undefined && root.param !== null && typeof e.param === "string") { + if (root.param.trim().toLowerCase() !== e.param.trim().toLowerCase()) return false; + } const errorCode = normalizedFailureCode(typeof e.code === "string" ? e.code : undefined); if (!genericCodes.has(errorCode) || typeof e.message !== "string") return false; if (e.type !== "invalid_request_error" && e.type !== "upstream_error") return false; diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index d5682998750..6cbddb8eab7 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -584,8 +584,6 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo try { const queue: unknown[] = [parsed]; let examined = 0; - let hasEffortParam = false; - let hasNonEffortParam = false; while (queue.length > 0 && examined < 32) { const candidate = queue.shift(); examined += 1; @@ -631,15 +629,8 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo if (rec.param !== undefined && rec.param !== null) { if (typeof rec.param !== "string") return true; const param = rec.param.trim().toLowerCase(); - if (param.length > 0) { - if (EFFORT_PARAM_NAMES.has(param)) { - hasEffortParam = true; - } else { - hasNonEffortParam = true; - } - } + if (param.length > 0 && !EFFORT_PARAM_NAMES.has(param)) return true; } - if (hasEffortParam && hasNonEffortParam) return true; if (rec.code !== undefined && rec.code !== null) { if (typeof rec.code !== "string") return true; const code = rec.code.trim().toLowerCase().replaceAll("-", "_"); @@ -660,6 +651,8 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo queue.push(inner); } else if (isIncompleteStructuredWrapper(val)) { return true; + } else if (/\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(val)) { + return true; } } } @@ -724,6 +717,34 @@ function diagnosticRecordBlamesReasoningEffort(rec: Record, dep } else if (rec.detail && typeof rec.detail === "object") { if (diagnosticRecordBlamesReasoningEffort(rec.detail as Record, depth + 1)) return true; } + if (typeof rec.details === "string") { + if (rec.details.includes("{") || rec.details.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.details, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.details)) return true; + } else if (Array.isArray(rec.details)) { + for (const item of rec.details) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } else if (rec.details && typeof rec.details === "object") { + if (diagnosticRecordBlamesReasoningEffort(rec.details as Record, depth + 1)) return true; + } + if (typeof rec.last_error === "string") { + if (rec.last_error.includes("{") || rec.last_error.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.last_error, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.last_error)) return true; + } else if (rec.last_error && typeof rec.last_error === "object") { + if (diagnosticRecordBlamesReasoningEffort(rec.last_error as Record, depth + 1)) return true; + } return false; } @@ -812,6 +833,12 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): } } } + } else if (typeof root.details === "string") { + if (root.details.includes("{") || root.details.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.details, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.details)) { + return true; + } } else if (root.details && typeof root.details === "object") { if (diagnosticRecordBlamesReasoningEffort(root.details as Record, depth + 1)) return true; } @@ -838,7 +865,13 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): if (diagnosticRecordBlamesReasoningEffort(root.detail as Record, depth + 1)) return true; } - if (root.last_error && typeof root.last_error === "object") { + if (typeof root.last_error === "string") { + if (root.last_error.includes("{") || root.last_error.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.last_error, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.last_error)) { + return true; + } + } else if (root.last_error && typeof root.last_error === "object") { if (diagnosticRecordBlamesReasoningEffort(root.last_error as Record, depth + 1)) return true; } @@ -847,9 +880,33 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): if (resp.error && typeof resp.error === "object") { if (diagnosticRecordBlamesReasoningEffort(resp.error as Record, depth + 1)) return true; } - if (resp.last_error && typeof resp.last_error === "object") { + if (typeof resp.last_error === "string") { + if (resp.last_error.includes("{") || resp.last_error.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(resp.last_error, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(resp.last_error)) { + return true; + } + } else if (resp.last_error && typeof resp.last_error === "object") { if (diagnosticRecordBlamesReasoningEffort(resp.last_error as Record, depth + 1)) return true; } + if (typeof resp.details === "string") { + if (resp.details.includes("{") || resp.details.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(resp.details, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(resp.details)) { + return true; + } + } else if (resp.details && typeof resp.details === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.details as Record, depth + 1)) return true; + } + if (typeof resp.detail === "string") { + if (resp.detail.includes("{") || resp.detail.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(resp.detail, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(resp.detail)) { + return true; + } + } else if (resp.detail && typeof resp.detail === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.detail as Record, depth + 1)) return true; + } } if (root.error === undefined || typeof root.error !== "object") { diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 49250d0027d..bf172c87e51 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -125,6 +125,19 @@ const CONFLICTING_PARAM_UNRELATED_REFUSAL = JSON.stringify({ message: "Invalid temperature.", }, }); +const NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL = JSON.stringify({ + error: { + param: "temperature", + message: "Invalid temperature.", + }, + detail: "Unsupported reasoning effort", +}); +const DETAILS_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ + details: "Unsupported reasoning effort", +}); +const LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ + last_error: "Unsupported reasoning effort", +}); const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ error: { param: "reasoning_effort", @@ -1337,4 +1350,134 @@ describe("rejected reasoning rungs", () => { expect(response.status).toBe(200); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); }); + + test("stops before effort replay on non-effort parameter with effort claim in detail (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on non-effort parameter with effort claim in detail (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 74d58c06190..e60ed1b946e 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -636,6 +636,19 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureDecision(400, payload)).toBe("stop"); expect(comboFailureCooldownScope(400, payload)).toBe("target"); }); + + test("rejects conflicting named parameters before exact-effort combo hop", () => { + const payload = JSON.stringify({ + param: "temperature", + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); }); describe("image rejection classifier bounds", () => { From 18cb9e3fe617de2839cc12c6700a284b5ffae78f Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:49:05 +0600 Subject: [PATCH 20/39] fix(combo): fail closed on malformed structured diagnostics before effort replay --- src/providers/reasoning-metadata.ts | 13 +++ ...sponses-reasoning-effort-downgrade.test.ts | 80 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 6cbddb8eab7..259cf3c1a7e 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -518,6 +518,7 @@ function isIncompleteStructuredWrapper(text: string): boolean { return true; } const firstBrace = s.indexOf("{"); + const lastBrace = s.lastIndexOf("}"); if (firstBrace !== -1) { let openBraces = 0; let inString = false; @@ -542,6 +543,13 @@ function isIncompleteStructuredWrapper(text: string): boolean { } } if (openBraces !== 0) return true; + if (lastBrace > firstBrace && s.slice(firstBrace, lastBrace + 1).includes(":")) { + try { + JSON.parse(s.slice(firstBrace, lastBrace + 1)); + } catch { + return true; + } + } } return false; } @@ -579,6 +587,8 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo } if (!parsed || typeof parsed !== "object") { if (isIncompleteStructuredWrapper(unescaped)) return true; + const hasBraces = unescaped.includes("{") && unescaped.includes("}"); + if (hasBraces) return true; return /\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(unescaped); } try { @@ -916,6 +926,9 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): return false; } + const hasBraces = unescaped.includes("{") && unescaped.includes("}"); + if (hasBraces) return false; + return diagnosticTextBlamesReasoningEffort(unescaped); } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index bf172c87e51..9ad108bb091 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -7,6 +7,7 @@ import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCoo import { hasConflictingReasoningEffortCode, isReasoningEffortLearnedUnsupported, + isReasoningEffortRejection, resetReasoningMetadataCachesForTests, } from "../../src/providers/reasoning-metadata"; import type { RequestLogContext } from "../../src/server/request-log"; @@ -77,6 +78,8 @@ const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ param: "reasoning_effort", message: 'Provider error 400: {"code":"permission_denied"', }); +const BALANCED_MALFORMED_JSON_REFUSAL = + 'Gateway error 400: {"code":"unknown_terminal_code","message":"Unsupported value for reasoning_effort", invalid_json: }'; const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ param: "reasoning_effort", @@ -748,6 +751,8 @@ describe("rejected reasoning rungs", () => { test("hasConflictingReasoningEffortCode unit checks", () => { expect(hasConflictingReasoningEffortCode(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL)).toBe(true); expect(hasConflictingReasoningEffortCode(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(VENDOR_EFFORT_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(JSON.stringify({ param: "reasoning_effort", @@ -1480,4 +1485,79 @@ describe("rejected reasoning rungs", () => { expect(response.status).toBe(200); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); }); + + test("stops before effort replay on balanced malformed JSON refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed JSON refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("combo stops immediately on balanced malformed JSON refusal without walking ladder", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("unknown_terminal_code"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + }); }); From a636e4ee4ee11dee9af2a45e2304dfcff4d86b21 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:29:06 +0600 Subject: [PATCH 21/39] fix(reasoning): extract prefixed JSON diagnostic envelopes in hard refusal checks --- src/lib/hard-refusal.ts | 190 +++++++++++------- src/providers/reasoning-metadata.ts | 43 +--- ...sponses-reasoning-effort-downgrade.test.ts | 100 +++++++++ 3 files changed, 222 insertions(+), 111 deletions(-) diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index 49a9e66e514..fa1961411ff 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -9,6 +9,47 @@ import { isNonReplayableUpstreamCode } from "./upstream-retry"; const SERIALIZED_HARD_CODE = /\bcode\s*[:=]\s*["']?(?:cyber_policy|origin_rejected|bio_policy|misalignment_policy_violation|invalid_prompt|upstream_no_response|upstream_closed_before_response|upstream_reset_replay_refused)\b/i; +/** + * Extract a balanced or prefixed JSON object/array envelope from error text. + * Strips common gateway prefixes and extracts content between the outermost JSON delimiters. + */ +export function extractJsonEnvelope(text: string): unknown | undefined { + let s = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (s.startsWith("Provider error 400: ")) { + s = s.slice("Provider error 400: ".length).trim(); + } else if (s.startsWith("Provider error: ")) { + s = s.slice("Provider error: ".length).trim(); + } else if (s.startsWith("Gateway error 400: ")) { + s = s.slice("Gateway error 400: ".length).trim(); + } else if (s.startsWith("Gateway error: ")) { + s = s.slice("Gateway error: ".length).trim(); + } + if (s.startsWith("data:") && !s.includes("\n")) { + s = s.slice("data:".length).trim(); + } + } + const firstBrace = s.indexOf("{"); + const lastBrace = s.lastIndexOf("}"); + if (firstBrace !== -1 && lastBrace > firstBrace) { + try { + return JSON.parse(s.slice(firstBrace, lastBrace + 1)); + } catch { + // ignore + } + } + const firstBracket = s.indexOf("["); + const lastBracket = s.lastIndexOf("]"); + if (firstBracket !== -1 && lastBracket > firstBracket) { + try { + return JSON.parse(s.slice(firstBracket, lastBracket + 1)); + } catch { + // ignore + } + } + return undefined; +} + /** * Detect a hard policy or origin refusal across known upstream envelope shapes and diagnostic text. * When an error is a hard refusal (e.g. cyber_policy, origin_rejected, bio_policy), recovery must @@ -24,6 +65,12 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) for (let d = 0; d < 3; d += 1) { if (unescaped.startsWith("Provider error 400: ")) { unescaped = unescaped.slice("Provider error 400: ".length).trim(); + } else if (unescaped.startsWith("Provider error: ")) { + unescaped = unescaped.slice("Provider error: ".length).trim(); + } else if (unescaped.startsWith("Gateway error 400: ")) { + unescaped = unescaped.slice("Gateway error 400: ".length).trim(); + } else if (unescaped.startsWith("Gateway error: ")) { + unescaped = unescaped.slice("Gateway error: ".length).trim(); } if (unescaped.startsWith("data:") && !unescaped.includes("\n")) { unescaped = unescaped.slice("data:".length).trim(); @@ -33,96 +80,89 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) // Restrict structured policy/origin checks to authoritative diagnostic carriers // so echoed user request content (e.g. request.input, prompt, echoed body) does not // suppress valid recoveries (#8173). + let parsed: unknown; try { - const parsed = JSON.parse(unescaped) as Record; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const queue: unknown[] = [ - parsed, - parsed.error, - (parsed.response as Record | undefined)?.error, - (parsed.response as Record | undefined)?.last_error, - parsed.last_error, - parsed.details, - parsed.detail, - parsed.errors, - ]; - let examined = 0; - while (queue.length > 0 && examined < 32) { - const candidate = queue.shift(); - examined += 1; - if (!candidate || typeof candidate !== "object") continue; - if (Array.isArray(candidate)) { - if (candidate.length > 32) return true; - for (const item of candidate) { - if (item && typeof item === "object") { - queue.push(item); - } else if (typeof item === "string") { - if ( - isCyberPolicyMessage(item) - || /origin_rejected/i.test(item) - || safetyRefusalCodeFromMessage(item) - || isHardPolicyOrOriginRefusal(item, depth + 1) - ) { - return true; - } - try { - const parsedItem = JSON.parse(item); - if (parsedItem && typeof parsedItem === "object") { - queue.push(parsedItem); - } - } catch { - /* non-JSON string */ - } + parsed = JSON.parse(unescaped); + } catch { + parsed = extractJsonEnvelope(unescaped); + } + + if (parsed && typeof parsed === "object") { + const queue: unknown[] = [parsed]; + let examined = 0; + while (queue.length > 0 && examined < 32) { + const candidate = queue.shift(); + examined += 1; + if (!candidate || typeof candidate !== "object") continue; + if (Array.isArray(candidate)) { + if (candidate.length > 32) return true; + for (const item of candidate) { + if (item && typeof item === "object") { + queue.push(item); + } else if (typeof item === "string") { + if ( + isCyberPolicyMessage(item) + || /origin_rejected/i.test(item) + || safetyRefusalCodeFromMessage(item) + || isHardPolicyOrOriginRefusal(item, depth + 1) + ) { + return true; + } + const inner = extractJsonEnvelope(item); + if (inner && typeof inner === "object") { + queue.push(inner); } } - continue; } - const rec = candidate as Record; + continue; + } + const rec = candidate as Record; - const code = typeof rec.code === "string" ? rec.code.trim().toLowerCase().replaceAll("-", "_") : undefined; - if (code) { - if ( - isCyberPolicyCode(code) - || code === "origin_rejected" - || isTerminalRefusalCode(code) - || isNonReplayableUpstreamCode(code) - ) { - return true; - } + const code = typeof rec.code === "string" ? rec.code.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (code) { + if ( + isCyberPolicyCode(code) + || code === "origin_rejected" + || isTerminalRefusalCode(code) + || isNonReplayableUpstreamCode(code) + ) { + return true; } + } - const type = typeof rec.type === "string" ? rec.type.trim().toLowerCase().replaceAll("-", "_") : undefined; - if (type) { + const type = typeof rec.type === "string" ? rec.type.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (type) { + if ( + isCyberPolicyCode(type) + || type === "origin_rejected" + || isTerminalRefusalCode(type) + ) { + return true; + } + } + + // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason) + for (const field of ["message", "error", "details", "detail", "last_error", "response", "errors"] as const) { + const val = rec[field]; + if (typeof val === "string") { if ( - isCyberPolicyCode(type) - || type === "origin_rejected" - || isTerminalRefusalCode(type) + isCyberPolicyMessage(val) + || /origin_rejected/i.test(val) + || safetyRefusalCodeFromMessage(val) + || isHardPolicyOrOriginRefusal(val, depth + 1) ) { return true; } - } - - // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason) - for (const field of ["message", "error", "details", "detail", "last_error", "response", "errors"] as const) { - const val = rec[field]; - if (typeof val === "string") { - if ( - isCyberPolicyMessage(val) - || /origin_rejected/i.test(val) - || safetyRefusalCodeFromMessage(val) - || isHardPolicyOrOriginRefusal(val, depth + 1) - ) { - return true; - } - } else if (val && typeof val === "object") { - queue.push(val); + const inner = extractJsonEnvelope(val); + if (inner && typeof inner === "object") { + queue.push(inner); } + } else if (val && typeof val === "object") { + queue.push(val); } } - return false; } - } catch { - /* non-JSON payload handled by text matches below */ + return false; } if (isCyberPolicyMessage(text)) return true; diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 259cf3c1a7e..bfc90b7d178 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -26,8 +26,8 @@ import { join } from "node:path"; // and leaves COMBO_NAMESPACE in its temporal dead zone for entry points that start at combos/types.ts. import { atomicWriteFile } from "../config/atomic-write"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; -import { isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; -export { isHardPolicyOrOriginRefusal }; +import { extractJsonEnvelope, isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; +export { extractJsonEnvelope, isHardPolicyOrOriginRefusal }; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; @@ -461,39 +461,6 @@ const EFFORT_COMPATIBLE_TYPES = new Set([ "upstream_error", ]); -function extractJsonEnvelope(text: string): unknown | undefined { - let s = text.trim(); - for (let d = 0; d < 3; d += 1) { - if (s.startsWith("Provider error 400: ")) { - s = s.slice("Provider error 400: ".length).trim(); - } else if (s.startsWith("Provider error: ")) { - s = s.slice("Provider error: ".length).trim(); - } - if (s.startsWith("data:") && !s.includes("\n")) { - s = s.slice("data:".length).trim(); - } - } - const firstBrace = s.indexOf("{"); - const lastBrace = s.lastIndexOf("}"); - if (firstBrace !== -1 && lastBrace > firstBrace) { - try { - return JSON.parse(s.slice(firstBrace, lastBrace + 1)); - } catch { - // ignore - } - } - const firstBracket = s.indexOf("["); - const lastBracket = s.lastIndexOf("]"); - if (firstBracket !== -1 && lastBracket > firstBracket) { - try { - return JSON.parse(s.slice(firstBracket, lastBracket + 1)); - } catch { - // ignore - } - } - return undefined; -} - function isIncompleteStructuredWrapper(text: string): boolean { let s = text.trim(); for (let d = 0; d < 3; d += 1) { @@ -501,6 +468,10 @@ function isIncompleteStructuredWrapper(text: string): boolean { s = s.slice("Provider error 400: ".length).trim(); } else if (s.startsWith("Provider error: ")) { s = s.slice("Provider error: ".length).trim(); + } else if (s.startsWith("Gateway error 400: ")) { + s = s.slice("Gateway error 400: ".length).trim(); + } else if (s.startsWith("Gateway error: ")) { + s = s.slice("Gateway error: ".length).trim(); } if (s.startsWith("data:") && !s.includes("\n")) { s = s.slice("data:".length).trim(); @@ -514,7 +485,7 @@ function isIncompleteStructuredWrapper(text: string): boolean { return true; } } - if (/^(?:Provider error (?:400:\s*)?|data:\s*)[{\[]/i.test(text.trim())) { + if (/^(?:(?:Provider|Gateway) error (?:400:\s*)?|data:\s*)[{\[]/i.test(text.trim())) { return true; } const firstBrace = s.indexOf("{"); diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 9ad108bb091..10faf445c9f 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -151,6 +151,14 @@ const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ reason: "testing cybersecurity policy", }, }); +const PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"origin_rejected"}}'; +const PREFIXED_ECHOED_CYBER_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"Request blocked by upstream cybersecurity policy"}}'; +const PREFIXED_ECHOED_BIO_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"bio_policy violation"}}'; +const PREFIXED_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"code":"cyber_policy","param":"reasoning_effort","message":"Request blocked by upstream cybersecurity policy"},"request":{"input":"ordinary text"}}'; const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -932,6 +940,98 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); }); + test.each([ + ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], + ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], + ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-chat)", async (_name, payload) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(payload, { status: 400, headers: { "content-type": "application/json" } }); + } + return chatSuccess(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test.each([ + ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], + ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], + ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-responses)", async (_name, payload) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(payload, { status: 400, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + test("stops before effort replay on incomplete outer object ending with inner brace (openai-chat)", async () => { writeSnapshot(["low", "high", "max"]); const outbound: Array> = []; From ecd01a09931a89df7a0765804c2f793be400f862 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:38:44 +0600 Subject: [PATCH 22/39] fix(reasoning): fail closed on malformed bracket diagnostics and sync combo guides --- .../src/content/docs/fr/guides/combos.md | 4 +- .../src/content/docs/ja/guides/combos.md | 4 +- .../src/content/docs/ko/guides/combos.md | 4 +- .../src/content/docs/ru/guides/combos.md | 4 +- .../src/content/docs/tr/guides/combos.md | 4 +- .../src/content/docs/zh-cn/guides/combos.md | 4 +- .../src/content/docs/zh-tw/guides/combos.md | 4 +- src/providers/reasoning-metadata.ts | 41 +++++++++- ...sponses-reasoning-effort-downgrade.test.ts | 79 +++++++++++++++++++ 9 files changed, 132 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 350911638a5..680828d3edb 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -202,7 +202,7 @@ Les échecs d’un combo se répartissent entre ceux qui entraînent un **bascul | Erreur classée comme erreur d’authentification, d’abonnement, de quota, de limitation de débit, de surcharge ou de serveur en amont | Place la cible en période de refroidissement et bascule, même si le statut seul ne suffit pas. | | Annulation client (499), `origin_rejected`, refus de cyber-politique ou autre demande invalide | Arrêtez et renvoyez l'erreur ; une autre cible ne rendrait pas la demande valide. | | Dépassement de contexte confirmé avant la sortie, y compris HTTP 400 | Essaie la prochaine cible admissible non tentée sans refroidissement ; à épuisement, renvoie la première erreur de contexte HTTP 400 confirmée (un refus de contexte avec un autre statut fait aussi passer à la cible suivante, sans être conservé). | -| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, rejet d'entrée d'image propre à un modèle (`param: input`), ou refus d'un média d'entrée que la route ne peut pas lire (`unsupported_input_modality`) | Bascule vers la cible admissible suivante avant le début de la sortie, sans délai de refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. | +| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, rejet d'entrée d'image propre à un modèle (`param: input`), ou refus d'un média d'entrée que la route ne peut pas lire (`unsupported_input_modality`) | En cas de refus d'effort, tente le niveau publié immédiatement inférieur sur la même cible tant que le budget d'envoi le permet ; si aucun niveau ne fonctionne, bascule avant l'engagement de sortie sans refroidissement. Les autres incompatibilités répertoriées basculent sans refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. | | Premier appel d'outil d'un tour Responses exécuté par un adaptateur interne (`runTurn`) que la requête courante n'a pas déclaré, avant toute sortie et tout effet de bord non rejouable | Met la cible en refroidissement et bascule avec le même catalogue d'outils. Après une sortie visible ou un effet de bord non rejouable, le refus est définitif. Les requêtes Chat Completions et Anthropic Messages ne changent pas. | | Toute autre erreur non classifiée | Arrêtez et renvoyez l'erreur. | @@ -378,7 +378,7 @@ gérer un refus de politique ou corriger l’origine de la demande rejetée. Les ## Compatibilité des paramètres facultatifs -Exception aux erreurs 400 terminales : un rejet structuré de `user`, une valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, un rejet d’entrée d’image propre à un modèle (`param: input`), ou le refus d’un média d’entrée que la route ne peut pas lire (`unsupported_input_modality`) peut faire passer le combo à la cible admissible suivante avant le début de la sortie, sans délai de refroidissement. Le refus de sécurité, l’annulation et une sortie déjà commencée restent non rejouables. +Exception aux erreurs 400 terminales : un rejet structuré de `user`, une valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, un rejet d’entrée d’image propre à un modèle (`param: input`), ou le refus d’un média d’entrée que la route ne peut pas lire (`unsupported_input_modality`), ainsi qu'après l'enveloppe fournisseur exacte `{"code":"invalid-argument","error":"Invalid reasoning effort."}`. Lorsqu'une cible de combo refuse l'effort de raisonnement demandé, le combo réessaye d'abord cette cible au niveau d'effort publié immédiatement inférieur, un niveau par refus et dans la limite du budget d'envoi partagé ; cela s'applique à tout refus d'effort reconnu. Si aucun niveau inférieur ne réussit, un refus correspondant à l'une des formes ci-dessus passe à une autre cible sans refroidir la cible rejetée, en conservant le 400 initial de cette cible lorsqu'il ne reste plus d'envoi disponible pour le niveau suivant ; les autres refus d'effort restent terminaux. Une route qui refuse le média de la requête avec le code `unsupported_input_modality` avance de la même façon. Ce sont des incompatibilités pour cette requête, et non la preuve que la cible est défaillante. Les requêtes directes conservent leur réparation unique par rétrogradation d'effort. La récupération de compatibilité ne transforme pas silencieusement `none` en un effort différent et n'élargit pas le saut de combo à des requêtes invalides arbitraires. Le refus de sécurité, l’annulation et une sortie déjà commencée restent non rejouables. Une requête à cible unique renvoie toujours un rejet non résolu en amont. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 9823756979f..7c4ae0d9514 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -129,7 +129,7 @@ ocx combo set balanced \ |クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 | | 出力前に確認したコンテキスト超過(HTTP 400 を含む) | クールダウンせず次の未試行の適格な対象へ進み、使い切った場合は最初に確認した HTTP 400 のコンテキストエラーを返します(他のステータスのコンテキスト拒否も次へ進みますが、保持されません)。 | | ChatGPT アカウントで Codex を使う際に、そのモデルをサポートしないことを示す正確な HTTP 400 の拒否 | 出力を確定する前に、次の設定済みターゲットを試します。拒否文全体が `detail`、`error.message`、または本文そのものにある場合に限ります。`detail` と `error` が競合する応答は終了扱いです。この拒否だけでアカウントを再認証待ちにしたり、プロバイダーの全モデルをクールダウンしたりしません。 | -|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化 HTTP 400 |出力開始前に次の適格なターゲットへ進み、クールダウンを記録しません。任意パラメーターの互換性を参照してください。 | +|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化 HTTP 400 |推論エフォートの拒否の場合、送信バジェットが許す限り同じターゲットで次に低い公開ラングを試行します。どのラングも機能しない場合は、出力確定前にクールダウンなしで次のターゲットへ進みます。その他の記載された不一致はクールダウンなしで進みます。任意パラメーターの互換性を参照してください。 | |インプロセスアダプター(`runTurn`)が実行する Responses ターンで、現在のリクエストが宣言していない最初のツール呼び出し(出力やリプレイ不可の副作用より前) |ターゲットをクールダウンし、同じツールカタログで次へ進みます。可視出力やリプレイ不可の副作用の後は拒否が確定します。Chat Completions と Anthropic Messages のリクエストは変わりません。 | |その他の未分類のエラー |停止してエラーを返します。 | @@ -275,7 +275,7 @@ ocx combo remove --yes ## 任意パラメーターの互換性 -通常の 400 エラーは終了扱いですが、`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化エラーでは、出力開始前に次の適格なターゲットへ進めます。この不一致ではクールダウンを記録しません。安全ポリシーによる拒否、キャンセル、出力開始後の再実行は禁止のままです。 +通常の 400 エラーは終了扱いですが、`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化エラー、およびベンダーエンベロープ `{"code":"invalid-argument","error":"Invalid reasoning effort."}` では、出力確定前に次の適格なターゲットへ進めます。コンボターゲットが要求された推論エフォートを拒否した場合、コンボはまずそのターゲットで次に低い公開エフォートラングを、拒否ごとに1ラングずつ、共有送信バジェットの範囲内で再試行します。これは認識されたすべてのエフォート拒否に適用されます。より低いラングがいずれも成功しない場合、上記のいずれかの形式の拒否は拒否されたターゲットを冷却することなく別のターゲットへ進み、次のラングに送信枠が残っていない場合はそのターゲットの元の 400 を保持します。その他のエフォート拒否は終了扱いのままです。メディアを `unsupported_input_modality` で拒否するルートも同様に進みます。これらはこのリクエストに対する不一致であり、ターゲットが異常である証拠ではありません。直接リクエストは単一のエフォートダウングレード修復を保持します。互換性回復によって `none` が別のエフォートに暗黙に変更されたり、任意の無効なリクエストにコンボホップが拡大されたりすることはありません。安全ポリシーによる拒否、キャンセル、出力開始後の再実行は禁止のままです。単一ターゲットのリクエストは未解決の上流拒否を返します。 [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b81448df36b..98b2164ad67 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -135,7 +135,7 @@ ocx combo set balanced \ | 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. | | 출력 전 확인된 컨텍스트 초과 오류(HTTP 400 포함) | 쿨다운 없이 다음 미시도 적격 대상을 시도합니다. 모두 실패하면 처음 확인된 HTTP 400 컨텍스트 오류를 반환합니다(다른 상태 코드의 컨텍스트 거부도 다음 대상으로 넘어가지만 보관되지 않습니다). | | ChatGPT 계정으로 Codex를 사용할 때 해당 모델을 지원하지 않는다는 정확한 HTTP 400 거부 | 출력이 시작되기 전에 다음 설정 대상으로 넘어갑니다. 거부 문장 전체가 `detail`, `error.message` 또는 일반 텍스트에 있어야 하며, `detail`과 `error`가 충돌하면 중단합니다. 이 사유만으로 계정을 재인증 상태로 바꾸거나 공급자의 모든 모델에 쿨다운을 걸지는 않습니다. | -| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 HTTP 400 | 출력 시작 전에 쿨다운 기록 없이 다음 적격 대상으로 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | +| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 HTTP 400 | 추론 노력 거부의 경우 전송 예산이 허용하는 동안 동일한 대상에서 다음으로 낮은 게시된 단계를 시도합니다. 작동하는 단계가 없으면 출력 확정 전에 쿨다운 없이 다음 대상으로 넘어갑니다. 기타 나열된 불일치는 쿨다운 없이 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | | 인프로세스 어댑터(`runTurn`)가 실행하는 Responses 턴에서 현재 요청이 선언하지 않은 첫 도구 호출(출력이나 재전송 불가 부작용 이전) | 대상을 쿨다운하고 같은 도구 카탈로그로 다음 대상으로 넘어갑니다. 출력이 보였거나 재전송 불가 부작용이 생긴 뒤에는 거부가 그대로 확정됩니다. Chat Completions와 Anthropic Messages 요청은 바뀌지 않습니다. | | 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. | @@ -291,7 +291,7 @@ opencodex 인스턴스에 기록했는지 확인하세요. ## 선택적 매개변수 호환성 -일반적인 400 오류는 종료되지만, `user`를 명시적으로 거부하거나 `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 오류는 출력 시작 전에 다음 적격 대상으로 넘어갈 수 있습니다. 이 경우 쿨다운을 기록하지 않습니다. 보안 정책 거부, 취소 및 이미 시작된 출력은 재실행하지 않습니다. +일반적인 400 오류는 종료되지만, `user`를 명시적으로 거부하거나 `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 오류 및 정확한 벤더 엔벨로프 `{"code":"invalid-argument","error":"Invalid reasoning effort."}`의 경우 출력 확정 전에 다음 적격 대상으로 넘어갈 수 있습니다. 콤보 대상이 요청된 추론 노력을 거부하면, 콤보는 먼저 해당 대상에 대해 공유 전송 예산 내에서 거부당 한 단계씩 다음으로 낮은 게시된 노력 단계로 재시도합니다. 이는 인식된 모든 노력 거부에 적용됩니다. 더 낮은 단계가 모두 성공하지 못하면 위의 형태 중 하나에 해당하는 거부는 거부된 대상을 냉각하지 않고 다른 대상으로 넘어가며, 다음 단계에 남은 전송이 없을 때 해당 대상의 원래 400을 유지합니다. 기타 노력 거부는 종료 상태로 유지됩니다. `unsupported_input_modality` 코드로 요청의 미디어를 거부하는 경로도 동일하게 진행됩니다. 이는 이 요청에 대한 불일치일 뿐 대상이 비정상이라는 증거가 아닙니다. 직접 요청은 단일 노력 다운그레이드 복구를 유지합니다. 호환성 복구는 `none`을 다른 노력으로 자동으로 변경하거나 임의의 잘못된 요청으로 콤보 홉을 넓히지 않습니다. 보안 정책 거부, 취소 및 이미 시작된 출력은 재실행하지 않습니다. 단일 대상 요청은 여전히 해결되지 않은 업스트림 거부를 반환합니다. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 5991f1a868c..3aa9d132f75 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -166,7 +166,7 @@ ocx combo set balanced \ | Отмена клиентом (499), `origin_rejected`, отказ из-за cyber-policy или иной некорректный запрос | Остановиться и вернуть ошибку; другая цель не сделает такой запрос корректным. | | Подтверждённое превышение контекста до вывода, включая HTTP 400 | Попробовать следующую допустимую цель без cooldown; при исчерпании вернуть первую подтверждённую ошибку контекста HTTP 400 (отказ по контексту с другим статусом тоже переводит к следующей цели, но не сохраняется). | | Точный отказ HTTP 400 о том, что модель не поддерживается в Codex с аккаунтом ChatGPT | До начала вывода пробует следующую настроенную цель. Полная фраза отказа должна находиться в `detail`, `error.message` или обычном тексте; смешанные объекты `detail` и `error` завершают запрос. Сам этот отказ не переводит аккаунт в состояние повторного входа и не включает период ожидания для всех моделей провайдера. | -| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) | До начала вывода переходит к следующей допустимой цели без охлаждения; см. «Совместимость необязательных параметров» ниже. | +| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) | При отказе в усилиях рассуждения (reasoning effort) пробует следующую более низкую опубликованную ступень на той же цели, пока позволяет бюджет отправок; если ни одна ступень не подошла, переходит к следующей цели до фиксации вывода без охлаждения. Другие перечисленные несоответствия переходят без охлаждения; см. «Совместимость необязательных параметров» ниже. | | Первый вызов инструмента в Responses-ходе внутрипроцессного адаптера (`runTurn`), который текущий запрос не объявлял, до любого вывода и до побочного эффекта, небезопасного для повтора | Охлаждает цель и переходит к следующей с тем же каталогом инструментов. После видимого вывода или небезопасного для повтора побочного эффекта отказ окончателен. Запросы Chat Completions и Anthropic Messages не меняются. | | Любая другая неклассифицированная ошибка | Остановиться и вернуть ошибку. | @@ -336,7 +336,7 @@ CLI и дашборд показывают точное сообщение ва ## Совместимость необязательных параметров -Исключение для завершающих ошибок 400: структурированный отказ от `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) позволяет до начала вывода перейти к следующей допустимой цели без периода охлаждения. Отказ политики безопасности, отмена и уже начавшийся вывод по-прежнему запрещают повторное выполнение. +Исключение для завершающих ошибок 400: структурированный отказ от `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`), а также точный конверт поставщика `{"code":"invalid-argument","error":"Invalid reasoning effort."}` позволяют до начала вывода перейти к следующей допустимой цели. Когда цель combo отклоняет запрошенное усилие рассуждения, combo сначала повторяет попытку для этой цели на следующей более низкой опубликованной ступени усилий (по одной ступени на отказ в рамках общего бюджета отправок); это относится к любому распознанному отказу в усилиях. Если ни одна более низкая ступень не увенчалась успехом, отказ одной из указанных выше форм переходит к другой цели без охлаждения отклоненной цели, сохраняя исходную ошибку 400 этой цели, когда для следующей ступени не осталось отправок; другие отказы в усилиях остаются завершающими. Маршрут, отклоняющий медиаданные запроса с кодом `unsupported_input_modality`, продвигается точно так же. Это несоответствия для данного запроса, а не признак неработоспособности цели. Прямые запросы сохраняют единичное восстановление с понижением усилий. Восстановление совместимости не заменяет неявно `none` на другое усилие и не расширяет переход combo на произвольные недействительные запросы. Отказ политики безопасности, отмена и уже начавшийся вывод по-прежнему запрещают повторное выполнение. Запрос к одной цели по-прежнему возвращает неразрешенный отказ вышестоящего сервиса. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index da74bcfd061..9b9a5b5eca8 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -231,7 +231,7 @@ ikiye ayrılır. | Sınıflandırılmış kimlik doğrulama, abonelik, kota, hız sınırı, aşırı yük veya yukarı akış sunucu hatası | Yalnızca durum yeterli olmadığında bile hedefi soğutun ve atlayın. | | İstemci iptali (499), `origin_rejected`, siber politika reddi veya diğer geçersiz istek | Durun ve hatayı döndürün; başka bir hedef isteği geçerli kılmaz. | | Çıktıdan önce doğrulanan bağlam aşımı (HTTP 400 dahil) | Soğutma olmadan denenmemiş uygun hedefe geçer; hedefler tükenirse ilk doğrulanan HTTP 400 bağlam hatasını döndürür (başka durum koduyla gelen bağlam reddi de sonraki hedefe geçirir ama saklanmaz). | -| `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış HTTP 400 | Çıktı başlamadan önce bekleme süresi kaydetmeden sonraki uygun hedefe atlar; aşağıdaki isteğe bağlı parametre uyumluluğuna bakın. | +| `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış HTTP 400 | Bir efor reddi için, gönderim bütçesi izin verdiği sürece aynı hedefte bir sonraki daha düşük yayınlanmış basamağı deneyin; hiçbir basamak çalışmazsa çıktı taahhüt edilmeden önce bekleme süresi kaydetmeden atlayın. Listelenen diğer uyuşmazlıklar bekleme süresi olmadan atlar; aşağıdaki isteğe bağlı parametre uyumluluğuna bakın. | | Süreç içi bir bağdaştırıcının (`runTurn`) yürüttüğü Responses turunda, geçerli isteğin bildirmediği ilk araç çağrısı (herhangi bir çıktıdan ve yeniden oynatılamaz yan etkiden önce) | Hedefi bekleme süresine alır ve aynı araç kataloğuyla sonraki hedefe atlar. Görünür çıktıdan veya yeniden oynatılamaz bir yan etkiden sonra ret kesindir. Chat Completions ve Anthropic Messages istekleri değişmez. | | Diğer sınıflandırılmamış hatalar | Durun ve hatayı döndürün. | @@ -417,7 +417,7 @@ reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapma ## İsteğe bağlı parametre uyumluluğu -Sonlandırıcı 400 hatalarının dar bir istisnası vardır: `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış hata, çıktı başlamadan önce sonraki uygun hedefe geçebilir. Bu uyumsuzluk için bekleme süresi kaydedilmez. Güvenlik politikası reddi, iptal ve başlamış çıktı yeniden yürütülmez. +Sonlandırıcı 400 hatalarının dar bir istisnası vardır: `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış hata ile tam satıcı zarfı `{"code":"invalid-argument","error":"Invalid reasoning effort."}` , çıktı başlamadan önce sonraki uygun hedefe geçebilir. Bir combo hedefi istenen akıl yürütme eforunu reddettiğinde, combo önce paylaşılan gönderim bütçesi dahilinde her ret için bir basamak olmak üzere bu hedefi bir sonraki daha düşük yayınlanmış efor basamağında yeniden dener; bu tanınan tüm efor retleri için geçerlidir. Daha düşük hiçbir basamak başarılı olmazsa, yukarıdaki şekillerden birindeki ret, reddedilen hedefi soğutmadan başka bir hedefe ilerler ve bir sonraki basamağın gönderim hakkı kalmadığında hedefin orijinal 400 hatasını korur; diğer efor retleri sonlandırıcı kalır. Medyayı `unsupported_input_modality` ile reddeden bir rota da aynı şekilde ilerler. Bunlar bu istek için uyuşmazlıklardır, hedefin sağlıksız olduğuna dair kanıt değildir. Doğrudan istekler tek efor düşürme onarımlarını korur. Uyumluluk kurtarması sessizce `none` değerini farklı bir efora dönüştürmez veya combo atlamasını rastgele geçersiz isteklere genişletmez. Güvenlik politikası reddi, iptal ve başlamış çıktı yeniden yürütülmez. Tek hedefli bir istek yine de çözülmemiş bir yukarı akış reddi döndürür. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 7262139cb71..b171b72197c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -155,7 +155,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 | 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、或其他无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 | | 输出前确认的上下文超限(包括 HTTP 400) | 不冷却该目标,尝试下一个尚未尝试的合格目标;耗尽后返回第一个确认的 HTTP 400 上下文错误(其他状态码的上下文拒绝也会切换到下一个目标,但不会被保留)。 | | HTTP 400 明确表示使用 ChatGPT 账户运行 Codex 时不支持该模型 | 在输出提交前尝试下一个已配置目标。完整拒绝句必须位于 `detail`、`error.message` 或纯文本中;同时包含 `detail` 与 `error` 的混合封装仍会终止请求。此原因本身不会将账户标为需要重新登录,也不会让供应商的所有模型进入冷却。 | -| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`) | 在输出开始前跳转到下一个符合条件的目标,且不记录冷却时间;参见下方可选参数兼容性。 | +| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`) | 对于 reasoning effort 拒绝,在发送预算允许的情况下先在同一目标上尝试下一个更低的已发布档位;若所有档位均未成功,则在提交输出前跳转到下一目标且不记录冷却时间。其他列出的不匹配直接跳转且不记录冷却时间;参见下方可选参数兼容性。 | | 由进程内适配器(`runTurn`)执行的 Responses 回合中,当前请求未声明的第一个工具调用(在任何输出和不可重放的副作用之前) | 让该目标进入冷却,并以相同的工具目录跳转到下一个目标。出现可见输出或不可重放的副作用之后,拒绝即为最终结果。Chat Completions 和 Anthropic Messages 请求不受影响。 | | 任何其他未分类错误 | 停止并返回错误。 | @@ -305,7 +305,7 @@ combo id 不存在。响应是 HTTP 404,类型为 `invalid_request_error`。 ## 可选参数兼容性 -一般 400 错误仍会终止请求,但明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`)的结构化错误,可让 combo 在输出开始前尝试下一个符合条件的目标,而不记录冷却时间。安全策略拒绝、取消以及已经开始的输出仍不可重放。 +一般 400 错误仍会终止请求,但明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`)的结构化错误,以及完全匹配的供应商包 `{"code":"invalid-argument","error":"Invalid reasoning effort."}`,可在输出提交前跳转。当 combo 目标拒绝所请求的 reasoning effort 时,combo 首先在共享发送预算内、每次拒绝退一个档位地在该目标上重试下一个更低的已发布 effort 档位;这适用于所有识别出的 effort 拒绝。如果更低档位均未成功,符合上述形式之一的拒绝将前进到另一个目标而不冷却被拒绝的目标,在下一档位无剩余发送额度时保留该目标的原始 400;其他 effort 拒绝保持终止。以 `unsupported_input_modality` 拒绝媒体的路由以相同方式跳转。这些是针对该请求的不匹配,并非目标不健康的证据。直接请求保留其单次 effort 降级修复。兼容性恢复不会隐式将 `none` 改为其他 effort,也不会将 combo 跳转扩大到任意无效请求。安全策略拒绝、取消以及已经开始的输出仍不可重放。单目标请求仍返回未解决的上游拒绝。 [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 6d90897006d..01666e88c6e 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -166,7 +166,7 @@ Combo 失敗分為**跳轉**失敗與**終端**失敗。 | 分類為認證、訂閱、配額、限流、過載或上游伺服器錯誤 | 冷卻目標並跳轉,即使單靠狀態碼不足。 | | 客戶端取消(499)、`origin_rejected`、cyber-policy 拒絕、其他無效請求 | 停止並回傳錯誤;另一個目標不會讓請求變為有效。 | | 輸出前確認的上下文超限(包括 HTTP 400) | 不冷卻該目標,嘗試下一個尚未嘗試的合格目標;耗盡後回傳第一個確認的 HTTP 400 上下文錯誤(其他狀態碼的上下文拒絕也會切換到下一個目標,但不會被保留)。 | -| 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`) | 在輸出開始前跳轉到下一個符合條件的目標,且不記錄冷卻時間;參見下方選用參數相容性。 | +| 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`) | 對於 reasoning effort 拒絕,在發送預算允許的情況下先在同一目標上嘗試下一個更低的已發布檔位;若所有檔位均未成功,則在提交輸出前跳轉到下一目標且不記錄冷卻時間。其他列出的不相容直接跳轉且不記錄冷卻時間;參見下方選用參數相容性。 | | 由行程內轉接器(`runTurn`)執行的 Responses 回合中,目前請求未宣告的第一個工具呼叫(在任何輸出與不可重播的副作用之前) | 讓該目標進入冷卻,並以相同的工具目錄跳轉到下一個目標。出現可見輸出或不可重播的副作用之後,拒絕即為最終結果。Chat Completions 與 Anthropic Messages 請求不受影響。 | | 任何其他未分類錯誤 | 停止並回傳錯誤。 | @@ -300,7 +300,7 @@ Combo id 未知。回應為 HTTP 404 並帶 type `invalid_request_error`。執 ## 選用參數相容性 -一般 400 錯誤仍會終止請求,但明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`)的結構化錯誤,可讓 combo 在輸出開始前嘗試下一個符合條件的目標,而不記錄冷卻時間。安全政策拒絕、取消及已開始的輸出仍不可重播。 +一般 400 錯誤仍會終止請求,但明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`)的結構化錯誤,以及完全相符的供應商包 `{"code":"invalid-argument","error":"Invalid reasoning effort."}`,可在輸出提交前跳轉。當 combo 目標拒絕所請求的 reasoning effort 時,combo 首先在共用發送預算內、每次拒絕退一個檔位地在該目標上重試下一個更低的已發布 effort 檔位;這適用於所有識別出的 effort 拒絕。如果更低檔位均未成功,符合上述形式之一的拒絕將前進到另一個目標而不冷卻被拒絕的目標,在下一檔位無剩餘發送額度時保留該目標的原始 400;其他 effort 拒絕保持終止。以 `unsupported_input_modality` 拒絕媒體的路由以相同方式跳轉。這些是針對該請求的不相容,並非目標不健康的證據。直接請求保留其單次 effort 降級修復。相容性復原不會隱式將 `none` 改為其他 effort,也不會將 combo 跳轉擴大到任意無效請求。安全政策拒絕、取消及已開始的輸出仍不可重播。單目標請求仍回傳未解決的上游拒絕。 [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index bfc90b7d178..8922819a3f4 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -522,6 +522,41 @@ function isIncompleteStructuredWrapper(text: string): boolean { } } } + const firstBracket = s.indexOf("["); + const lastBracket = s.lastIndexOf("]"); + if (firstBracket !== -1) { + let openBrackets = 0; + let inString = false; + let escape = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (!inString) { + if (ch === "[") openBrackets += 1; + else if (ch === "]") openBrackets -= 1; + } + } + if (openBrackets !== 0) return true; + const bracketSlice = s.slice(firstBracket, lastBracket + 1); + if (lastBracket > firstBracket && (bracketSlice.includes('"') || bracketSlice.includes(":"))) { + try { + JSON.parse(bracketSlice); + } catch { + return true; + } + } + } return false; } @@ -559,7 +594,8 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo if (!parsed || typeof parsed !== "object") { if (isIncompleteStructuredWrapper(unescaped)) return true; const hasBraces = unescaped.includes("{") && unescaped.includes("}"); - if (hasBraces) return true; + const hasBrackets = unescaped.includes('["') || (unescaped.includes("[") && unescaped.includes("]") && unescaped.includes('"')); + if (hasBraces || hasBrackets) return true; return /\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(unescaped); } try { @@ -898,7 +934,8 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): } const hasBraces = unescaped.includes("{") && unescaped.includes("}"); - if (hasBraces) return false; + const hasBrackets = unescaped.includes('["') || (unescaped.includes("[") && unescaped.includes("]") && unescaped.includes('"')); + if (hasBraces || hasBrackets || isIncompleteStructuredWrapper(unescaped)) return false; return diagnosticTextBlamesReasoningEffort(unescaped); } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 10faf445c9f..3efc36b9c08 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -80,6 +80,8 @@ const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ }); const BALANCED_MALFORMED_JSON_REFUSAL = 'Gateway error 400: {"code":"unknown_terminal_code","message":"Unsupported value for reasoning_effort", invalid_json: }'; +const BALANCED_MALFORMED_BRACKET_REFUSAL = + 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort",]'; const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ param: "reasoning_effort", @@ -761,6 +763,8 @@ describe("rejected reasoning rungs", () => { expect(hasConflictingReasoningEffortCode(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL)).toBe(true); expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(true); expect(isReasoningEffortRejection(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(VENDOR_EFFORT_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(JSON.stringify({ param: "reasoning_effort", @@ -1660,4 +1664,79 @@ describe("rejected reasoning rungs", () => { { host: "opencode.ai", effort: "max" }, ]); }); + + test("stops before effort replay on balanced malformed bracket refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed bracket refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("combo stops immediately on balanced malformed bracket refusal without walking ladder", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("unknown_terminal_code"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + }); }); From ac8b6f65d871502167046289892b833d36917f68 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:54:40 +0600 Subject: [PATCH 23/39] fix(reasoning): preserve hard-refusal envelope prefixes and parse serialized diagnostics first --- src/lib/hard-refusal.ts | 142 ++++++++++--- ...sponses-reasoning-effort-downgrade.test.ts | 194 ++++++++++++++++++ 2 files changed, 308 insertions(+), 28 deletions(-) diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts index fa1961411ff..c7202db0c5a 100644 --- a/src/lib/hard-refusal.ts +++ b/src/lib/hard-refusal.ts @@ -9,23 +9,50 @@ import { isNonReplayableUpstreamCode } from "./upstream-retry"; const SERIALIZED_HARD_CODE = /\bcode\s*[:=]\s*["']?(?:cyber_policy|origin_rejected|bio_policy|misalignment_policy_violation|invalid_prompt|upstream_no_response|upstream_closed_before_response|upstream_reset_replay_refused)\b/i; +export interface ExtractedJsonEnvelope { + value: unknown; + prefix: string; + suffix: string; +} + +const HARD_REFUSAL_TOKEN = + /\b(?:cyber_policy|origin_rejected|bio_policy|misalignment_policy_violation|invalid_prompt)\b/i; + +function isAuthoritativeHardRefusalProse(str: string | undefined): boolean { + if (!str) return false; + const s = str.trim(); + if (!s) return false; + if (isCyberPolicyMessage(s)) return true; + if (/origin_rejected/i.test(s)) return true; + if (HARD_REFUSAL_TOKEN.test(s)) return true; + if (SERIALIZED_HARD_CODE.test(s)) return true; + if (safetyRefusalCodeFromMessage(s)) return true; + return false; +} + /** - * Extract a balanced or prefixed JSON object/array envelope from error text. - * Strips common gateway prefixes and extracts content between the outermost JSON delimiters. + * Extract a balanced or prefixed JSON object/array envelope from error text, + * capturing any authoritative diagnostic prefix and suffix outside the JSON delimiters. */ -export function extractJsonEnvelope(text: string): unknown | undefined { +export function extractJsonEnvelopeWithContext(text: string): ExtractedJsonEnvelope | undefined { let s = text.trim(); + let strippedPrefix = ""; for (let d = 0; d < 3; d += 1) { if (s.startsWith("Provider error 400: ")) { + strippedPrefix += s.slice(0, "Provider error 400: ".length); s = s.slice("Provider error 400: ".length).trim(); } else if (s.startsWith("Provider error: ")) { + strippedPrefix += s.slice(0, "Provider error: ".length); s = s.slice("Provider error: ".length).trim(); } else if (s.startsWith("Gateway error 400: ")) { + strippedPrefix += s.slice(0, "Gateway error 400: ".length); s = s.slice("Gateway error 400: ".length).trim(); } else if (s.startsWith("Gateway error: ")) { + strippedPrefix += s.slice(0, "Gateway error: ".length); s = s.slice("Gateway error: ".length).trim(); } if (s.startsWith("data:") && !s.includes("\n")) { + strippedPrefix += s.slice(0, "data:".length); s = s.slice("data:".length).trim(); } } @@ -33,7 +60,12 @@ export function extractJsonEnvelope(text: string): unknown | undefined { const lastBrace = s.lastIndexOf("}"); if (firstBrace !== -1 && lastBrace > firstBrace) { try { - return JSON.parse(s.slice(firstBrace, lastBrace + 1)); + const value = JSON.parse(s.slice(firstBrace, lastBrace + 1)); + return { + value, + prefix: (strippedPrefix + " " + s.slice(0, firstBrace)).trim(), + suffix: s.slice(lastBrace + 1).trim(), + }; } catch { // ignore } @@ -42,7 +74,12 @@ export function extractJsonEnvelope(text: string): unknown | undefined { const lastBracket = s.lastIndexOf("]"); if (firstBracket !== -1 && lastBracket > firstBracket) { try { - return JSON.parse(s.slice(firstBracket, lastBracket + 1)); + const value = JSON.parse(s.slice(firstBracket, lastBracket + 1)); + return { + value, + prefix: (strippedPrefix + " " + s.slice(0, firstBracket)).trim(), + suffix: s.slice(lastBracket + 1).trim(), + }; } catch { // ignore } @@ -50,6 +87,14 @@ export function extractJsonEnvelope(text: string): unknown | undefined { return undefined; } +/** + * Extract a balanced or prefixed JSON object/array envelope from error text. + * Strips common gateway prefixes and extracts content between the outermost JSON delimiters. + */ +export function extractJsonEnvelope(text: string): unknown | undefined { + return extractJsonEnvelopeWithContext(text)?.value; +} + /** * Detect a hard policy or origin refusal across known upstream envelope shapes and diagnostic text. * When an error is a hard refusal (e.g. cyber_policy, origin_rejected, bio_policy), recovery must @@ -81,10 +126,24 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) // so echoed user request content (e.g. request.input, prompt, echoed body) does not // suppress valid recoveries (#8173). let parsed: unknown; + let hasOuterRefusal = false; try { parsed = JSON.parse(unescaped); } catch { - parsed = extractJsonEnvelope(unescaped); + const extracted = extractJsonEnvelopeWithContext(unescaped); + if (extracted) { + parsed = extracted.value; + if ( + isAuthoritativeHardRefusalProse(extracted.prefix) + || isAuthoritativeHardRefusalProse(extracted.suffix) + ) { + hasOuterRefusal = true; + } + } + } + + if (hasOuterRefusal) { + return true; } if (parsed && typeof parsed === "object") { @@ -100,17 +159,31 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) if (item && typeof item === "object") { queue.push(item); } else if (typeof item === "string") { - if ( - isCyberPolicyMessage(item) - || /origin_rejected/i.test(item) - || safetyRefusalCodeFromMessage(item) - || isHardPolicyOrOriginRefusal(item, depth + 1) - ) { + let itemParsed: unknown; + let itemHasOuterRefusal = false; + try { + itemParsed = JSON.parse(item); + } catch { + const itemExtracted = extractJsonEnvelopeWithContext(item); + if (itemExtracted) { + itemParsed = itemExtracted.value; + if ( + isAuthoritativeHardRefusalProse(itemExtracted.prefix) + || isAuthoritativeHardRefusalProse(itemExtracted.suffix) + ) { + itemHasOuterRefusal = true; + } + } + } + + if (itemHasOuterRefusal) { return true; } - const inner = extractJsonEnvelope(item); - if (inner && typeof inner === "object") { - queue.push(inner); + + if (itemParsed && typeof itemParsed === "object") { + queue.push(itemParsed); + } else if (isAuthoritativeHardRefusalProse(item)) { + return true; } } } @@ -141,21 +214,36 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) } } - // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason) + // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason). + // Parse structured diagnostic strings first so nested request echo is not matched by prose rules (#8173). for (const field of ["message", "error", "details", "detail", "last_error", "response", "errors"] as const) { const val = rec[field]; if (typeof val === "string") { - if ( - isCyberPolicyMessage(val) - || /origin_rejected/i.test(val) - || safetyRefusalCodeFromMessage(val) - || isHardPolicyOrOriginRefusal(val, depth + 1) - ) { + let innerParsed: unknown; + let innerHasOuterRefusal = false; + try { + innerParsed = JSON.parse(val); + } catch { + const innerExtracted = extractJsonEnvelopeWithContext(val); + if (innerExtracted) { + innerParsed = innerExtracted.value; + if ( + isAuthoritativeHardRefusalProse(innerExtracted.prefix) + || isAuthoritativeHardRefusalProse(innerExtracted.suffix) + ) { + innerHasOuterRefusal = true; + } + } + } + + if (innerHasOuterRefusal) { return true; } - const inner = extractJsonEnvelope(val); - if (inner && typeof inner === "object") { - queue.push(inner); + + if (innerParsed && typeof innerParsed === "object") { + queue.push(innerParsed); + } else if (isAuthoritativeHardRefusalProse(val)) { + return true; } } else if (val && typeof val === "object") { queue.push(val); @@ -165,9 +253,7 @@ export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0) return false; } - if (isCyberPolicyMessage(text)) return true; - if (/origin_rejected/i.test(text)) return true; - if (SERIALIZED_HARD_CODE.test(text)) return true; + if (isAuthoritativeHardRefusalProse(unescaped)) return true; return false; } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 3efc36b9c08..a44e75fc581 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -161,6 +161,32 @@ const PREFIXED_ECHOED_BIO_POLICY_REFUSAL = 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"bio_policy violation"}}'; const PREFIXED_POLICY_REFUSAL = 'Gateway error 400: {"error":{"code":"cyber_policy","param":"reasoning_effort","message":"Request blocked by upstream cybersecurity policy"},"request":{"input":"ordinary text"}}'; +const HARD_REFUSAL_PREFIX_ENVELOPE = + 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; +const NESTED_WRAPPED_ECHO_ORIGIN_REJECTED = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "origin_rejected" }, + }), + }, +}); +const NESTED_WRAPPED_ECHO_ORDINARY = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "ordinary input prompt" }, + }), + }, +}); +const NESTED_WRAPPED_HARD_REFUSAL_PREFIX = JSON.stringify({ + error: { + type: "upstream_error", + message: 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -1036,6 +1062,174 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); + } + return chatSuccess(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message has ordinary request input (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); + } + return chatSuccess(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message has ordinary request input (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(response.status).toBe(200); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + test("stops before effort replay on incomplete outer object ending with inner brace (openai-chat)", async () => { writeSnapshot(["low", "high", "max"]); const outbound: Array> = []; From 582583f1a3b41daa9ae607537c1ba71109507d69 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:21:09 +0600 Subject: [PATCH 24/39] fix(reasoning): preserve conflicting envelope prefix text and qualify combo guide summaries --- .../src/content/docs/fr/guides/combos.md | 2 +- docs-site/src/content/docs/guides/combos.md | 2 +- .../src/content/docs/ja/guides/combos.md | 2 +- .../src/content/docs/ko/guides/combos.md | 2 +- .../src/content/docs/ru/guides/combos.md | 2 +- .../src/content/docs/tr/guides/combos.md | 2 +- .../src/content/docs/zh-cn/guides/combos.md | 2 +- .../src/content/docs/zh-tw/guides/combos.md | 2 +- src/providers/reasoning-metadata.ts | 86 ++++++++++++++++--- ...sponses-reasoning-effort-downgrade.test.ts | 51 +++++++++++ 10 files changed, 135 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 680828d3edb..e3dd0a32982 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -202,7 +202,7 @@ Les échecs d’un combo se répartissent entre ceux qui entraînent un **bascul | Erreur classée comme erreur d’authentification, d’abonnement, de quota, de limitation de débit, de surcharge ou de serveur en amont | Place la cible en période de refroidissement et bascule, même si le statut seul ne suffit pas. | | Annulation client (499), `origin_rejected`, refus de cyber-politique ou autre demande invalide | Arrêtez et renvoyez l'erreur ; une autre cible ne rendrait pas la demande valide. | | Dépassement de contexte confirmé avant la sortie, y compris HTTP 400 | Essaie la prochaine cible admissible non tentée sans refroidissement ; à épuisement, renvoie la première erreur de contexte HTTP 400 confirmée (un refus de contexte avec un autre statut fait aussi passer à la cible suivante, sans être conservé). | -| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, rejet d'entrée d'image propre à un modèle (`param: input`), ou refus d'un média d'entrée que la route ne peut pas lire (`unsupported_input_modality`) | En cas de refus d'effort, tente le niveau publié immédiatement inférieur sur la même cible tant que le budget d'envoi le permet ; si aucun niveau ne fonctionne, bascule avant l'engagement de sortie sans refroidissement. Les autres incompatibilités répertoriées basculent sans refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. | +| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, rejet d'entrée d'image propre à un modèle (`param: input`), ou refus d'un média d'entrée que la route ne peut pas lire (`unsupported_input_modality`) | En cas de refus d'effort, tente le niveau publié immédiatement inférieur sur la même cible tant que le budget d'envoi le permet ; si aucun niveau ne fonctionne, bascule avant l'engagement de sortie sans refroidissement si le refus correspond à une enveloppe de compatibilité reconnue, sinon reste terminal. Les autres incompatibilités répertoriées basculent sans refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. | | Premier appel d'outil d'un tour Responses exécuté par un adaptateur interne (`runTurn`) que la requête courante n'a pas déclaré, avant toute sortie et tout effet de bord non rejouable | Met la cible en refroidissement et bascule avec le même catalogue d'outils. Après une sortie visible ou un effet de bord non rejouable, le refus est définitif. Les requêtes Chat Completions et Anthropic Messages ne changent pas. | | Toute autre erreur non classifiée | Arrêtez et renvoyez l'erreur. | diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index c4911d2f222..906c625b1fa 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -682,7 +682,7 @@ provider, see [Fallback off](/guides/fallback-off/). | Confirmed context-window overflow before output, including HTTP 400 | Try the next untried eligible target without cooling this target; another model may have a larger window. Exhaustion returns the first confirmed HTTP 400 context-length error; a context refusal sent with another status still advances but is not retained. | | Client cancellation (499), `origin_rejected`, cyber-policy refusal, or other invalid request | Stop and return the error; another target would not make the request valid. | | Exact HTTP 400 saying a model is not supported when using Codex with a ChatGPT account | Try the next declared target before output commitment. The message must be the whole refusal in `detail`, `error.message`, or bare text; competing `detail` and `error` envelopes stay terminal. This does not quarantine the account or cool every model on the provider. | -| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | For an effort refusal, try the next lower published rung on the same target while the send budget allows; if no rung works, hop before output commitment without cooling. Other listed mismatches hop without cooling; see request-local target compatibility below. | +| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | For an effort refusal, try the next lower published rung on the same target while the send budget allows; if no rung works, hop before output commitment without cooling if the refusal matches a recognized compatibility shape, otherwise stay terminal. Other listed mismatches hop without cooling; see request-local target compatibility below. | | First tool call of a Responses turn run by an in-process adapter (`runTurn`) that the current request did not declare, before any output or replay-unsafe side effect | Cool the target and hop with the same tool catalog. After visible output or a replay-unsafe side effect the refusal is final. Chat Completions and Anthropic Messages requests are unchanged. | | Any other unclassified error | Stop and return the error. | diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 7c4ae0d9514..3c48d990d81 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -129,7 +129,7 @@ ocx combo set balanced \ |クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 | | 出力前に確認したコンテキスト超過(HTTP 400 を含む) | クールダウンせず次の未試行の適格な対象へ進み、使い切った場合は最初に確認した HTTP 400 のコンテキストエラーを返します(他のステータスのコンテキスト拒否も次へ進みますが、保持されません)。 | | ChatGPT アカウントで Codex を使う際に、そのモデルをサポートしないことを示す正確な HTTP 400 の拒否 | 出力を確定する前に、次の設定済みターゲットを試します。拒否文全体が `detail`、`error.message`、または本文そのものにある場合に限ります。`detail` と `error` が競合する応答は終了扱いです。この拒否だけでアカウントを再認証待ちにしたり、プロバイダーの全モデルをクールダウンしたりしません。 | -|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化 HTTP 400 |推論エフォートの拒否の場合、送信バジェットが許す限り同じターゲットで次に低い公開ラングを試行します。どのラングも機能しない場合は、出力確定前にクールダウンなしで次のターゲットへ進みます。その他の記載された不一致はクールダウンなしで進みます。任意パラメーターの互換性を参照してください。 | +|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化 HTTP 400 |推論エフォートの拒否の場合、送信バジェットが許す限り同じターゲットで次に低い公開ラングを試行します。どのラングも機能しない場合は、認識された互換性のある形式に一致すれば冷却せずに出力確定前に次のターゲットへ進み、それ以外は終了します。その他の記載された不一致はクールダウンなしで進みます。任意パラメーターの互換性を参照してください。 | |インプロセスアダプター(`runTurn`)が実行する Responses ターンで、現在のリクエストが宣言していない最初のツール呼び出し(出力やリプレイ不可の副作用より前) |ターゲットをクールダウンし、同じツールカタログで次へ進みます。可視出力やリプレイ不可の副作用の後は拒否が確定します。Chat Completions と Anthropic Messages のリクエストは変わりません。 | |その他の未分類のエラー |停止してエラーを返します。 | diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index 98b2164ad67..bf8b175fe6c 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -135,7 +135,7 @@ ocx combo set balanced \ | 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. | | 출력 전 확인된 컨텍스트 초과 오류(HTTP 400 포함) | 쿨다운 없이 다음 미시도 적격 대상을 시도합니다. 모두 실패하면 처음 확인된 HTTP 400 컨텍스트 오류를 반환합니다(다른 상태 코드의 컨텍스트 거부도 다음 대상으로 넘어가지만 보관되지 않습니다). | | ChatGPT 계정으로 Codex를 사용할 때 해당 모델을 지원하지 않는다는 정확한 HTTP 400 거부 | 출력이 시작되기 전에 다음 설정 대상으로 넘어갑니다. 거부 문장 전체가 `detail`, `error.message` 또는 일반 텍스트에 있어야 하며, `detail`과 `error`가 충돌하면 중단합니다. 이 사유만으로 계정을 재인증 상태로 바꾸거나 공급자의 모든 모델에 쿨다운을 걸지는 않습니다. | -| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 HTTP 400 | 추론 노력 거부의 경우 전송 예산이 허용하는 동안 동일한 대상에서 다음으로 낮은 게시된 단계를 시도합니다. 작동하는 단계가 없으면 출력 확정 전에 쿨다운 없이 다음 대상으로 넘어갑니다. 기타 나열된 불일치는 쿨다운 없이 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | +| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 HTTP 400 | 추론 노력 거부의 경우 전송 예산이 허용하는 동안 동일한 대상에서 다음으로 낮은 게시된 단계를 시도합니다. 작동하는 단계가 없으면 거부가 인지된 호환성 형태와 일치할 경우 출력 확정 전에 쿨다운 없이 다음 대상으로 넘어가며, 그렇지 않으면 종료됩니다. 기타 나열된 불일치는 쿨다운 없이 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | | 인프로세스 어댑터(`runTurn`)가 실행하는 Responses 턴에서 현재 요청이 선언하지 않은 첫 도구 호출(출력이나 재전송 불가 부작용 이전) | 대상을 쿨다운하고 같은 도구 카탈로그로 다음 대상으로 넘어갑니다. 출력이 보였거나 재전송 불가 부작용이 생긴 뒤에는 거부가 그대로 확정됩니다. Chat Completions와 Anthropic Messages 요청은 바뀌지 않습니다. | | 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. | diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 3aa9d132f75..1456e83ade0 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -166,7 +166,7 @@ ocx combo set balanced \ | Отмена клиентом (499), `origin_rejected`, отказ из-за cyber-policy или иной некорректный запрос | Остановиться и вернуть ошибку; другая цель не сделает такой запрос корректным. | | Подтверждённое превышение контекста до вывода, включая HTTP 400 | Попробовать следующую допустимую цель без cooldown; при исчерпании вернуть первую подтверждённую ошибку контекста HTTP 400 (отказ по контексту с другим статусом тоже переводит к следующей цели, но не сохраняется). | | Точный отказ HTTP 400 о том, что модель не поддерживается в Codex с аккаунтом ChatGPT | До начала вывода пробует следующую настроенную цель. Полная фраза отказа должна находиться в `detail`, `error.message` или обычном тексте; смешанные объекты `detail` и `error` завершают запрос. Сам этот отказ не переводит аккаунт в состояние повторного входа и не включает период ожидания для всех моделей провайдера. | -| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) | При отказе в усилиях рассуждения (reasoning effort) пробует следующую более низкую опубликованную ступень на той же цели, пока позволяет бюджет отправок; если ни одна ступень не подошла, переходит к следующей цели до фиксации вывода без охлаждения. Другие перечисленные несоответствия переходят без охлаждения; см. «Совместимость необязательных параметров» ниже. | +| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) | При отказе в усилиях рассуждения (reasoning effort) пробует следующую более низкую опубликованную ступень на той же цели, пока позволяет бюджет отправок; если ни одна ступень не подошла, переходит к следующей цели до фиксации вывода без охлаждения, если отказ соответствует распознанному формату совместимости, иначе остается завершающим. Другие перечисленные несоответствия переходят без охлаждения; см. «Совместимость необязательных параметров» ниже. | | Первый вызов инструмента в Responses-ходе внутрипроцессного адаптера (`runTurn`), который текущий запрос не объявлял, до любого вывода и до побочного эффекта, небезопасного для повтора | Охлаждает цель и переходит к следующей с тем же каталогом инструментов. После видимого вывода или небезопасного для повтора побочного эффекта отказ окончателен. Запросы Chat Completions и Anthropic Messages не меняются. | | Любая другая неклассифицированная ошибка | Остановиться и вернуть ошибку. | diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index 9b9a5b5eca8..81a2551284a 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -231,7 +231,7 @@ ikiye ayrılır. | Sınıflandırılmış kimlik doğrulama, abonelik, kota, hız sınırı, aşırı yük veya yukarı akış sunucu hatası | Yalnızca durum yeterli olmadığında bile hedefi soğutun ve atlayın. | | İstemci iptali (499), `origin_rejected`, siber politika reddi veya diğer geçersiz istek | Durun ve hatayı döndürün; başka bir hedef isteği geçerli kılmaz. | | Çıktıdan önce doğrulanan bağlam aşımı (HTTP 400 dahil) | Soğutma olmadan denenmemiş uygun hedefe geçer; hedefler tükenirse ilk doğrulanan HTTP 400 bağlam hatasını döndürür (başka durum koduyla gelen bağlam reddi de sonraki hedefe geçirir ama saklanmaz). | -| `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış HTTP 400 | Bir efor reddi için, gönderim bütçesi izin verdiği sürece aynı hedefte bir sonraki daha düşük yayınlanmış basamağı deneyin; hiçbir basamak çalışmazsa çıktı taahhüt edilmeden önce bekleme süresi kaydetmeden atlayın. Listelenen diğer uyuşmazlıklar bekleme süresi olmadan atlar; aşağıdaki isteğe bağlı parametre uyumluluğuna bakın. | +| `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış HTTP 400 | Bir efor reddi için, gönderim bütçesi izin verdiği sürece aynı hedefte bir sonraki daha düşük yayınlanmış basamağı deneyin; hiçbir basamak çalışmazsa, ret tanınan bir uyumluluk kalıbıyla eşleşiyorsa çıktı taahhüt edilmeden önce bekleme süresi kaydetmeden atlayın, aksi takdirde sonlandırıcı kalın. Listelenen diğer uyuşmazlıklar bekleme süresi olmadan atlar; aşağıdaki isteğe bağlı parametre uyumluluğuna bakın. | | Süreç içi bir bağdaştırıcının (`runTurn`) yürüttüğü Responses turunda, geçerli isteğin bildirmediği ilk araç çağrısı (herhangi bir çıktıdan ve yeniden oynatılamaz yan etkiden önce) | Hedefi bekleme süresine alır ve aynı araç kataloğuyla sonraki hedefe atlar. Görünür çıktıdan veya yeniden oynatılamaz bir yan etkiden sonra ret kesindir. Chat Completions ve Anthropic Messages istekleri değişmez. | | Diğer sınıflandırılmamış hatalar | Durun ve hatayı döndürün. | diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index b171b72197c..6e91f7b3c86 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -155,7 +155,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 | 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、或其他无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 | | 输出前确认的上下文超限(包括 HTTP 400) | 不冷却该目标,尝试下一个尚未尝试的合格目标;耗尽后返回第一个确认的 HTTP 400 上下文错误(其他状态码的上下文拒绝也会切换到下一个目标,但不会被保留)。 | | HTTP 400 明确表示使用 ChatGPT 账户运行 Codex 时不支持该模型 | 在输出提交前尝试下一个已配置目标。完整拒绝句必须位于 `detail`、`error.message` 或纯文本中;同时包含 `detail` 与 `error` 的混合封装仍会终止请求。此原因本身不会将账户标为需要重新登录,也不会让供应商的所有模型进入冷却。 | -| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`) | 对于 reasoning effort 拒绝,在发送预算允许的情况下先在同一目标上尝试下一个更低的已发布档位;若所有档位均未成功,则在提交输出前跳转到下一目标且不记录冷却时间。其他列出的不匹配直接跳转且不记录冷却时间;参见下方可选参数兼容性。 | +| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`) | 对于 reasoning effort 拒绝,在发送预算允许的情况下先在同一目标上尝试下一个更低的已发布档位;若所有档位均未成功,在拒绝符合已识别的兼容形式时在提交输出前跳转且不记录冷却时间,否则保持终止。其他列出的不匹配直接跳转且不记录冷却时间;参见下方可选参数兼容性。 | | 由进程内适配器(`runTurn`)执行的 Responses 回合中,当前请求未声明的第一个工具调用(在任何输出和不可重放的副作用之前) | 让该目标进入冷却,并以相同的工具目录跳转到下一个目标。出现可见输出或不可重放的副作用之后,拒绝即为最终结果。Chat Completions 和 Anthropic Messages 请求不受影响。 | | 任何其他未分类错误 | 停止并返回错误。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 01666e88c6e..0f9396e2008 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -166,7 +166,7 @@ Combo 失敗分為**跳轉**失敗與**終端**失敗。 | 分類為認證、訂閱、配額、限流、過載或上游伺服器錯誤 | 冷卻目標並跳轉,即使單靠狀態碼不足。 | | 客戶端取消(499)、`origin_rejected`、cyber-policy 拒絕、其他無效請求 | 停止並回傳錯誤;另一個目標不會讓請求變為有效。 | | 輸出前確認的上下文超限(包括 HTTP 400) | 不冷卻該目標,嘗試下一個尚未嘗試的合格目標;耗盡後回傳第一個確認的 HTTP 400 上下文錯誤(其他狀態碼的上下文拒絕也會切換到下一個目標,但不會被保留)。 | -| 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`) | 對於 reasoning effort 拒絕,在發送預算允許的情況下先在同一目標上嘗試下一個更低的已發布檔位;若所有檔位均未成功,則在提交輸出前跳轉到下一目標且不記錄冷卻時間。其他列出的不相容直接跳轉且不記錄冷卻時間;參見下方選用參數相容性。 | +| 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`) | 對於 reasoning effort 拒絕,在發送預算允許的情況下先在同一目標上嘗試下一個更低的已發布檔位;若所有檔位均未成功,在拒絕符合已識別的相容形式時於提交輸出前跳轉且不記錄冷卻時間,否則保持終止。其他列出的不相容直接跳轉且不記錄冷卻時間;參見下方選用參數相容性。 | | 由行程內轉接器(`runTurn`)執行的 Responses 回合中,目前請求未宣告的第一個工具呼叫(在任何輸出與不可重播的副作用之前) | 讓該目標進入冷卻,並以相同的工具目錄跳轉到下一個目標。出現可見輸出或不可重播的副作用之後,拒絕即為最終結果。Chat Completions 與 Anthropic Messages 請求不受影響。 | | 任何其他未分類錯誤 | 停止並回傳錯誤。 | diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 8922819a3f4..1b63b11b70e 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -26,8 +26,12 @@ import { join } from "node:path"; // and leaves COMBO_NAMESPACE in its temporal dead zone for entry points that start at combos/types.ts. import { atomicWriteFile } from "../config/atomic-write"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; -import { extractJsonEnvelope, isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; -export { extractJsonEnvelope, isHardPolicyOrOriginRefusal }; +import { + extractJsonEnvelope, + extractJsonEnvelopeWithContext, + isHardPolicyOrOriginRefusal, +} from "../lib/hard-refusal"; +export { extractJsonEnvelope, extractJsonEnvelopeWithContext, isHardPolicyOrOriginRefusal }; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; @@ -567,6 +571,28 @@ const EFFORT_PARAM_NAMES = new Set([ "reasoning_parameters", ]); +function isConflictingDiagnosticProse(str: string | undefined): boolean { + if (!str) return false; + const s = str.trim(); + if (!s) return false; + if ( + /\b(?:permission[-_]denied|insufficient[-_]quota|rate[-_]limit[-_]exceeded|model[-_]not[-_]found|context[-_]length[-_]exceeded|access[-_]denied|unauthorized|invalid[-_]api[-_]key|account[-_]deactivated|billing[-_]hard[-_]limit)\b/i.test(s) + ) { + return true; + } + const codeMatch = /\bcode\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); + if (codeMatch && codeMatch[1]) { + const code = codeMatch[1].trim().toLowerCase().replaceAll("-", "_"); + if (!EFFORT_COMPATIBLE_CODES.has(code)) return true; + } + const typeMatch = /\btype\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); + if (typeMatch && typeMatch[1]) { + const type = typeMatch[1].trim().toLowerCase().replaceAll("-", "_"); + if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; + } + return false; +} + /** * Rejects error envelopes that carry a conflicting code (e.g. permission_denied, insufficient_quota, * rate_limit_exceeded, model_not_found, malformed non-string code) or competing diagnostic parameters @@ -586,17 +612,27 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo } } let parsed: unknown; + let outerPrefix = ""; + let outerSuffix = ""; try { parsed = JSON.parse(unescaped); } catch { - parsed = extractJsonEnvelope(unescaped); + const extracted = extractJsonEnvelopeWithContext(unescaped); + if (extracted) { + parsed = extracted.value; + outerPrefix = extracted.prefix; + outerSuffix = extracted.suffix; + } + } + if (isConflictingDiagnosticProse(outerPrefix) || isConflictingDiagnosticProse(outerSuffix)) { + return true; } if (!parsed || typeof parsed !== "object") { if (isIncompleteStructuredWrapper(unescaped)) return true; const hasBraces = unescaped.includes("{") && unescaped.includes("}"); const hasBrackets = unescaped.includes('["') || (unescaped.includes("[") && unescaped.includes("]") && unescaped.includes('"')); if (hasBraces || hasBrackets) return true; - return /\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(unescaped); + return isConflictingDiagnosticProse(unescaped); } try { const queue: unknown[] = [parsed]; @@ -612,9 +648,24 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo queue.push(item); } else if (typeof item === "string") { if (item.length > 16_384) return true; - const inner = extractJsonEnvelope(item); - if (inner && typeof inner === "object") { - queue.push(inner); + let innerItem: unknown; + let innerPrefix = ""; + let innerSuffix = ""; + try { + innerItem = JSON.parse(item); + } catch { + const extractedItem = extractJsonEnvelopeWithContext(item); + if (extractedItem) { + innerItem = extractedItem.value; + innerPrefix = extractedItem.prefix; + innerSuffix = extractedItem.suffix; + } + } + if (innerItem && typeof innerItem === "object") { + if (isConflictingDiagnosticProse(innerPrefix) || isConflictingDiagnosticProse(innerSuffix)) { + return true; + } + queue.push(innerItem); } else if (isIncompleteStructuredWrapper(item)) { return true; } else { @@ -631,7 +682,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo return true; } } else { - if (/\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(trimmed)) { + if (isConflictingDiagnosticProse(trimmed)) { return true; } } @@ -663,12 +714,27 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo if (val && typeof val === "object") { queue.push(val); } else if (typeof val === "string") { - const inner = extractJsonEnvelope(val); + let inner: unknown; + let innerPrefix = ""; + let innerSuffix = ""; + try { + inner = JSON.parse(val); + } catch { + const extracted = extractJsonEnvelopeWithContext(val); + if (extracted) { + inner = extracted.value; + innerPrefix = extracted.prefix; + innerSuffix = extracted.suffix; + } + } if (inner && typeof inner === "object") { + if (isConflictingDiagnosticProse(innerPrefix) || isConflictingDiagnosticProse(innerSuffix)) { + return true; + } queue.push(inner); } else if (isIncompleteStructuredWrapper(val)) { return true; - } else if (/\b(?:permission_denied|insufficient_quota|rate_limit_exceeded|model_not_found|context_length_exceeded)\b/i.test(val)) { + } else if (isConflictingDiagnosticProse(val)) { return true; } } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index a44e75fc581..d1cfb3bdfa4 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -187,6 +187,25 @@ const NESTED_WRAPPED_HARD_REFUSAL_PREFIX = JSON.stringify({ message: 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', }, }); +const CONFLICTING_PERMISSION_PREFIX_REFUSAL = + 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; +const PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"permission_denied"}}'; +const NESTED_WRAPPED_ECHO_PERMISSION_DENIED = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "permission_denied" }, + }), + }, +}); +const NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX = JSON.stringify({ + error: { + type: "upstream_error", + message: 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -791,6 +810,14 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortRejection(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(true); expect(isReasoningEffortRejection(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(false); + expect(isReasoningEffortRejection(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(true); + expect(isReasoningEffortRejection(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(false); + expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(false); + expect(isReasoningEffortRejection(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(true); expect(hasConflictingReasoningEffortCode(VENDOR_EFFORT_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(JSON.stringify({ param: "reasoning_effort", @@ -974,6 +1001,7 @@ describe("rejected reasoning rungs", () => { ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-chat)", async (_name, payload) => { writeSnapshot(["low", "high", "max"]); const outbound: Array> = []; @@ -1001,6 +1029,7 @@ describe("rejected reasoning rungs", () => { ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-responses)", async (_name, payload) => { writeSnapshot(["low", "high", "max"]); const outbound: Array> = []; @@ -1100,6 +1129,28 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + test.each([ + ["openai-chat", config], + ["openai-responses", passthroughConfig], + ])("stops before effort replay on conflicting permission diagnostic prefix outside extracted envelope (%s)", async (_name, makeConfig) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PERMISSION_PREFIX_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = makeConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-chat)", async () => { writeSnapshot(["low", "high", "max"]); const outbound: Array> = []; From 637c9ad62492d3743904a47d50bdb531d3600298 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:46:30 +0600 Subject: [PATCH 25/39] fix(combo): reconcile effort diagnostics, retain rebuild errors, and qualify runtime fallback --- src/combos/failover.ts | 22 ++- src/server/responses/adapter-dispatch.ts | 14 +- structure/runtime.md | 2 +- ...sponses-reasoning-effort-downgrade.test.ts | 136 ++++++++---------- ...uter-combo-failover-classification.test.ts | 50 +++++++ 5 files changed, 138 insertions(+), 86 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index c7de638c46c..7f7624999a0 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -3,6 +3,7 @@ import { classifyError, isCyberPolicyCode } from "../lib/errors"; import { isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; import { isNonReplayableUpstreamCode } from "../lib/upstream-retry"; import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal"; +import { hasConflictingReasoningEffortCode } from "../providers/reasoning-metadata"; import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal"; import type { OcxComboTarget } from "../types"; import { targetKey } from "./types"; @@ -445,6 +446,9 @@ function hasIncompatibleDiagnosticCarrier(val: unknown, depth = 0): boolean { if (rec.errors !== undefined && rec.errors !== null) { if (hasIncompatibleDiagnosticCarrier(rec.errors, depth + 1)) return true; } + if (rec.message !== undefined && rec.message !== null) { + if (typeof rec.message !== "string" || rec.message !== "Invalid reasoning effort.") return true; + } return false; } return false; @@ -455,7 +459,7 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (code !== undefined && code !== null && typeof code !== "string") return false; const outerCode = normalizedFailureCode(code); if (!EXACT_EFFORT_COMPATIBLE_CODES.has(outerCode)) return false; - if (isHardPolicyOrOriginRefusal(message)) return false; + if (isHardPolicyOrOriginRefusal(message) || hasConflictingReasoningEffortCode(message)) return false; let text = message.trim(); for (let depth = 0; depth < 3; depth += 1) { if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length).trim(); @@ -466,6 +470,7 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c const root = payload as Record; if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; + if (root.message !== undefined && root.message !== null && typeof root.message !== "string") return false; if (root.param !== undefined && root.param !== null) { if (typeof root.param !== "string") return false; const param = root.param.trim().toLowerCase(); @@ -483,12 +488,17 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c if (rootCode === "invalid_argument" && root.error === "Invalid reasoning effort." && (root.type === undefined || root.type === "invalid_request_error") - && (root.param === undefined || root.param === "reasoning_effort" || root.param === "reasoning.effort")) return true; + && (root.param === undefined || root.param === "reasoning_effort" || root.param === "reasoning.effort")) { + if (root.message !== undefined && root.message !== null && root.message !== "Invalid reasoning effort.") return false; + return true; + } const error = root.error; if (!error || typeof error !== "object" || Array.isArray(error)) return false; const record = error as Record; if (record.code !== undefined && record.code !== null && typeof record.code !== "string") return false; if (record.type !== undefined && record.type !== null && typeof record.type !== "string") return false; + if (record.message !== undefined && record.message !== null && typeof record.message !== "string") return false; + if (record.error !== undefined && record.error !== null && typeof record.error !== "string") return false; if (record.param !== undefined && record.param !== null) { if (typeof record.param !== "string") return false; const param = record.param.trim().toLowerCase(); @@ -512,6 +522,14 @@ function isExactInvalidReasoningEffortRefusal(status: number, message: string, c text = record.message; continue; } + if (root.type === "upstream_error" && typeof root.message === "string" + && root.message.startsWith("Provider error 400: ") + && EXACT_EFFORT_COMPATIBLE_CODES.has(rootCode)) { + text = root.message; + continue; + } + if (root.message !== undefined && root.message !== null && root.message !== "Invalid reasoning effort.") return false; + if (record.error !== undefined && record.error !== null && record.error !== "Invalid reasoning effort.") return false; return record.type === "invalid_request_error" && record.message === "Invalid reasoning effort." && EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode) diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 91d92faf19f..6252b8db8cc 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -664,12 +664,6 @@ export async function prepareAdapterExchange( } catch (err) { const authRefusal = options.abortSignal?.aborted ? undefined : codexAdapterBuildRefusal(err, admissionState.authCtx, route.codexAccountNamespace, () => { cleanupUpstreamAbort(); upstream.abort(); }); if (authRefusal) return { failed: authRefusal }; - // A rotated credential that cannot be shaped keeps the refusal it was meant to - // replace. The image-tier rebuild is different: the request itself failed to shape, - // and its established 413/400 build-error contract stays authoritative. - if (preserveFailureResponse && recovery !== "image-413" && !options.abortSignal?.aborted) { - return { failed: preserveFailureResponse }; - } // A rotated/rebuilt adapter build failure is a request-shaping error, not an // upstream connect failure: tear the abort link down and map it as 400. A // translator-budget refusal keeps its established 413 here as well — adapters @@ -684,6 +678,12 @@ export async function prepareAdapterExchange( code: "translation_buffer_limit", }) }; } + // A rotated credential that cannot be shaped keeps the refusal it was meant to + // replace. The image-tier and reasoning-effort rebuilds are different: the request itself + // failed to shape, and their established build-error contracts stay authoritative. + if (preserveFailureResponse && recovery !== "image-413" && recovery !== "reasoning-effort-downgrade") { + return { failed: preserveFailureResponse }; + } const msg = err instanceof Error ? err.message : String(err); return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) }; } @@ -849,7 +849,7 @@ export async function prepareAdapterExchange( releaseCodexAuthContextProbeLease(admissionState.authCtx); return { failed: codexRefusal }; } - if (preserveFailureResponse && !replacementAdmitted && !options.abortSignal?.aborted) + if (preserveFailureResponse && (recovery !== "reasoning-effort-downgrade" || err instanceof SendBudgetExhaustedError) && !replacementAdmitted && !options.abortSignal?.aborted) return { failed: preserveFailureResponse }; cleanupUpstreamAbort(); upstream.abort(); diff --git a/structure/runtime.md b/structure/runtime.md index fa265702104..5d73efdd501 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -492,7 +492,7 @@ Translated audio/file admission follows the [final-adapter input contract](adapt `src/adapters/openai-responses.ts` omits top-level `user` and `safety_identifier` at the canonical ChatGPT Codex forward destination. The backend rejects both with its strict-allowlist `{"detail":"Unsupported parameter: …"}` envelope, and canonical forwarding removes `safety_identifier` regardless of which client supplied it — third-party clients (droid's registry-matched BYOK) attach it from the model name alone, but any Responses client may send the public OpenAI-platform parameter explicitly. Claude translation retains its original identity and prompt-cache key; public API and noncanonical gateways retain their `user` and `safety_identifier` fields. Input roles, tool-schema properties and original replay bodies are not changed. -`src/combos/failover.ts` treats four intact HTTP 400 invalid-request envelopes as request-local incompatibilities: exactly `Unsupported parameter: user`; `unsupported_value` naming `reasoning.effort` or `reasoning_effort` with an explicit unsupported-value message; `param: input` with a bounded model-scoped `does not support image inputs` message; and the exact null-code `gpt-6-astra` function-tool routing mismatch that tells an existing Responses request to use `/v1/responses`, allowing only the bare model name or its strict `YYYY-MM-DD` deployment suffix. It also accepts the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}` and the matching structured `invalid_request_error` message. A combo first retries that target at each next lower published reasoning rung, one step per refusal and within the shared send budget; if no lower rung works, it advances to another eligible target without cooling the refused one. For the vendor envelope, the inner code and exact message remain required even when an outer provider code is absent. Only the exact proxy wrapper is unwrapped, within three envelopes and 16,384 characters; conflicting codes, malformed/truncated envelopes and reflected JSON do not gain hop permission. +`src/combos/failover.ts` treats four intact HTTP 400 invalid-request envelopes as request-local incompatibilities: exactly `Unsupported parameter: user`; `unsupported_value` naming `reasoning.effort` or `reasoning_effort` with an explicit unsupported-value message; `param: input` with a bounded model-scoped `does not support image inputs` message; and the exact null-code `gpt-6-astra` function-tool routing mismatch that tells an existing Responses request to use `/v1/responses`, allowing only the bare model name or its strict `YYYY-MM-DD` deployment suffix. It also accepts the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}` and the matching structured `invalid_request_error` message. A combo first retries that target at each next lower published reasoning rung, one step per refusal and within the shared send budget; if no lower rung works, it advances to another eligible target without cooling the refused one when the refusal matches a recognized compatibility envelope, and remains terminal otherwise. For the vendor envelope, the inner code and exact message remain required even when an outer provider code is absent. Only the exact proxy wrapper is unwrapped, within three envelopes and 16,384 characters; conflicting codes, malformed/truncated envelopes and reflected JSON do not gain hop permission. A `response_format` capability refusal is a sixth envelope, kept separate because it needs one code and one frame the earlier cases do not admit. The refusal must name `response_format` AND state that it is unavailable or unsupported; a message that merely names the field, such as an invalid-schema complaint, stays terminal, because replaying a malformed request at every later target is the outcome this distinction exists to avoid. `param` may be absent or explicitly null and a param naming another field fails closed. Its code set is the shared generic one plus `invalid_parameter_error`, held separately so the `user` and image branches are not widened by it. It also unwraps a single `data:` SSE frame on a one-line body — the reported gateway answers on the stream, so the error object is never extracted and the structured code arrives undefined — while a multi-event body is left alone. The next target receives the same request with `response_format` intact: no field is dropped and the output contract the caller asked for is unchanged. Traversal stays finite because combo excludes each attempted target. This verdict records no cooldown, and cancellation, origin/cyber-policy rejection and the non-replayable post-send codes are all tested before it (#4903). diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index d1cfb3bdfa4..d04d57b945d 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -1856,35 +1856,41 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); - test("stops before effort replay on balanced malformed JSON refusal (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + test.each([ + ["JSON", BALANCED_MALFORMED_JSON_REFUSAL], + ["bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], + ])("stops before effort replay on balanced malformed %s refusal", async (_label, refusal) => { + writeSnapshot(["low", "high", "max"]); + for (const [, getCfg] of [["chat", config], ["responses", passthroughConfig]] as const) { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const cfg = getCfg(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + } }); - test("combo stops immediately on balanced malformed JSON refusal without walking ladder", async () => { + test.each([ + ["JSON", BALANCED_MALFORMED_JSON_REFUSAL], + ["bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], + ])("combo stops immediately on balanced malformed %s refusal without walking ladder", async (_label, refusal) => { writeSnapshot(["low", "high", "max"]); const attempts: Array<{ host: string; effort: unknown }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); const body = JSON.parse(String(init?.body)) as Record; attempts.push({ host: url.host, effort: effortOf(body) }); - return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); }) as typeof fetch; const comboConfig = { providers: { @@ -1905,61 +1911,19 @@ describe("rejected reasoning rungs", () => { expect(response.status).toBe(400); expect(result).toContain("unknown_terminal_code"); - expect(attempts).toEqual([ - { host: "opencode.ai", effort: "max" }, - ]); - }); - - test("stops before effort replay on balanced malformed bracket refusal (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on balanced malformed bracket refusal (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + expect(attempts).toEqual([{ host: "opencode.ai", effort: "max" }]); }); - test("combo stops immediately on balanced malformed bracket refusal without walking ladder", async () => { + test("combo preserves 413 translation-budget refusal on rebuild without trying fallback", async () => { writeSnapshot(["low", "high", "max"]); const attempts: Array<{ host: string; effort: unknown }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); const body = JSON.parse(String(init?.body)) as Record; attempts.push({ host: url.host, effort: effortOf(body) }); - return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + return url.host === "fallback.example" + ? chatSuccess("fallback served the turn", MODEL) + : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); }) as typeof fetch; const comboConfig = { providers: { @@ -1975,13 +1939,33 @@ describe("rejected reasoning rungs", () => { } as OcxConfig; const logCtx: RequestLogContext = { model: "", provider: "" }; - const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); - const result = await response.text(); + const adapterRegistry = await import("../../src/adapters/registry"); + const originalCreate = adapterRegistry.createRegisteredAdapter; + let buildAttempts = 0; + const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { + const adapter = originalCreate(wire, provider, ctx); + const originalBuild = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (parsed, opts) => { + buildAttempts += 1; + if (buildAttempts === 2) { + const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); + throw new TranslatorBudgetExceededError("live_transient", 1); + } + return originalBuild(parsed, opts); + }; + return adapter; + }); - expect(response.status).toBe(400); - expect(result).toContain("unknown_terminal_code"); - expect(attempts).toEqual([ - { host: "opencode.ai", effort: "max" }, - ]); + try { + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + expect(response.status).toBe(413); + const json = await response.json() as Record; + expect(json.error?.code).toBe("context_length_exceeded"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + } finally { + spy.mockRestore(); + } }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index e60ed1b946e..d5a70e3889a 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -649,6 +649,56 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureDecision(400, payload)).toBe("stop"); expect(comboFailureCooldownScope(400, payload)).toBe("target"); }); + + test("rejects competing root or nested diagnostic messages before granting exact-effort hop", () => { + // Competing root message on vendor envelope elevates to target cooldown or stop, never cooldown none + const permPayload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: "permission_denied", + }); + expect(comboFailureDecision(400, permPayload)).toBe("hop"); + expect(comboFailureCooldownScope(400, permPayload)).toBe("target"); + + for (const competing of ["unknown_terminal_code", "invalid_request_error", "unsupported parameter: user"]) { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: competing, + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + + const nestedPayload = JSON.stringify({ + message: competing, + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort.", + }, + }); + expect(comboFailureDecision(400, nestedPayload)).toBe("stop"); + expect(comboFailureCooldownScope(400, nestedPayload)).toBe("target"); + + const competingNestedError = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort.", + error: competing, + }, + }); + expect(comboFailureDecision(400, competingNestedError)).toBe("stop"); + expect(comboFailureCooldownScope(400, competingNestedError)).toBe("target"); + } + + // Reconciled matching message grants hop without cooldown + const matchingPayload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: "Invalid reasoning effort.", + }); + expect(comboFailureDecision(400, matchingPayload)).toBe("hop"); + expect(comboFailureCooldownScope(400, matchingPayload)).toBe("none"); + }); }); describe("image rejection classifier bounds", () => { From aec4d88392c0e98b4323b477a8806e249581fa9c Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:23:17 +0600 Subject: [PATCH 26/39] fix(combo): preserve rotated refusal without aborting upstream signal --- src/server/responses/adapter-dispatch.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 6252b8db8cc..ef23bcf57af 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -664,16 +664,14 @@ export async function prepareAdapterExchange( } catch (err) { const authRefusal = options.abortSignal?.aborted ? undefined : codexAdapterBuildRefusal(err, admissionState.authCtx, route.codexAccountNamespace, () => { cleanupUpstreamAbort(); upstream.abort(); }); if (authRefusal) return { failed: authRefusal }; - // A rotated/rebuilt adapter build failure is a request-shaping error, not an - // upstream connect failure: tear the abort link down and map it as 400. A - // translator-budget refusal keeps its established 413 here as well — adapters - // can throw it while sizing generated content (e.g. Ollama late-tool - // attribution), not only parseRequest/buildToolBridgeMaps. - cleanupUpstreamAbort(); - upstream.abort(); - if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; - if (err instanceof OllamaLateAttributionBudgetError) return { failed: lateAttributionLimitResponse() }; + if (err instanceof OllamaLateAttributionBudgetError) { + cleanupUpstreamAbort(); + upstream.abort(); + return { failed: lateAttributionLimitResponse() }; + } if (isTranslatorBudgetExceededError(err)) { + cleanupUpstreamAbort(); + upstream.abort(); return { failed: formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { code: "translation_buffer_limit", }) }; @@ -681,9 +679,14 @@ export async function prepareAdapterExchange( // A rotated credential that cannot be shaped keeps the refusal it was meant to // replace. The image-tier and reasoning-effort rebuilds are different: the request itself // failed to shape, and their established build-error contracts stay authoritative. - if (preserveFailureResponse && recovery !== "image-413" && recovery !== "reasoning-effort-downgrade") { + if (preserveFailureResponse && recovery !== "image-413" && recovery !== "reasoning-effort-downgrade" && !options.abortSignal?.aborted) { return { failed: preserveFailureResponse }; } + // A rotated/rebuilt adapter build failure is a request-shaping error, not an + // upstream connect failure: tear the abort link down and map it as 400. + cleanupUpstreamAbort(); + upstream.abort(); + if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; const msg = err instanceof Error ? err.message : String(err); return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) }; } From 16d998d93dad1fb22b55d6200797dd28b208879a Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:43:49 +0600 Subject: [PATCH 27/39] fix(reasoning): fail closed on malformed bracket diagnostics and add request-path regressions --- src/providers/reasoning-metadata.ts | 27 ++++++++- ...sponses-reasoning-effort-downgrade.test.ts | 56 +++++++++++-------- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 1b63b11b70e..e3090ebe075 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -553,7 +553,10 @@ function isIncompleteStructuredWrapper(text: string): boolean { } if (openBrackets !== 0) return true; const bracketSlice = s.slice(firstBracket, lastBracket + 1); - if (lastBracket > firstBracket && (bracketSlice.includes('"') || bracketSlice.includes(":"))) { + if ( + lastBracket > firstBracket && + (bracketSlice.includes('"') || bracketSlice.includes("'") || bracketSlice.includes(":") || bracketSlice.includes(",")) + ) { try { JSON.parse(bracketSlice); } catch { @@ -630,7 +633,11 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo if (!parsed || typeof parsed !== "object") { if (isIncompleteStructuredWrapper(unescaped)) return true; const hasBraces = unescaped.includes("{") && unescaped.includes("}"); - const hasBrackets = unescaped.includes('["') || (unescaped.includes("[") && unescaped.includes("]") && unescaped.includes('"')); + const hasBrackets = + unescaped.includes('["') || + (unescaped.includes("[") && + unescaped.includes("]") && + (unescaped.includes('"') || unescaped.includes("'") || unescaped.includes(","))); if (hasBraces || hasBrackets) return true; return isConflictingDiagnosticProse(unescaped); } @@ -682,6 +689,15 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo return true; } } else { + const codeLike = trimmed.toLowerCase().replaceAll("-", "_"); + if ( + !codeLike.includes(" ") && + (codeLike.includes("_") || codeLike.length < 32) && + !EFFORT_COMPATIBLE_CODES.has(codeLike) && + !EFFORT_COMPATIBLE_TYPES.has(codeLike) + ) { + return true; + } if (isConflictingDiagnosticProse(trimmed)) { return true; } @@ -862,6 +878,7 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): if (parsed && typeof parsed === "object") { if (Array.isArray(parsed)) { + if (hasConflictingReasoningEffortCode(unescaped)) return false; for (const item of parsed) { if (item && typeof item === "object") { if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; @@ -1000,7 +1017,11 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): } const hasBraces = unescaped.includes("{") && unescaped.includes("}"); - const hasBrackets = unescaped.includes('["') || (unescaped.includes("[") && unescaped.includes("]") && unescaped.includes('"')); + const hasBrackets = + unescaped.includes('["') || + (unescaped.includes("[") && + unescaped.includes("]") && + (unescaped.includes('"') || unescaped.includes("'") || unescaped.includes(","))); if (hasBraces || hasBrackets || isIncompleteStructuredWrapper(unescaped)) return false; return diagnosticTextBlamesReasoningEffort(unescaped); diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index d04d57b945d..d2a2af74a2d 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -82,6 +82,8 @@ const BALANCED_MALFORMED_JSON_REFUSAL = 'Gateway error 400: {"code":"unknown_terminal_code","message":"Unsupported value for reasoning_effort", invalid_json: }'; const BALANCED_MALFORMED_BRACKET_REFUSAL = 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort",]'; +const VALID_BRACKET_CONFLICTING_REFUSAL = + 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort"]'; const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ param: "reasoning_effort", @@ -810,6 +812,8 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortRejection(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(true); expect(isReasoningEffortRejection(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(true); expect(isReasoningEffortRejection(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(false); expect(hasConflictingReasoningEffortCode(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(false); @@ -1835,12 +1839,16 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); }); - test("stops before effort replay on balanced malformed JSON refusal (openai-chat)", async () => { + test.each([ + ["balanced malformed JSON", BALANCED_MALFORMED_JSON_REFUSAL], + ["balanced malformed bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], + ["valid bracket with conflicting code", VALID_BRACKET_CONFLICTING_REFUSAL], + ])("stops before effort replay on %s refusal (openai-chat)", async (_label, refusal) => { writeSnapshot(["low", "high", "max"]); const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); }) as typeof fetch; const logCtx: RequestLogContext = { model: "", provider: "" }; @@ -1857,33 +1865,35 @@ describe("rejected reasoning rungs", () => { }); test.each([ - ["JSON", BALANCED_MALFORMED_JSON_REFUSAL], - ["bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], - ])("stops before effort replay on balanced malformed %s refusal", async (_label, refusal) => { + ["balanced malformed JSON", BALANCED_MALFORMED_JSON_REFUSAL], + ["balanced malformed bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], + ["valid bracket with conflicting code", VALID_BRACKET_CONFLICTING_REFUSAL], + ])("stops before effort replay on %s refusal (openai-responses)", async (_label, refusal) => { writeSnapshot(["low", "high", "max"]); - for (const [, getCfg] of [["chat", config], ["responses", passthroughConfig]] as const) { - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - const cfg = getCfg(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - } + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); test.each([ ["JSON", BALANCED_MALFORMED_JSON_REFUSAL], ["bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], - ])("combo stops immediately on balanced malformed %s refusal without walking ladder", async (_label, refusal) => { + ["valid bracket conflicting", VALID_BRACKET_CONFLICTING_REFUSAL], + ])("combo stops immediately on %s refusal without walking ladder", async (_label, refusal) => { writeSnapshot(["low", "high", "max"]); const attempts: Array<{ host: string; effort: unknown }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { From f1649eacb781d3cf8f01a96fb9ac8307ba1441e5 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:48:03 +0600 Subject: [PATCH 28/39] fix(combo): reconcile competing diagnostic prose and prioritize cancellation on rebuild - Check for competing non-effort parameter claims in diagnostic prose (e.g. invalid temperature alongside reasoning effort) to treat ambiguous rejections as terminal rather than retrying or learning unsupported effort rungs. - Check client cancellation before mapping translation and attribution budget errors during rebuild in adapter-dispatch to return 499 rather than 413. - Split diagnostic envelope tests into tests/responses/responses-reasoning-effort-diagnostics.test.ts to keep test files well below the 2,000-line ratchet limit. --- scripts/test-layout/layout.json | 1 + src/providers/reasoning-metadata.ts | 119 ++ src/server/responses/adapter-dispatch.ts | 20 +- tests/fixtures/test-layout-expected.json | 2 +- ...onses-reasoning-effort-diagnostics.test.ts | 1690 +++++++++++++++++ ...sponses-reasoning-effort-downgrade.test.ts | 1319 ------------- 6 files changed, 1819 insertions(+), 1332 deletions(-) create mode 100644 tests/responses/responses-reasoning-effort-diagnostics.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1b7ab30cacc..0aed7559af7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1579,6 +1579,7 @@ "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", + "responses-reasoning-effort-diagnostics.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", "responses-url-citation-title-repair.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index e3090ebe075..66552967e4f 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -574,6 +574,122 @@ const EFFORT_PARAM_NAMES = new Set([ "reasoning_parameters", ]); +function cleanParam(str: string): string { + return str.trim().toLowerCase().replaceAll("-", "_").replace(/[^a-z0-9_]+$/gi, "").replace(/^[^a-z0-9_]+/gi, ""); +} + +const KNOWN_NON_EFFORT_PARAMS = new Set([ + "temperature", + "top_p", + "top_k", + "max_tokens", + "max_completion_tokens", + "max_output_tokens", + "min_tokens", + "stop", + "stop_sequences", + "presence_penalty", + "frequency_penalty", + "repetition_penalty", + "logit_bias", + "logprobs", + "top_logprobs", + "seed", + "tools", + "tool_choice", + "functions", + "function_call", + "response_format", + "stream", + "stream_options", + "user", + "service_tier", + "modalities", + "prediction", + "parallel_tool_calls", + "messages", + "prompt", + "n", + "best_of", +]); + +const GENERIC_PARAM_WORDS = new Set([ + "request", + "requests", + "argument", + "arguments", + "parameter", + "parameters", + "param", + "params", + "payload", + "body", + "input", + "inputs", + "json", + "format", + "data", + "content", + "value", + "values", + "field", + "fields", + "call", + "type", + "error", + "message", + "model", + "url", + "header", + "headers", + "auth", + "api", + "key", + "token", + "status", + "code", + "response", + "gateway", + "provider", + "invalid", + "unsupported", + "unknown", + "unrecognized", + "unexpected", + "unallowed", +]); + +function diagnosticTextNamesOtherParameter(text: string): boolean { + if (!text) return false; + const s = text.replace(GENERIC_ERROR_TYPE, " "); + + const paramPattern = /\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`])([a-zA-Z0-9_\-\.]+)['"`]?/gi; + for (let m = paramPattern.exec(s); m; m = paramPattern.exec(s)) { + const p = cleanParam(m[1]); + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + } + + const prefixParamPattern = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/gi; + for (let m = prefixParamPattern.exec(s); m; m = prefixParamPattern.exec(s)) { + const p = cleanParam(m[1]); + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + } + + const nonEffortTerm = /\b(?:temperature|top_p|top_k|max_tokens|max_completion_tokens|max_output_tokens|min_tokens|stop|stop_sequences|presence_penalty|frequency_penalty|repetition_penalty|logit_bias|logprobs|top_logprobs|seed|tools|tool_choice|functions|function_call|response_format|stream|stream_options|user|service_tier|modalities|prediction|parallel_tool_calls|messages|prompt)\b/gi; + for (let m = nonEffortTerm.exec(s); m; m = nonEffortTerm.exec(s)) { + const from = Math.max(0, m.index - REJECTION_WINDOW); + const to = Math.min(s.length, m.index + m[0].length + REJECTION_WINDOW); + if (REJECTION_LANGUAGE.test(s.slice(from, to))) return true; + } + + const rejectionDirect = /\b(?:invalid|unsupported|unrecognized|unknown)\s+['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; + for (let m = rejectionDirect.exec(s); m; m = rejectionDirect.exec(s)) { + const p = cleanParam(m[1]); + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + } + return false; +} + function isConflictingDiagnosticProse(str: string | undefined): boolean { if (!str) return false; const s = str.trim(); @@ -593,6 +709,9 @@ function isConflictingDiagnosticProse(str: string | undefined): boolean { const type = typeMatch[1].trim().toLowerCase().replaceAll("-", "_"); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } + if (diagnosticTextNamesOtherParameter(s)) { + return true; + } return false; } diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index c7ccd1e2769..f71aa6ae2fd 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -660,18 +660,6 @@ export async function prepareAdapterExchange( } catch (err) { const authRefusal = options.abortSignal?.aborted ? undefined : codexAdapterBuildRefusal(err, admissionState.authCtx, route.codexAccountNamespace, () => { cleanupUpstreamAbort(); upstream.abort(); }); if (authRefusal) return { failed: authRefusal }; - if (err instanceof OllamaLateAttributionBudgetError) { - cleanupUpstreamAbort(); - upstream.abort(); - return { failed: lateAttributionLimitResponse() }; - } - if (isTranslatorBudgetExceededError(err)) { - cleanupUpstreamAbort(); - upstream.abort(); - return { failed: formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { - code: "translation_buffer_limit", - }) }; - } // A rotated credential that cannot be shaped keeps the refusal it was meant to // replace. The image-tier and reasoning-effort rebuilds are different: the request itself // failed to shape, and their established build-error contracts stay authoritative. @@ -683,6 +671,14 @@ export async function prepareAdapterExchange( cleanupUpstreamAbort(); upstream.abort(); if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; + if (err instanceof OllamaLateAttributionBudgetError) { + return { failed: lateAttributionLimitResponse() }; + } + if (isTranslatorBudgetExceededError(err)) { + return { failed: formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { + code: "translation_buffer_limit", + }) }; + } const msg = err instanceof Error ? err.message : String(err); return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) }; } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fce3434026d..b3f8784f67a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1303,7 +1303,7 @@ "responses-parser-agent-message.test.ts": "responses", "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", - "responses-preview-main-read-fence.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", + "responses-preview-main-read-fence.test.ts": "responses", "responses-reasoning-effort-diagnostics.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", "responses-url-citation-title-repair.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", "responses-translated-reset.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", diff --git a/tests/responses/responses-reasoning-effort-diagnostics.test.ts b/tests/responses/responses-reasoning-effort-diagnostics.test.ts new file mode 100644 index 00000000000..85dff429c41 --- /dev/null +++ b/tests/responses/responses-reasoning-effort-diagnostics.test.ts @@ -0,0 +1,1690 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses } from "../../src/server/responses/core"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { + hasConflictingReasoningEffortCode, + isReasoningEffortLearnedUnsupported, + isReasoningEffortRejection, + resetReasoningMetadataCachesForTests, +} from "../../src/providers/reasoning-metadata"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { chatSuccess } from "../helpers/combo-failover-upstream"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +/** + * Diagnostic envelope and boundary tests for reasoning effort refusals (#8173). + * + * Verifies robust extraction, handling of conflicting codes, incomplete/malformed envelopes, + * echoed request context, and competing diagnostic prose. + */ + +const originalFetch = globalThis.fetch; +const originalOpenCodexHome = process.env.OPENCODEX_HOME; +const MODEL = "muse-spark-1.3-contributor"; + +const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); +const CONFLICTING_CODE_REFUSAL = JSON.stringify({ + code: "permission_denied", + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, +}); +const SERIALIZED_WRAPPER_CONFLICTING_REFUSAL = JSON.stringify({ + type: "upstream_error", + param: "reasoning_effort", + message: 'Provider error 400: {"code":"permission_denied","error":"Invalid reasoning effort."}', +}); +const UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + details: [ + ...Array.from({ length: 32 }, (_, i) => ({ id: i, note: "ordinary candidate object" })), + { code: "permission_denied", message: "Denied after candidate limit" }, + ], +}); +const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + message: 'Provider error 400: {"code":"permission_denied"', +}); +const BALANCED_MALFORMED_JSON_REFUSAL = + 'Gateway error 400: {"code":"unknown_terminal_code","message":"Unsupported value for reasoning_effort", invalid_json: }'; +const BALANCED_MALFORMED_BRACKET_REFUSAL = + 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort",]'; +const VALID_BRACKET_CONFLICTING_REFUSAL = + 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort"]'; +const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; +const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: [{ code: "bio_policy" }], +}); +const SERIALIZED_ARRAY_BIO_POLICY_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: ['{"code":"bio_policy"}'], +}); +const ERRORS_CONFLICTING_CODE_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: [{ code: "permission_denied" }], +}); +const SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: ['{"code":"permission_denied"}'], +}); +const ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL = JSON.stringify({ + error: { + param: "temperature", + message: "Invalid temperature.", + }, + request: { + param: "reasoning_effort", + }, +}); +const SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL = JSON.stringify({ + error: { + type: "upstream_error", + message: `Provider error 400: ${JSON.stringify({ + error: { param: "temperature", message: "Invalid temperature." }, + request: { param: "reasoning_effort" }, + })}`, + }, +}); +const DETAIL_REASONING_EFFORT_REFUSAL = JSON.stringify({ + detail: "Unsupported reasoning effort", +}); +const DETAIL_ARRAY_REASONING_EFFORT_REFUSAL = JSON.stringify({ + detail: ["Unsupported reasoning effort"], +}); +const CONFLICTING_PARAM_UNRELATED_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + error: { + param: "temperature", + message: "Invalid temperature.", + }, +}); +const NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL = JSON.stringify({ + error: { + param: "temperature", + message: "Invalid temperature.", + }, + detail: "Unsupported reasoning effort", +}); +const DETAILS_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ + details: "Unsupported reasoning effort", +}); +const LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ + last_error: "Unsupported reasoning effort", +}); +const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ + error: { + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, + request: { + input: "origin_rejected", + reason: "testing cybersecurity policy", + }, +}); +const PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"origin_rejected"}}'; +const PREFIXED_ECHOED_CYBER_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"Request blocked by upstream cybersecurity policy"}}'; +const PREFIXED_ECHOED_BIO_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"bio_policy violation"}}'; +const PREFIXED_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"code":"cyber_policy","param":"reasoning_effort","message":"Request blocked by upstream cybersecurity policy"},"request":{"input":"ordinary text"}}'; +const HARD_REFUSAL_PREFIX_ENVELOPE = + 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; +const NESTED_WRAPPED_ECHO_ORIGIN_REJECTED = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "origin_rejected" }, + }), + }, +}); +const NESTED_WRAPPED_ECHO_ORDINARY = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "ordinary input prompt" }, + }), + }, +}); +const NESTED_WRAPPED_HARD_REFUSAL_PREFIX = JSON.stringify({ + error: { + type: "upstream_error", + message: 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', + }, +}); +const CONFLICTING_PERMISSION_PREFIX_REFUSAL = + 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; +const PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"permission_denied"}}'; +const NESTED_WRAPPED_ECHO_PERMISSION_DENIED = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "permission_denied" }, + }), + }, +}); +const NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX = JSON.stringify({ + error: { + type: "upstream_error", + message: 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', + }, +}); +const COMPETING_DIAGNOSTIC_PROSE_REFUSAL = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: "Invalid temperature.", +}); + +let testDir = ""; +let releaseSpendHome: (() => void) | undefined; + +function writeSnapshot(values: string[]): void { + writeFileSync(join(testDir, "reasoning-metadata-cache.json"), JSON.stringify({ + version: 1, + fetchedAt: Date.now(), + source: "test", + providers: { "opencode-go": { [MODEL]: { reasoning: true, options: [{ type: "effort", values }] } } }, + })); +} + +function config(): OcxConfig { + return { + defaultProvider: "first", + providers: { + first: { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig; +} + +function passthroughConfig(): OcxConfig { + return { + defaultProvider: "first", + providers: { + first: { + adapter: "openai-responses", + baseUrl: "https://opencode.ai/zen/go/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig; +} + +function effortOf(body: Record | undefined): unknown { + if (!body) return undefined; + const reasoning = body.reasoning; + if (reasoning && typeof reasoning === "object" && "effort" in reasoning) { + return (reasoning as { effort?: unknown }).effort; + } + return body.reasoning_effort; +} + +function request(stream = false, model = "first/" + MODEL): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + stream, + store: false, + reasoning: { effort: "max" }, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }], + }), + }); +} + +beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + testDir = mkdtempSync(join(tmpdir(), "ocx-reasoning-diag-")); + process.env.OPENCODEX_HOME = testDir; + releaseSpendHome = acquireOwnedSpendHome(); + resetReasoningMetadataCachesForTests(); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); + resetReasoningMetadataCachesForTests(); + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + rmSync(testDir, { recursive: true, force: true }); +}); + +describe("reasoning effort diagnostic envelopes and boundaries", () => { + test("hasConflictingReasoningEffortCode unit checks", () => { + expect(hasConflictingReasoningEffortCode(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(false); + expect(isReasoningEffortRejection(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(true); + expect(isReasoningEffortRejection(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(false); + expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(false); + expect(isReasoningEffortRejection(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(true); + expect(hasConflictingReasoningEffortCode(VENDOR_EFFORT_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(JSON.stringify({ + param: "reasoning_effort", + details: [{ id: 1 }, { id: 2 }], + }))).toBe(false); + expect(hasConflictingReasoningEffortCode(COMPETING_DIAGNOSTIC_PROSE_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(COMPETING_DIAGNOSTIC_PROSE_REFUSAL)).toBe(false); + }); + + test("stops before effort replay on serialized provider wrapper conflicting code (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized provider wrapper conflicting code (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when candidate queue leaves unexamined objects (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("reasoning_effort"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when candidate queue leaves unexamined objects (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete structured wrapper (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete structured wrapper (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test.each([ + ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], + ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], + ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], + ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-chat)", async (_kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test.each([ + ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], + ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], + ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], + ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-responses)", async (_kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting permission diagnostic prefix outside extracted envelope (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PERMISSION_PREFIX_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting permission diagnostic prefix outside extracted envelope (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PERMISSION_PREFIX_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message has ordinary request input (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message has ordinary request input (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete outer object ending with inner brace (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete outer object ending with inner brace (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on hard policy refusal in errors carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on hard policy refusal in errors carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting code in errors carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting code in errors carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized hard policy refusal inside errors array (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized hard policy refusal inside errors array (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized conflicting code inside errors array (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized conflicting code inside errors array (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on unrelated refusal with echoed request param (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on unrelated refusal with echoed request param (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized wrapper containing echoed request param (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized wrapper containing echoed request param (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on conflicting diagnostic parameters (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting diagnostic parameters (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on competing diagnostic prose (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(COMPETING_DIAGNOSTIC_PROSE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on competing diagnostic prose (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(COMPETING_DIAGNOSTIC_PROSE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on non-effort parameter with effort claim in detail (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on non-effort parameter with effort claim in detail (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on balanced malformed JSON refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed bracket refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on valid bracket with conflicting code refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(VALID_BRACKET_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed JSON refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed bracket refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on valid bracket with conflicting code refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(VALID_BRACKET_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test.each([ + ["JSON", BALANCED_MALFORMED_JSON_REFUSAL, "unknown_terminal_code"], + ["bracket", BALANCED_MALFORMED_BRACKET_REFUSAL, "unknown_terminal_code"], + ["valid bracket conflicting", VALID_BRACKET_CONFLICTING_REFUSAL, "unknown_terminal_code"], + ["competing diagnostic prose", COMPETING_DIAGNOSTIC_PROSE_REFUSAL, "Invalid temperature."], + ])("combo stops immediately on %s refusal without walking ladder", async (_label, refusal, expectedText) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain(expectedText); + expect(attempts).toEqual([{ host: "opencode.ai", effort: "max" }]); + }); + + test("combo preserves 413 translation-budget refusal on rebuild without trying fallback", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return url.host === "fallback.example" + ? chatSuccess("fallback served the turn", MODEL) + : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const adapterRegistry = await import("../../src/adapters/registry"); + const originalCreate = adapterRegistry.createRegisteredAdapter; + let buildAttempts = 0; + const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { + const adapter = originalCreate(wire, provider, ctx); + const originalBuild = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (parsed, opts) => { + buildAttempts += 1; + if (buildAttempts === 2) { + const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); + throw new TranslatorBudgetExceededError("live_transient", 1); + } + return originalBuild(parsed, opts); + }; + return adapter; + }); + + try { + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + expect(response.status).toBe(413); + const json = await response.json() as Record; + expect(json.error?.code).toBe("context_length_exceeded"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + } finally { + spy.mockRestore(); + } + }); + + test("direct Chat gives cancellation precedence over rebuild budget error returning 499 rather than 413", async () => { + writeSnapshot(["low", "high", "max"]); + const controller = new AbortController(); + let sends = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => { + sends += 1; + return new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const cfg = config(); + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const adapterRegistry = await import("../../src/adapters/registry"); + const originalCreate = adapterRegistry.createRegisteredAdapter; + let buildAttempts = 0; + const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { + const adapter = originalCreate(wire, provider, ctx); + const originalBuild = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (parsed, opts) => { + buildAttempts += 1; + if (buildAttempts === 2) { + controller.abort(); + const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); + throw new TranslatorBudgetExceededError("live_transient", 1); + } + return originalBuild(parsed, opts); + }; + return adapter; + }); + + try { + const response = await handleResponses(request(), cfg, logCtx, { + abortSignal: controller.signal, + inboundWire: "chat", + }); + expect(response.status).toBe(499); + expect(sends).toBe(1); + } finally { + spy.mockRestore(); + } + }); + + test("direct Chat returns 413 when rebuild budget error occurs without client cancellation", async () => { + writeSnapshot(["low", "high", "max"]); + let sends = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => { + sends += 1; + return new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const cfg = config(); + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const adapterRegistry = await import("../../src/adapters/registry"); + const originalCreate = adapterRegistry.createRegisteredAdapter; + let buildAttempts = 0; + const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { + const adapter = originalCreate(wire, provider, ctx); + const originalBuild = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (parsed, opts) => { + buildAttempts += 1; + if (buildAttempts === 2) { + const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); + throw new TranslatorBudgetExceededError("live_transient", 1); + } + return originalBuild(parsed, opts); + }; + return adapter; + }); + + try { + const response = await handleResponses(request(), cfg, logCtx, { + inboundWire: "chat", + }); + expect(response.status).toBe(413); + expect(sends).toBe(1); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index d2a2af74a2d..a3da384d25a 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -62,152 +62,6 @@ const CONFLICTING_CODE_REFUSAL = JSON.stringify({ message: "Invalid reasoning effort.", }, }); -const SERIALIZED_WRAPPER_CONFLICTING_REFUSAL = JSON.stringify({ - type: "upstream_error", - param: "reasoning_effort", - message: 'Provider error 400: {"code":"permission_denied","error":"Invalid reasoning effort."}', -}); -const UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL = JSON.stringify({ - param: "reasoning_effort", - details: [ - ...Array.from({ length: 32 }, (_, i) => ({ id: i, note: "ordinary candidate object" })), - { code: "permission_denied", message: "Denied after candidate limit" }, - ], -}); -const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ - param: "reasoning_effort", - message: 'Provider error 400: {"code":"permission_denied"', -}); -const BALANCED_MALFORMED_JSON_REFUSAL = - 'Gateway error 400: {"code":"unknown_terminal_code","message":"Unsupported value for reasoning_effort", invalid_json: }'; -const BALANCED_MALFORMED_BRACKET_REFUSAL = - 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort",]'; -const VALID_BRACKET_CONFLICTING_REFUSAL = - 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort"]'; -const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; -const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ - param: "reasoning_effort", - errors: [{ code: "bio_policy" }], -}); -const SERIALIZED_ARRAY_BIO_POLICY_REFUSAL = JSON.stringify({ - param: "reasoning_effort", - errors: ['{"code":"bio_policy"}'], -}); -const ERRORS_CONFLICTING_CODE_REFUSAL = JSON.stringify({ - param: "reasoning_effort", - errors: [{ code: "permission_denied" }], -}); -const SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL = JSON.stringify({ - param: "reasoning_effort", - errors: ['{"code":"permission_denied"}'], -}); -const ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL = JSON.stringify({ - error: { - param: "temperature", - message: "Invalid temperature.", - }, - request: { - param: "reasoning_effort", - }, -}); -const SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL = JSON.stringify({ - error: { - type: "upstream_error", - message: `Provider error 400: ${JSON.stringify({ - error: { param: "temperature", message: "Invalid temperature." }, - request: { param: "reasoning_effort" }, - })}`, - }, -}); -const DETAIL_REASONING_EFFORT_REFUSAL = JSON.stringify({ - detail: "Unsupported reasoning effort", -}); -const DETAIL_ARRAY_REASONING_EFFORT_REFUSAL = JSON.stringify({ - detail: ["Unsupported reasoning effort"], -}); -const CONFLICTING_PARAM_UNRELATED_REFUSAL = JSON.stringify({ - param: "reasoning_effort", - error: { - param: "temperature", - message: "Invalid temperature.", - }, -}); -const NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL = JSON.stringify({ - error: { - param: "temperature", - message: "Invalid temperature.", - }, - detail: "Unsupported reasoning effort", -}); -const DETAILS_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ - details: "Unsupported reasoning effort", -}); -const LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ - last_error: "Unsupported reasoning effort", -}); -const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ - error: { - param: "reasoning_effort", - message: "Invalid reasoning effort.", - }, - request: { - input: "origin_rejected", - reason: "testing cybersecurity policy", - }, -}); -const PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL = - 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"origin_rejected"}}'; -const PREFIXED_ECHOED_CYBER_POLICY_REFUSAL = - 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"Request blocked by upstream cybersecurity policy"}}'; -const PREFIXED_ECHOED_BIO_POLICY_REFUSAL = - 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"bio_policy violation"}}'; -const PREFIXED_POLICY_REFUSAL = - 'Gateway error 400: {"error":{"code":"cyber_policy","param":"reasoning_effort","message":"Request blocked by upstream cybersecurity policy"},"request":{"input":"ordinary text"}}'; -const HARD_REFUSAL_PREFIX_ENVELOPE = - 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; -const NESTED_WRAPPED_ECHO_ORIGIN_REJECTED = JSON.stringify({ - error: { - type: "upstream_error", - message: JSON.stringify({ - error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, - request: { input: "origin_rejected" }, - }), - }, -}); -const NESTED_WRAPPED_ECHO_ORDINARY = JSON.stringify({ - error: { - type: "upstream_error", - message: JSON.stringify({ - error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, - request: { input: "ordinary input prompt" }, - }), - }, -}); -const NESTED_WRAPPED_HARD_REFUSAL_PREFIX = JSON.stringify({ - error: { - type: "upstream_error", - message: 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', - }, -}); -const CONFLICTING_PERMISSION_PREFIX_REFUSAL = - 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; -const PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL = - 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"permission_denied"}}'; -const NESTED_WRAPPED_ECHO_PERMISSION_DENIED = JSON.stringify({ - error: { - type: "upstream_error", - message: JSON.stringify({ - error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, - request: { input: "permission_denied" }, - }), - }, -}); -const NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX = JSON.stringify({ - error: { - type: "upstream_error", - message: 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', - }, -}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -805,1177 +659,4 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); - test("hasConflictingReasoningEffortCode unit checks", () => { - expect(hasConflictingReasoningEffortCode(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL)).toBe(true); - expect(hasConflictingReasoningEffortCode(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL)).toBe(true); - expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(true); - expect(isReasoningEffortRejection(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(false); - expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(true); - expect(isReasoningEffortRejection(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(false); - expect(hasConflictingReasoningEffortCode(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(true); - expect(isReasoningEffortRejection(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(false); - expect(hasConflictingReasoningEffortCode(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(true); - expect(isReasoningEffortRejection(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(false); - expect(hasConflictingReasoningEffortCode(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(false); - expect(isReasoningEffortRejection(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(true); - expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(true); - expect(isReasoningEffortRejection(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(false); - expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(false); - expect(isReasoningEffortRejection(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(true); - expect(hasConflictingReasoningEffortCode(VENDOR_EFFORT_REFUSAL)).toBe(false); - expect(hasConflictingReasoningEffortCode(JSON.stringify({ - param: "reasoning_effort", - details: [{ id: 1 }, { id: 2 }], - }))).toBe(false); - }); - - test("stops before effort replay on serialized provider wrapper conflicting code (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on serialized provider wrapper conflicting code (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay when candidate queue leaves unexamined objects (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("reasoning_effort"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay when candidate queue leaves unexamined objects (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("reasoning_effort"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on incomplete structured wrapper (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on incomplete structured wrapper (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - } - return chatSuccess(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - } - return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test.each([ - ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], - ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], - ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], - ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], - ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-chat)", async (_name, payload) => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(payload, { status: 400, headers: { "content-type": "application/json" } }); - } - return chatSuccess(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test.each([ - ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], - ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], - ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], - ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], - ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-responses)", async (_name, payload) => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(payload, { status: 400, headers: { "content-type": "application/json" } }); - } - return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test.each([ - ["openai-chat", config], - ["openai-responses", passthroughConfig], - ])("stops before effort replay on conflicting permission diagnostic prefix outside extracted envelope (%s)", async (_name, makeConfig) => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(CONFLICTING_PERMISSION_PREFIX_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = makeConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); - } - return chatSuccess(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); - } - return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("replays downgrade when nested upstream_error message has ordinary request input (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); - } - return chatSuccess(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("replays downgrade when nested upstream_error message has ordinary request input (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - if (outbound.length === 1) { - return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); - } - return new Response(JSON.stringify({ id: "resp-ok" }), { status: 200, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on incomplete outer object ending with inner brace (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on incomplete outer object ending with inner brace (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on hard policy refusal in errors carrier (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("bio_policy"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on hard policy refusal in errors carrier (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("bio_policy"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on conflicting code in errors carrier (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on conflicting code in errors carrier (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on serialized hard policy refusal inside errors array (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("bio_policy"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on serialized hard policy refusal inside errors array (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("bio_policy"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on serialized conflicting code inside errors array (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on serialized conflicting code inside errors array (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("permission_denied"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on unrelated refusal with echoed request param (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on unrelated refusal with echoed request param (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on serialized wrapper containing echoed request param (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on serialized wrapper containing echoed request param (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("stops before effort replay on conflicting diagnostic parameters (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on conflicting diagnostic parameters (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("stops before effort replay on non-effort parameter with effort claim in detail (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("stops before effort replay on non-effort parameter with effort claim in detail (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("Invalid temperature."); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-chat)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-responses)", async () => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length === 1 - ? new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) - : success(); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - await response.text(); - - expect(outbound).toHaveLength(2); - expect(effortOf(outbound[0])).toBe("max"); - expect(effortOf(outbound[1])).toBe("high"); - expect(response.status).toBe(200); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); - }); - - test.each([ - ["balanced malformed JSON", BALANCED_MALFORMED_JSON_REFUSAL], - ["balanced malformed bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], - ["valid bracket with conflicting code", VALID_BRACKET_CONFLICTING_REFUSAL], - ])("stops before effort replay on %s refusal (openai-chat)", async (_label, refusal) => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = config(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test.each([ - ["balanced malformed JSON", BALANCED_MALFORMED_JSON_REFUSAL], - ["balanced malformed bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], - ["valid bracket with conflicting code", VALID_BRACKET_CONFLICTING_REFUSAL], - ])("stops before effort replay on %s refusal (openai-responses)", async (_label, refusal) => { - writeSnapshot(["low", "high", "max"]); - const outbound: Array> = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - outbound.push(JSON.parse(String(init?.body)) as Record); - return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const cfg = passthroughConfig(); - const response = await handleResponses(request(), cfg, logCtx); - const text = await response.text(); - - expect(outbound).toHaveLength(1); - expect(effortOf(outbound[0])).toBe("max"); - expect(response.status).toBe(400); - expect(text).toContain("unknown_terminal_code"); - expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); - expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); - }); - - test.each([ - ["JSON", BALANCED_MALFORMED_JSON_REFUSAL], - ["bracket", BALANCED_MALFORMED_BRACKET_REFUSAL], - ["valid bracket conflicting", VALID_BRACKET_CONFLICTING_REFUSAL], - ])("combo stops immediately on %s refusal without walking ladder", async (_label, refusal) => { - writeSnapshot(["low", "high", "max"]); - const attempts: Array<{ host: string; effort: unknown }> = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = new URL(input instanceof Request ? input.url : String(input)); - const body = JSON.parse(String(init?.body)) as Record; - attempts.push({ host: url.host, effort: effortOf(body) }); - return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const comboConfig = { - providers: { - first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, - fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, - }, - combos: { - effort: { - strategy: "failover", - targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], - }, - }, - } as OcxConfig; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); - const result = await response.text(); - - expect(response.status).toBe(400); - expect(result).toContain("unknown_terminal_code"); - expect(attempts).toEqual([{ host: "opencode.ai", effort: "max" }]); - }); - - test("combo preserves 413 translation-budget refusal on rebuild without trying fallback", async () => { - writeSnapshot(["low", "high", "max"]); - const attempts: Array<{ host: string; effort: unknown }> = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = new URL(input instanceof Request ? input.url : String(input)); - const body = JSON.parse(String(init?.body)) as Record; - attempts.push({ host: url.host, effort: effortOf(body) }); - return url.host === "fallback.example" - ? chatSuccess("fallback served the turn", MODEL) - : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); - }) as typeof fetch; - const comboConfig = { - providers: { - first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, - fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, - }, - combos: { - effort: { - strategy: "failover", - targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], - }, - }, - } as OcxConfig; - const logCtx: RequestLogContext = { model: "", provider: "" }; - - const adapterRegistry = await import("../../src/adapters/registry"); - const originalCreate = adapterRegistry.createRegisteredAdapter; - let buildAttempts = 0; - const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { - const adapter = originalCreate(wire, provider, ctx); - const originalBuild = adapter.buildRequest.bind(adapter); - adapter.buildRequest = async (parsed, opts) => { - buildAttempts += 1; - if (buildAttempts === 2) { - const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); - throw new TranslatorBudgetExceededError("live_transient", 1); - } - return originalBuild(parsed, opts); - }; - return adapter; - }); - - try { - const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); - expect(response.status).toBe(413); - const json = await response.json() as Record; - expect(json.error?.code).toBe("context_length_exceeded"); - expect(attempts).toEqual([ - { host: "opencode.ai", effort: "max" }, - ]); - } finally { - spy.mockRestore(); - } - }); }); From 006b2f53864e7369f9da6098eb8f6e7198dd3729 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:54:02 +0600 Subject: [PATCH 29/39] chore(layout): pack layout entries to stay within 2000-line cap --- scripts/test-layout/layout.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dbe04161d04..d3b61ae5df6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1579,8 +1579,7 @@ "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", - "responses-reasoning-effort-diagnostics.test.ts": "responses", - "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", + "responses-reasoning-effort-diagnostics.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", "responses-url-citation-title-repair.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", "responses-translated-reset.test.ts": "responses", From b7d49a372191b311b74dc7e41748f9c271b14ba5 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:43:40 +0600 Subject: [PATCH 30/39] fix(reasoning): reconcile diagnostic subject for unlisted field names and stop effort replay --- src/providers/reasoning-metadata.ts | 76 +++++++++++++++- ...sponses-reasoning-effort-downgrade.test.ts | 90 +++++++++++++++++++ 2 files changed, 163 insertions(+), 3 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 66552967e4f..c736dd859bd 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -657,6 +657,31 @@ const GENERIC_PARAM_WORDS = new Set([ "unrecognized", "unexpected", "unallowed", + "it", + "this", + "that", + "these", + "those", + "which", + "what", + "all", + "each", + "every", + "both", + "either", + "neither", + "one", + "they", + "there", + "here", + "string", + "number", + "boolean", + "integer", + "array", + "object", + "null", + "none", ]); function diagnosticTextNamesOtherParameter(text: string): boolean { @@ -675,6 +700,33 @@ function diagnosticTextNamesOtherParameter(text: string): boolean { if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; } + const subjectMustBe = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+must\s+be\b/gi; + for (let m = subjectMustBe.exec(s); m; m = subjectMustBe.exec(s)) { + const p = cleanParam(m[1]); + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + } + + const subjectIsRejection = + /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:is|are)\s+(?:invalid|unsupported|unrecognized|unknown|not\s+allowed|not\s+permitted|not\s+supported|required|missing)\b/gi; + for (let m = subjectIsRejection.exec(s); m; m = subjectIsRejection.exec(s)) { + const p = cleanParam(m[1]); + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + } + + const subjectColon = + /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s*:\s*(?:must\s+be|invalid|unsupported|unrecognized|unknown|expected|required|missing|not\s+allowed)\b/gi; + for (let m = subjectColon.exec(s); m; m = subjectColon.exec(s)) { + const p = cleanParam(m[1]); + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + } + + const rejectionExtra = + /\b(?:unexpected|missing|cannot\s+(?:specify|use|set|pass|provide))\s+(?:field|param|parameter|argument|property)?\s*['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; + for (let m = rejectionExtra.exec(s); m; m = rejectionExtra.exec(s)) { + const p = cleanParam(m[1]); + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + } + const nonEffortTerm = /\b(?:temperature|top_p|top_k|max_tokens|max_completion_tokens|max_output_tokens|min_tokens|stop|stop_sequences|presence_penalty|frequency_penalty|repetition_penalty|logit_bias|logprobs|top_logprobs|seed|tools|tool_choice|functions|function_call|response_format|stream|stream_options|user|service_tier|modalities|prediction|parallel_tool_calls|messages|prompt)\b/gi; for (let m = nonEffortTerm.exec(s); m; m = nonEffortTerm.exec(s)) { const from = Math.max(0, m.index - REJECTION_WINDOW); @@ -885,13 +937,31 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo } function diagnosticTextBlamesReasoningEffort(text: string): boolean { + if (diagnosticTextNamesOtherParameter(text)) return false; if (/unsupported.{0,24}effort/i.test(text)) return true; const scanned = text.replace(GENERIC_ERROR_TYPE, " "); const term = /reasoning\.effort|reasoning_effort|reasoning effort|thinking budget|reasoning_parameters/gi; for (let match = term.exec(scanned); match; match = term.exec(scanned)) { - const from = Math.max(0, match.index - REJECTION_WINDOW); - const to = Math.min(scanned.length, match.index + match[0].length + REJECTION_WINDOW); - if (REJECTION_LANGUAGE.test(scanned.slice(from, to))) return true; + const preText = scanned.slice(0, match.index); + const postText = scanned.slice(match.index + match[0].length); + const lastBoundary = Math.max( + preText.lastIndexOf(";"), + preText.lastIndexOf("\n"), + preText.lastIndexOf("|"), + preText.search(/\.(?:\s+|$)(?=[^.]*$)/) + ); + const clauseStart = lastBoundary !== -1 ? lastBoundary + 1 : 0; + const nextBoundary = postText.search(/[;\n|]|\.(?:\s+|$)/); + const clauseEnd = nextBoundary !== -1 ? match.index + match[0].length + nextBoundary : scanned.length; + + const from = Math.max(clauseStart, match.index - REJECTION_WINDOW); + const to = Math.min(clauseEnd, match.index + match[0].length + REJECTION_WINDOW); + const windowText = scanned.slice(from, to); + if (REJECTION_LANGUAGE.test(windowText)) { + if (!diagnosticTextNamesOtherParameter(windowText)) { + return true; + } + } } return false; } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index a3da384d25a..4ea65b79829 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -62,6 +62,12 @@ const CONFLICTING_CODE_REFUSAL = JSON.stringify({ message: "Invalid reasoning effort.", }, }); +const UNLISTED_PARAM_DIAGNOSTIC_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "custom_field must be a string; reasoning_effort is high.", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -659,4 +665,88 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }); + test("classifies unlisted parameter predicate as conflicting diagnostic subject", () => { + expect(hasConflictingReasoningEffortCode(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL)).toBe(false); + }); + + test("does not replay effort or learn rejection when unlisted field is diagnostic subject (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("custom_field must be a string"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("does not replay effort or learn rejection when unlisted field is diagnostic subject (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("custom_field must be a string"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test.each(["openai-chat", "openai-responses"] as const)( + "combo stops immediately without walking ladder when unlisted field is diagnostic subject (%s)", + async (adapter) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("custom_field must be a string"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(false); + } + ); + }); From eb0b378d1358e326147cd9c032483bdf0abc8dbf Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:24:34 +0600 Subject: [PATCH 31/39] fix(reasoning): classify effort keywords as generic param words to support requested effort refusal shapes --- src/providers/reasoning-metadata.ts | 14 +++++ ...sponses-reasoning-effort-downgrade.test.ts | 60 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index c736dd859bd..f0a15e924f7 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -572,6 +572,7 @@ const EFFORT_PARAM_NAMES = new Set([ "reasoning.effort", "reasoning", "reasoning_parameters", + "effort", ]); function cleanParam(str: string): string { @@ -682,6 +683,19 @@ const GENERIC_PARAM_WORDS = new Set([ "object", "null", "none", + "low", + "medium", + "high", + "max", + "xhigh", + "minimal", + "standard", + "default", + "effort", + "level", + "mode", + "rung", + "rungs", ]); function diagnosticTextNamesOtherParameter(text: string): boolean { diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 4ea65b79829..7d3bf06c382 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -260,6 +260,66 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); }); + test.each(["openai-chat", "openai-responses"] as const)( + "replays once on requested reasoning effort not supported phrasing (%s)", + async (adapter) => { + writeSnapshot(["low", "high", "max"]); + const requestedEffortRefusal = JSON.stringify({ + error: { + message: "The requested reasoning effort is not supported for this model", + }, + }); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(requestedEffortRefusal, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(true), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + }, + ); + + test.each(["openai-chat", "openai-responses"] as const)( + "replays once on translated param effort not supported phrasing (%s)", + async (adapter) => { + writeSnapshot(["low", "high", "max"]); + const paramEffortRefusal = JSON.stringify({ + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "reasoning_effort max is not supported for this model", + }, + }); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(paramEffortRefusal, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(true), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + }, + ); + // The guard used to live inside the generic `recovery:` loop, so every `continue recovery` // handed the turn a fresh downgrade budget and one request could walk the ladder down. test("downgrades at most once even when the replay is refused again", async () => { From 2dc0d6f7b60865ecdedcefa1d857fbad07384f17 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:59:15 +0600 Subject: [PATCH 32/39] fix(reasoning): distinguish effort-value descriptions in context from separately rejected fields (mode, level) --- src/providers/reasoning-metadata.ts | 24 +++-- ...sponses-reasoning-effort-downgrade.test.ts | 90 +++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index f0a15e924f7..43cf07475dc 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -683,6 +683,9 @@ const GENERIC_PARAM_WORDS = new Set([ "object", "null", "none", +]); + +const EFFORT_VALUE_WORDS = new Set([ "low", "medium", "high", @@ -691,13 +694,18 @@ const GENERIC_PARAM_WORDS = new Set([ "minimal", "standard", "default", - "effort", "level", "mode", "rung", "rungs", ]); +function isEffortDescriptorInContext(text: string, matchIndex: number, param: string): boolean { + if (!EFFORT_VALUE_WORDS.has(param)) return false; + const preText = text.slice(Math.max(0, matchIndex - 32), matchIndex); + return /(?:reasoning[._\s]+effort|reasoning|effort)\s*[:=]?\s*['"`]?$/i.test(preText); +} + function diagnosticTextNamesOtherParameter(text: string): boolean { if (!text) return false; const s = text.replace(GENERIC_ERROR_TYPE, " "); @@ -705,40 +713,40 @@ function diagnosticTextNamesOtherParameter(text: string): boolean { const paramPattern = /\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`])([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = paramPattern.exec(s); m; m = paramPattern.exec(s)) { const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; } const prefixParamPattern = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/gi; for (let m = prefixParamPattern.exec(s); m; m = prefixParamPattern.exec(s)) { const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; } const subjectMustBe = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+must\s+be\b/gi; for (let m = subjectMustBe.exec(s); m; m = subjectMustBe.exec(s)) { const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; } const subjectIsRejection = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:is|are)\s+(?:invalid|unsupported|unrecognized|unknown|not\s+allowed|not\s+permitted|not\s+supported|required|missing)\b/gi; for (let m = subjectIsRejection.exec(s); m; m = subjectIsRejection.exec(s)) { const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; } const subjectColon = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s*:\s*(?:must\s+be|invalid|unsupported|unrecognized|unknown|expected|required|missing|not\s+allowed)\b/gi; for (let m = subjectColon.exec(s); m; m = subjectColon.exec(s)) { const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; } const rejectionExtra = /\b(?:unexpected|missing|cannot\s+(?:specify|use|set|pass|provide))\s+(?:field|param|parameter|argument|property)?\s*['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = rejectionExtra.exec(s); m; m = rejectionExtra.exec(s)) { const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; } const nonEffortTerm = /\b(?:temperature|top_p|top_k|max_tokens|max_completion_tokens|max_output_tokens|min_tokens|stop|stop_sequences|presence_penalty|frequency_penalty|repetition_penalty|logit_bias|logprobs|top_logprobs|seed|tools|tool_choice|functions|function_call|response_format|stream|stream_options|user|service_tier|modalities|prediction|parallel_tool_calls|messages|prompt)\b/gi; @@ -751,7 +759,7 @@ function diagnosticTextNamesOtherParameter(text: string): boolean { const rejectionDirect = /\b(?:invalid|unsupported|unrecognized|unknown)\s+['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = rejectionDirect.exec(s); m; m = rejectionDirect.exec(s)) { const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p)) return true; + if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; } return false; } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 7d3bf06c382..b175089535e 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -68,6 +68,18 @@ const UNLISTED_PARAM_DIAGNOSTIC_REFUSAL = JSON.stringify({ message: "custom_field must be a string; reasoning_effort is high.", }, }); +const MODE_INVALID_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "reasoning effort is unsupported; mode is invalid", + }, +}); +const LEVEL_INVALID_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "reasoning effort is unsupported; level is invalid", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -809,4 +821,82 @@ describe("rejected reasoning rungs", () => { } ); + test.each([ + ["mode", MODE_INVALID_REFUSAL], + ["level", LEVEL_INVALID_REFUSAL], + ])("classifies explicitly invalid %s as conflicting diagnostic subject", (_field, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(true); + expect(isReasoningEffortRejection(refusal)).toBe(false); + }); + + test.each([ + ["openai-chat", "mode", MODE_INVALID_REFUSAL], + ["openai-chat", "level", LEVEL_INVALID_REFUSAL], + ["openai-responses", "mode", MODE_INVALID_REFUSAL], + ["openai-responses", "level", LEVEL_INVALID_REFUSAL], + ] as const)( + "stops after one send without effort replay or learned rejection when %s is invalid (%s)", + async (adapter, field, refusal) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain(`${field} is invalid`); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }, + ); + + test.each([ + ["openai-chat", "mode", MODE_INVALID_REFUSAL], + ["openai-chat", "level", LEVEL_INVALID_REFUSAL], + ["openai-responses", "mode", MODE_INVALID_REFUSAL], + ["openai-responses", "level", LEVEL_INVALID_REFUSAL], + ] as const)( + "combo stops immediately without walking ladder when %s is invalid (%s)", + async (adapter, field, refusal) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain(`${field} is invalid`); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(false); + }, + ); }); From 478cf57f9187adc2c0680850aadb805d3c7dbd48 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:46:28 +0600 Subject: [PATCH 33/39] fix(providers): keep named effort value in diagnostic context eligible for downgrade --- src/providers/reasoning-metadata.ts | 19 +++- ...sponses-reasoning-effort-downgrade.test.ts | 97 +++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 43cf07475dc..ec866b797f0 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -685,7 +685,8 @@ const GENERIC_PARAM_WORDS = new Set([ "none", ]); -const EFFORT_VALUE_WORDS = new Set([ +const EFFORT_RUNGS = new Set([ + "none", "low", "medium", "high", @@ -694,16 +695,26 @@ const EFFORT_VALUE_WORDS = new Set([ "minimal", "standard", "default", +]); + +const STRUCTURAL_EFFORT_WORDS = new Set([ "level", "mode", "rung", "rungs", ]); +const REASONING_EFFORT_CONTEXT_REGEX = /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\b/i; + function isEffortDescriptorInContext(text: string, matchIndex: number, param: string): boolean { - if (!EFFORT_VALUE_WORDS.has(param)) return false; - const preText = text.slice(Math.max(0, matchIndex - 32), matchIndex); - return /(?:reasoning[._\s]+effort|reasoning|effort)\s*[:=]?\s*['"`]?$/i.test(preText); + if (EFFORT_RUNGS.has(param)) { + return REASONING_EFFORT_CONTEXT_REGEX.test(text); + } + if (STRUCTURAL_EFFORT_WORDS.has(param)) { + const preText = text.slice(Math.max(0, matchIndex - 32), matchIndex); + return /(?:reasoning[._\s]+effort|reasoning|effort)\s*[:=]?\s*['"`]?$/i.test(preText); + } + return false; } function diagnosticTextNamesOtherParameter(text: string): boolean { diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index b175089535e..f0e881ce278 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -80,6 +80,16 @@ const LEVEL_INVALID_REFUSAL = JSON.stringify({ message: "reasoning effort is unsupported; level is invalid", }, }); +const SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. Max is not supported for this model.", + }, +}); +const VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort. Max is not supported for this model.", +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -899,4 +909,91 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(false); }, ); + + test.each([ + ["standard error envelope", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["vendor string error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ])("classifies named effort value in separate sentence as valid effort rejection (%s)", (_label, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(false); + expect(isReasoningEffortRejection(refusal)).toBe(true); + }); + + test.each([ + ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ] as const)( + "replays and downgrades from max to high when effort value is in separate sentence (%s, %s)", + async (adapter, _kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("served at high effort", MODEL); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(text).toContain("served at high effort"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + ]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }, + ); + + test.each([ + ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ] as const)( + "combo probe downgrades effort and succeeds on lower rung when effort value is in separate sentence (%s, %s)", + async (adapter, _kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("served at high effort", MODEL); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("served at high effort"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(true); + }, + ); }); From 12701a617f129a647cca3e5afb781b7161799969 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:33:10 +0600 Subject: [PATCH 34/39] fix(providers): distinguish explicit field subjects from effort values in diagnostic text --- src/providers/reasoning-metadata.ts | 9 +++ ...sponses-reasoning-effort-downgrade.test.ts | 78 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index ec866b797f0..4083d20acef 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -706,7 +706,16 @@ const STRUCTURAL_EFFORT_WORDS = new Set([ const REASONING_EFFORT_CONTEXT_REGEX = /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\b/i; +function isExplicitFieldOrParameter(text: string, matchIndex: number, param: string): boolean { + const pre = text.slice(Math.max(0, matchIndex - 32), matchIndex); + if (/\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test(pre)) return true; + const post = text.slice(matchIndex + param.length, Math.min(text.length, matchIndex + param.length + 32)); + if (/^\s*['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/i.test(post)) return true; + return false; +} + function isEffortDescriptorInContext(text: string, matchIndex: number, param: string): boolean { + if (isExplicitFieldOrParameter(text, matchIndex, param)) return false; if (EFFORT_RUNGS.has(param)) { return REASONING_EFFORT_CONTEXT_REGEX.test(text); } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index f0e881ce278..70f0532c2f7 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -90,6 +90,12 @@ const VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort. Max is not supported for this model.", }); +const EXPLICIT_FIELD_EFFORT_WORD_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. The max field is invalid.", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -996,4 +1002,76 @@ describe("rejected reasoning rungs", () => { expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(true); }, ); + + test("classifies explicit invalid field naming effort word as conflicting diagnostic subject", () => { + expect(hasConflictingReasoningEffortCode(EXPLICIT_FIELD_EFFORT_WORD_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(EXPLICIT_FIELD_EFFORT_WORD_REFUSAL)).toBe(false); + }); + + test.each([ + ["openai-chat", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ["openai-responses", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ] as const)( + "stops after one send without effort replay or learned rejection when effort word is an explicit invalid field (%s)", + async (adapter, refusal) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("max field is invalid"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }, + ); + + test.each([ + ["openai-chat", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ["openai-responses", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ] as const)( + "combo stops after one send without walking ladder when effort word is an explicit invalid field (%s)", + async (adapter, refusal) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("max field is invalid"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(false); + }, + ); }); From 7f06933d477e0099bf6372575d23be3f230e9b78 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:25:55 +0600 Subject: [PATCH 35/39] fix(providers): preserve explicitly named competing fields through descriptor checks --- src/providers/reasoning-metadata.ts | 42 ++++++++++------ ...sponses-reasoning-effort-downgrade.test.ts | 50 ++++++++++++++----- 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 4083d20acef..5848bdbf5e0 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -726,47 +726,61 @@ function isEffortDescriptorInContext(text: string, matchIndex: number, param: st return false; } +function tokenStartIndex(m: RegExpExecArray, rawToken: string, isPrefixToken: boolean): number { + const offset = isPrefixToken ? m[0].indexOf(rawToken) : m[0].lastIndexOf(rawToken); + return offset >= 0 ? m.index + offset : m.index; +} + +function isCompetingParameterToken(text: string, tokenIndex: number, rawToken: string): boolean { + const p = cleanParam(rawToken); + if (!p || EFFORT_PARAM_NAMES.has(p)) return false; + if (isExplicitFieldOrParameter(text, tokenIndex, p)) return true; + if (GENERIC_PARAM_WORDS.has(p)) return false; + if (isEffortDescriptorInContext(text, tokenIndex, p)) return false; + return true; +} + function diagnosticTextNamesOtherParameter(text: string): boolean { if (!text) return false; const s = text.replace(GENERIC_ERROR_TYPE, " "); const paramPattern = /\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`])([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = paramPattern.exec(s); m; m = paramPattern.exec(s)) { - const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; + const tokenIndex = tokenStartIndex(m, m[1], false); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; } const prefixParamPattern = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/gi; for (let m = prefixParamPattern.exec(s); m; m = prefixParamPattern.exec(s)) { - const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; } const subjectMustBe = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+must\s+be\b/gi; for (let m = subjectMustBe.exec(s); m; m = subjectMustBe.exec(s)) { - const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; } const subjectIsRejection = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:is|are)\s+(?:invalid|unsupported|unrecognized|unknown|not\s+allowed|not\s+permitted|not\s+supported|required|missing)\b/gi; for (let m = subjectIsRejection.exec(s); m; m = subjectIsRejection.exec(s)) { - const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; } const subjectColon = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s*:\s*(?:must\s+be|invalid|unsupported|unrecognized|unknown|expected|required|missing|not\s+allowed)\b/gi; for (let m = subjectColon.exec(s); m; m = subjectColon.exec(s)) { - const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; } const rejectionExtra = /\b(?:unexpected|missing|cannot\s+(?:specify|use|set|pass|provide))\s+(?:field|param|parameter|argument|property)?\s*['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = rejectionExtra.exec(s); m; m = rejectionExtra.exec(s)) { - const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; + const tokenIndex = tokenStartIndex(m, m[1], false); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; } const nonEffortTerm = /\b(?:temperature|top_p|top_k|max_tokens|max_completion_tokens|max_output_tokens|min_tokens|stop|stop_sequences|presence_penalty|frequency_penalty|repetition_penalty|logit_bias|logprobs|top_logprobs|seed|tools|tool_choice|functions|function_call|response_format|stream|stream_options|user|service_tier|modalities|prediction|parallel_tool_calls|messages|prompt)\b/gi; @@ -778,8 +792,8 @@ function diagnosticTextNamesOtherParameter(text: string): boolean { const rejectionDirect = /\b(?:invalid|unsupported|unrecognized|unknown)\s+['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = rejectionDirect.exec(s); m; m = rejectionDirect.exec(s)) { - const p = cleanParam(m[1]); - if (p.length > 0 && !EFFORT_PARAM_NAMES.has(p) && !GENERIC_PARAM_WORDS.has(p) && !isEffortDescriptorInContext(s, m.index, p)) return true; + const tokenIndex = tokenStartIndex(m, m[1], false); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; } return false; } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 70f0532c2f7..b3614aa9ffd 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -96,6 +96,18 @@ const EXPLICIT_FIELD_EFFORT_WORD_REFUSAL = JSON.stringify({ message: "Invalid reasoning effort. The max field is invalid.", }, }); +const INVALID_FIELD_COLON_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. Invalid field: max.", + }, +}); +const THE_NONE_FIELD_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. The none field is invalid.", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -1003,17 +1015,25 @@ describe("rejected reasoning rungs", () => { }, ); - test("classifies explicit invalid field naming effort word as conflicting diagnostic subject", () => { - expect(hasConflictingReasoningEffortCode(EXPLICIT_FIELD_EFFORT_WORD_REFUSAL)).toBe(true); - expect(isReasoningEffortRejection(EXPLICIT_FIELD_EFFORT_WORD_REFUSAL)).toBe(false); + test.each([ + ["The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ["Invalid field: max", INVALID_FIELD_COLON_REFUSAL], + ["The none field is invalid", THE_NONE_FIELD_REFUSAL], + ])("classifies explicit invalid field (%s) as conflicting diagnostic subject", (_desc, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(true); + expect(isReasoningEffortRejection(refusal)).toBe(false); }); test.each([ - ["openai-chat", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], - ["openai-responses", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ["openai-chat", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-chat", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-chat", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-responses", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-responses", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-responses", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], ] as const)( - "stops after one send without effort replay or learned rejection when effort word is an explicit invalid field (%s)", - async (adapter, refusal) => { + "stops after one send without effort replay or learned rejection when effort word is an explicit invalid field (%s, %s)", + async (adapter, _desc, refusal, expectedSubstring) => { writeSnapshot(["low", "high", "max"]); const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -1029,18 +1049,22 @@ describe("rejected reasoning rungs", () => { expect(outbound).toHaveLength(1); expect(effortOf(outbound[0])).toBe("max"); expect(response.status).toBe(400); - expect(text).toContain("max field is invalid"); + expect(text).toContain(expectedSubstring); expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); }, ); test.each([ - ["openai-chat", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], - ["openai-responses", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ["openai-chat", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-chat", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-chat", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-responses", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-responses", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-responses", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], ] as const)( - "combo stops after one send without walking ladder when effort word is an explicit invalid field (%s)", - async (adapter, refusal) => { + "combo stops after one send without walking ladder when effort word is an explicit invalid field (%s, %s)", + async (adapter, _desc, refusal, expectedSubstring) => { writeSnapshot(["low", "high", "max"]); const attempts: Array<{ host: string; effort: unknown }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -1067,7 +1091,7 @@ describe("rejected reasoning rungs", () => { const result = await response.text(); expect(response.status).toBe(400); - expect(result).toContain("max field is invalid"); + expect(result).toContain(expectedSubstring); expect(attempts).toEqual([ { host: "opencode.ai", effort: "max" }, ]); From 2e5099c18e84c56c551fbb5f72196eef6cf42e04 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:13:09 +0600 Subject: [PATCH 36/39] fix(providers): recognize unquoted parameter subjects and ultra effort descriptor --- src/providers/reasoning-metadata.ts | 3 +- ...sponses-reasoning-effort-downgrade.test.ts | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 5848bdbf5e0..ffd7e605dfc 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -692,6 +692,7 @@ const EFFORT_RUNGS = new Set([ "high", "max", "xhigh", + "ultra", "minimal", "standard", "default", @@ -744,7 +745,7 @@ function diagnosticTextNamesOtherParameter(text: string): boolean { if (!text) return false; const s = text.replace(GENERIC_ERROR_TYPE, " "); - const paramPattern = /\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`])([a-zA-Z0-9_\-\.]+)['"`]?/gi; + const paramPattern = /\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = paramPattern.exec(s); m; m = paramPattern.exec(s)) { const tokenIndex = tokenStartIndex(m, m[1], false); if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index b3614aa9ffd..906be9abb4c 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -108,6 +108,30 @@ const THE_NONE_FIELD_REFUSAL = JSON.stringify({ message: "Invalid reasoning effort. The none field is invalid.", }, }); +const UNQUOTED_PARAM_MAX_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "parameter max cannot be negative; reasoning effort high is unsupported", + }, +}); +const ULTRA_EFFORT_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Reasoning effort ultra is invalid", + }, +}); +const THE_ULTRA_FIELD_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. The ultra field is invalid.", + }, +}); +const INVALID_FIELD_ULTRA_COLON_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. Invalid field: ultra.", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -931,7 +955,8 @@ describe("rejected reasoning rungs", () => { test.each([ ["standard error envelope", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["vendor string error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], - ])("classifies named effort value in separate sentence as valid effort rejection (%s)", (_label, refusal) => { + ["ultra descriptor", ULTRA_EFFORT_REFUSAL], + ])("classifies named effort value or descriptor as valid effort rejection (%s)", (_label, refusal) => { expect(hasConflictingReasoningEffortCode(refusal)).toBe(false); expect(isReasoningEffortRejection(refusal)).toBe(true); }); @@ -939,10 +964,12 @@ describe("rejected reasoning rungs", () => { test.each([ ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "ultra descriptor", ULTRA_EFFORT_REFUSAL], ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "ultra descriptor", ULTRA_EFFORT_REFUSAL], ] as const)( - "replays and downgrades from max to high when effort value is in separate sentence (%s, %s)", + "replays and downgrades from max to high when effort value is in separate sentence or ultra descriptor (%s, %s)", async (adapter, _kind, refusal) => { writeSnapshot(["low", "high", "max"]); const attempts: Array<{ host: string; effort: unknown }> = []; @@ -973,8 +1000,10 @@ describe("rejected reasoning rungs", () => { test.each([ ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "ultra descriptor", ULTRA_EFFORT_REFUSAL], ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "ultra descriptor", ULTRA_EFFORT_REFUSAL], ] as const)( "combo probe downgrades effort and succeeds on lower rung when effort value is in separate sentence (%s, %s)", async (adapter, _kind, refusal) => { @@ -1019,6 +1048,9 @@ describe("rejected reasoning rungs", () => { ["The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], ["Invalid field: max", INVALID_FIELD_COLON_REFUSAL], ["The none field is invalid", THE_NONE_FIELD_REFUSAL], + ["parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL], + ["The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL], + ["Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL], ])("classifies explicit invalid field (%s) as conflicting diagnostic subject", (_desc, refusal) => { expect(hasConflictingReasoningEffortCode(refusal)).toBe(true); expect(isReasoningEffortRejection(refusal)).toBe(false); @@ -1028,9 +1060,15 @@ describe("rejected reasoning rungs", () => { ["openai-chat", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], ["openai-chat", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], ["openai-chat", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-chat", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-chat", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-chat", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], ["openai-responses", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], ["openai-responses", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], ["openai-responses", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-responses", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-responses", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-responses", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], ] as const)( "stops after one send without effort replay or learned rejection when effort word is an explicit invalid field (%s, %s)", async (adapter, _desc, refusal, expectedSubstring) => { @@ -1059,9 +1097,15 @@ describe("rejected reasoning rungs", () => { ["openai-chat", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], ["openai-chat", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], ["openai-chat", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-chat", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-chat", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-chat", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], ["openai-responses", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], ["openai-responses", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], ["openai-responses", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-responses", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-responses", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-responses", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], ] as const)( "combo stops after one send without walking ladder when effort word is an explicit invalid field (%s, %s)", async (adapter, _desc, refusal, expectedSubstring) => { From 18d723422f67fe19a3bec449c88fb72dad8991ef Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:59:03 +0600 Subject: [PATCH 37/39] fix(providers): distinguish verbs following effort parameter from unquoted field names --- src/providers/reasoning-metadata.ts | 39 ++++++++++++++++++- ...sponses-reasoning-effort-downgrade.test.ts | 13 ++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index ffd7e605dfc..9f01d8b92b3 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -705,10 +705,45 @@ const STRUCTURAL_EFFORT_WORDS = new Set([ "rungs", ]); +const SUBJECT_FOLLOWING_VERBS = new Set([ + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "has", + "have", + "had", + "does", + "do", + "did", + "cannot", + "cant", + "can", + "could", + "will", + "would", + "shall", + "should", + "may", + "might", + "must", +]); + const REASONING_EFFORT_CONTEXT_REGEX = /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\b/i; function isExplicitFieldOrParameter(text: string, matchIndex: number, param: string): boolean { - const pre = text.slice(Math.max(0, matchIndex - 32), matchIndex); + if (SUBJECT_FOLLOWING_VERBS.has(param)) return false; + const pre = text.slice(Math.max(0, matchIndex - 48), matchIndex); + if ( + /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\s+(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test( + pre + ) + ) { + return false; + } if (/\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test(pre)) return true; const post = text.slice(matchIndex + param.length, Math.min(text.length, matchIndex + param.length + 32)); if (/^\s*['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/i.test(post)) return true; @@ -734,7 +769,7 @@ function tokenStartIndex(m: RegExpExecArray, rawToken: string, isPrefixToken: bo function isCompetingParameterToken(text: string, tokenIndex: number, rawToken: string): boolean { const p = cleanParam(rawToken); - if (!p || EFFORT_PARAM_NAMES.has(p)) return false; + if (!p || EFFORT_PARAM_NAMES.has(p) || SUBJECT_FOLLOWING_VERBS.has(p)) return false; if (isExplicitFieldOrParameter(text, tokenIndex, p)) return true; if (GENERIC_PARAM_WORDS.has(p)) return false; if (isEffortDescriptorInContext(text, tokenIndex, p)) return false; diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index 906be9abb4c..e553408c9a9 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -132,6 +132,12 @@ const INVALID_FIELD_ULTRA_COLON_REFUSAL = JSON.stringify({ message: "Invalid reasoning effort. Invalid field: ultra.", }, }); +const REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "The reasoning effort parameter is unsupported", + }, +}); const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); @@ -956,6 +962,7 @@ describe("rejected reasoning rungs", () => { ["standard error envelope", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["vendor string error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["reasoning effort parameter is unsupported", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], ])("classifies named effort value or descriptor as valid effort rejection (%s)", (_label, refusal) => { expect(hasConflictingReasoningEffortCode(refusal)).toBe(false); expect(isReasoningEffortRejection(refusal)).toBe(true); @@ -965,11 +972,13 @@ describe("rejected reasoning rungs", () => { ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-chat", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-chat", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-responses", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-responses", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], ] as const)( - "replays and downgrades from max to high when effort value is in separate sentence or ultra descriptor (%s, %s)", + "replays and downgrades from max to high when effort value is in separate sentence, ultra descriptor, or verb follows effort parameter (%s, %s)", async (adapter, _kind, refusal) => { writeSnapshot(["low", "high", "max"]); const attempts: Array<{ host: string; effort: unknown }> = []; @@ -1001,9 +1010,11 @@ describe("rejected reasoning rungs", () => { ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-chat", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-chat", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], ["openai-responses", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-responses", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], ] as const)( "combo probe downgrades effort and succeeds on lower rung when effort value is in separate sentence (%s, %s)", async (adapter, _kind, refusal) => { From 7741ab34bb992aa04dd5956e89d4d298f8f4b63c Mon Sep 17 00:00:00 2001 From: cpa Date: Tue, 10 Nov 2026 19:54:06 +0900 Subject: [PATCH 38/39] fix(providers): keep canonical effort refusals repairable The reviewer reproduced a regression from the combo ladder branch: a genuine reasoning-effort refusal returned 400 where dev repaired it. Four independent heuristics in the conflicting-envelope classifier each read a diagnostic word or the refused rung as a competing request field, so `hasConflictingReasoningEffortCode` vetoed the rejection before either the direct one-repair path or the combo ladder could run. - `Unsupported parameter: 'reasoning_effort'` matched the `field` parameter subject pattern with "Unsupported" as the field; `Unsupported value: 'max'` made "max" the subject of "is not supported". Diagnostic adjectives and nouns (and determiners such as "following") are now never competing subjects. - A rung value is an effort descriptor unless it is introduced as a field in its own right (`the max field`, `Invalid field: max`, `parameter max`), and the descriptor window is a clause rather than the whole body. - "thinking budget 32000" read the bare number as a field name; numeric literals are values, not subjects. - `reasoning_effort must be one of ['low','medium','high']` was rejected as a malformed/reflected bracket envelope; a slice made only of rung names is a published-value list. - Google's `{"code":400,...}` echoed the HTTP status into `code`, which the walk treated as an unrecognized vendor code. A 4xx numeric code is the status. A structured `param`/field naming reasoning effort is now authoritative: text inference may only override it by naming a concrete other request field (temperature, max_tokens, ...), never by reading an adjective or a quoted value. The prior narrowings that must stay correct still do: the conflicting `invalid-argument` + `Invalid temperature.` envelope, the `parameter max cannot be negative` clause pair, explicit invalid fields, and every non-effort 400. Also stops `withRawBodyReasoningEffort` from inventing a `reasoning` object or overriding a string/null one on the downgrade rewrite; it now writes only the shape the caller sent. Regression coverage drives the real `handleResponses` path for all five canonical envelopes, direct (2 sends, 200) and combo (max -> high) on both the Chat and Responses adapters. --- src/providers/reasoning-metadata.ts | 215 ++++++++++++++++-- src/server/responses/core-opaque-recovery.ts | 14 +- ...sponses-reasoning-effort-downgrade.test.ts | 180 +++++++++++++++ 3 files changed, 391 insertions(+), 18 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 9f01d8b92b3..f5368ff7a05 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -560,7 +560,9 @@ function isIncompleteStructuredWrapper(text: string): boolean { try { JSON.parse(bracketSlice); } catch { - return true; + // "reasoning_effort must be one of ['low','medium','high']" is a value list in prose, not + // a truncated or reflected envelope (#8173). + if (!isEffortValueEnumeration(bracketSlice)) return true; } } } @@ -573,6 +575,11 @@ const EFFORT_PARAM_NAMES = new Set([ "reasoning", "reasoning_parameters", "effort", + // A thinking-budget field is the same subject on the Anthropic-shaped wires: a refusal that + // names it is about the reasoning control, not about some other parameter. + "thinking_budget", + "thinking.budget", + "thinking.budget_tokens", ]); function cleanParam(str: string): string { @@ -698,6 +705,97 @@ const EFFORT_RUNGS = new Set([ "default", ]); +/** + * Words that describe a diagnostic, never name a request field. + * + * "Unsupported parameter: 'reasoning_effort'" puts an adjective exactly where a field name sits + * in "`field` parameter", and "Unsupported value: 'max'" puts the refused value under a noun. + * Reading either as the competing subject made genuine effort refusals terminal (#8173). + */ +const DIAGNOSTIC_NOISE_WORDS = new Set([ + "unsupported", + "invalid", + "unrecognized", + "unknown", + "unexpected", + "bad", + "malformed", + "value", + "values", + "parameter", + "parameters", + "param", + "params", + "field", + "fields", + "argument", + "arguments", + "arg", + "property", + "request", + "response", + "payload", + "body", + "error", + // Determiners and participles that introduce the subject: "The following parameter is not + // supported: reasoning_effort" puts one where a field name would sit. + "following", + "specified", + "given", + "provided", + "requested", + "supplied", + "above", + "a", + "an", + "the", + "same", +]); + +/** + * A bare number (or numeric literal) is a value in every diagnostic that names a limit, never a + * field name, so "thinking budget 32000 is not supported" is not a competing-parameter refusal. + */ +const NUMERIC_VALUE_TOKEN = /^[0-9]+(?:\.[0-9]+)?$/; + +/** Connectives and glue words allowed inside a published-value enumeration. */ +const EFFORT_ENUMERATION_FILLER = new Set([ + "and", + "or", + "values", + "value", + "supported", + "allowed", + "available", + "one", + "of", + "are", + "is", +]); + +/** + * Whether a bracket slice is just a list of effort rungs, e.g. `['low','medium','high']`. + * + * "reasoning_effort must be one of ['low','medium','high']" is prose that lists the ladder. The + * structured-wrapper checks treat any bracket payload with commas and quotes as a malformed or + * reflected envelope, which made this genuine refusal terminal (#8173). A payload composed only + * of rung names is a value list, not an envelope. + */ +function isEffortValueEnumeration(slice: string): boolean { + const tokens = slice.match(/[A-Za-z_][A-Za-z0-9_.\-]*|[0-9]+(?:\.[0-9]+)?/g); + if (!tokens || tokens.length === 0) return false; + return tokens.every(token => { + const normalized = token.toLowerCase(); + return EFFORT_RUNGS.has(normalized) || EFFORT_ENUMERATION_FILLER.has(normalized); + }); +} + +/** True when every bracketed group in the text is an effort-value enumeration. */ +function carriesOnlyEffortEnumerations(text: string): boolean { + const groups = text.match(/\[[^\]]*\]/g); + return !!groups && groups.length > 0 && groups.every(isEffortValueEnumeration); +} + const STRUCTURAL_EFFORT_WORDS = new Set([ "level", "mode", @@ -734,8 +832,19 @@ const SUBJECT_FOLLOWING_VERBS = new Set([ const REASONING_EFFORT_CONTEXT_REGEX = /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\b/i; +/** How far from a rung value its own clause may name effort and still describe it. */ +const EFFORT_DESCRIPTOR_WINDOW = 64; + function isExplicitFieldOrParameter(text: string, matchIndex: number, param: string): boolean { if (SUBJECT_FOLLOWING_VERBS.has(param)) return false; + // "Unsupported parameter", "Invalid value", "Missing field" put a diagnostic word exactly + // where a field name sits. It is never the subject the sentence is about (#8173). + if (DIAGNOSTIC_NOISE_WORDS.has(param)) return false; + // A rung value is never a field name: "Invalid reasoning effort. The max field is invalid." + // names its subject explicitly in the sentence before, and no diagnostic says "the max field" + // about an unrelated field named max. Judging the value on its own words first keeps the + // quoted-field windows below from being applied to a ladder rung. + if (EFFORT_RUNGS.has(param) && !namesRungAsDeclaredField(text, matchIndex, param)) return false; const pre = text.slice(Math.max(0, matchIndex - 48), matchIndex); if ( /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\s+(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test( @@ -750,10 +859,29 @@ function isExplicitFieldOrParameter(text: string, matchIndex: number, param: str return false; } +/** + * Whether the rung at `matchIndex` is introduced as a field in its own right — the shapes + * "the max field", "Invalid field: max", "parameter max" — rather than as a value under a + * diagnostic noun such as "Unsupported value: 'max'". + */ +function namesRungAsDeclaredField(text: string, matchIndex: number, param: string): boolean { + const pre = text.slice(Math.max(0, matchIndex - 48), matchIndex); + if (/\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test(pre)) return true; + const post = text.slice(matchIndex + param.length, Math.min(text.length, matchIndex + param.length + 32)); + return /^\s*['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/i.test(post); +} + function isEffortDescriptorInContext(text: string, matchIndex: number, param: string): boolean { - if (isExplicitFieldOrParameter(text, matchIndex, param)) return false; if (EFFORT_RUNGS.has(param)) { - return REASONING_EFFORT_CONTEXT_REGEX.test(text); + if (namesRungAsDeclaredField(text, matchIndex, param)) return false; + // A rung is an effort descriptor when the surrounding words tie it to effort. The context + // window is the whole diagnostic minus OTHER parameters: a message that ends + // "Supported values are: 'low','medium','high'" must not turn the refused 'max' into a + // competing field merely because the same sentence lists a ladder. + const pre = text.slice(Math.max(0, matchIndex - EFFORT_DESCRIPTOR_WINDOW), matchIndex); + if (REASONING_EFFORT_CONTEXT_REGEX.test(pre)) return true; + const post = text.slice(matchIndex + param.length, Math.min(text.length, matchIndex + param.length + EFFORT_DESCRIPTOR_WINDOW)); + return REASONING_EFFORT_CONTEXT_REGEX.test(post); } if (STRUCTURAL_EFFORT_WORDS.has(param)) { const preText = text.slice(Math.max(0, matchIndex - 32), matchIndex); @@ -770,15 +898,47 @@ function tokenStartIndex(m: RegExpExecArray, rawToken: string, isPrefixToken: bo function isCompetingParameterToken(text: string, tokenIndex: number, rawToken: string): boolean { const p = cleanParam(rawToken); if (!p || EFFORT_PARAM_NAMES.has(p) || SUBJECT_FOLLOWING_VERBS.has(p)) return false; + // A diagnostic adjective or noun ("Unsupported", "invalid", "value", "field") occupies the + // syntactic slot of a field name without naming one. GENERIC_PARAM_WORDS covers most of them; + // "bad" and "malformed" were missing there, so the two sets stay separate. + if (DIAGNOSTIC_NOISE_WORDS.has(p)) return false; + // A bare number names a limit, not a request field ("thinking budget 32000 is not supported"). + if (NUMERIC_VALUE_TOKEN.test(p)) return false; if (isExplicitFieldOrParameter(text, tokenIndex, p)) return true; if (GENERIC_PARAM_WORDS.has(p)) return false; if (isEffortDescriptorInContext(text, tokenIndex, p)) return false; return true; } -function diagnosticTextNamesOtherParameter(text: string): boolean { +/** + * Concrete request fields a diagnostic can name instead of effort. Unlike the heuristic subject + * patterns below, a member of this list is a real wire field, so naming one beside an + * authoritative effort param really is a different subject. + */ +const CONCRETE_FIELD_TERM = + /\b(?:temperature|top_p|top_k|max_tokens|max_completion_tokens|max_output_tokens|min_tokens|stop|stop_sequences|presence_penalty|frequency_penalty|repetition_penalty|logit_bias|logprobs|top_logprobs|seed|tools|tool_choice|functions|function_call|response_format|stream|stream_options|user|safety_identifier|service_tier|modalities|prediction|parallel_tool_calls|messages|prompt|input|image_url)\b/gi; + +/** + * Whether the text names a concrete non-effort request field while rejecting something. + * + * This is the only inference allowed to override a structured `param` that already names + * reasoning effort. Everything heuristic in `diagnosticTextNamesOtherParameter` — adjective + * subjects, quoted rungs, "value:" prefixes — must not, or it would re-break every canonical + * effort refusal whose prose happens to quote the refused value (#8173). + */ +function diagnosticTextNamesConcreteOtherField(text: string): boolean { + for (let m = CONCRETE_FIELD_TERM.exec(text); m; m = CONCRETE_FIELD_TERM.exec(text)) { + const from = Math.max(0, m.index - REJECTION_WINDOW); + const to = Math.min(text.length, m.index + m[0].length + REJECTION_WINDOW); + if (REJECTION_LANGUAGE.test(text.slice(from, to))) return true; + } + return false; +} + +function diagnosticTextNamesOtherParameter(text: string, authoritativeEffortSubject = false): boolean { if (!text) return false; const s = text.replace(GENERIC_ERROR_TYPE, " "); + if (authoritativeEffortSubject) return diagnosticTextNamesConcreteOtherField(s); const paramPattern = /\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; for (let m = paramPattern.exec(s); m; m = paramPattern.exec(s)) { @@ -834,7 +994,7 @@ function diagnosticTextNamesOtherParameter(text: string): boolean { return false; } -function isConflictingDiagnosticProse(str: string | undefined): boolean { +function isConflictingDiagnosticProse(str: string | undefined, authoritativeEffortSubject = false): boolean { if (!str) return false; const s = str.trim(); if (!s) return false; @@ -846,19 +1006,37 @@ function isConflictingDiagnosticProse(str: string | undefined): boolean { const codeMatch = /\bcode\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); if (codeMatch && codeMatch[1]) { const code = codeMatch[1].trim().toLowerCase().replaceAll("-", "_"); - if (!EFFORT_COMPATIBLE_CODES.has(code)) return true; + if (!EFFORT_COMPATIBLE_CODES.has(code) && !isHttpStatusText(code)) return true; } const typeMatch = /\btype\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); if (typeMatch && typeMatch[1]) { const type = typeMatch[1].trim().toLowerCase().replaceAll("-", "_"); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } - if (diagnosticTextNamesOtherParameter(s)) { + if (diagnosticTextNamesOtherParameter(s, authoritativeEffortSubject)) { return true; } return false; } +/** + * Whether a numeric code is the HTTP status echoed back rather than a vendor error code. + * + * Google answers a bad reasoning effort with `{"code":400,"error":{"message":"…"}}`, where the + * numeric field is the response status. Treating it as an unrecognized semantic code made that + * genuine refusal terminal (#8173). Only 4xx is accepted: a numeric 5xx is a server failure and + * must keep its terminal verdict. + */ +function isHttpStatusText(token: string): boolean { + if (!NUMERIC_VALUE_TOKEN.test(token)) return false; + const value = Number(token); + return Number.isInteger(value) && value >= 400 && value <= 499; +} + +function isHttpStatusNumber(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 499; +} + /** * Rejects error envelopes that carry a conflicting code (e.g. permission_denied, insufficient_quota, * rate_limit_exceeded, model_not_found, malformed non-string code) or competing diagnostic parameters @@ -901,7 +1079,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo (unescaped.includes("[") && unescaped.includes("]") && (unescaped.includes('"') || unescaped.includes("'") || unescaped.includes(","))); - if (hasBraces || hasBrackets) return true; + if (hasBraces || (hasBrackets && !carriesOnlyEffortEnumerations(unescaped))) return true; return isConflictingDiagnosticProse(unescaped); } try { @@ -973,15 +1151,23 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo continue; } const rec = candidate as Record; + // A structured param/field naming effort is authoritative about the subject. Text + // inference may only override it by naming a concrete other request field, never by + // reading a diagnostic adjective or the refused value as a field name (#8173). + const authoritativeEffortSubject = typeof rec.param === "string" && EFFORT_PARAM_NAMES.has(rec.param.trim().toLowerCase()); if (rec.param !== undefined && rec.param !== null) { if (typeof rec.param !== "string") return true; const param = rec.param.trim().toLowerCase(); if (param.length > 0 && !EFFORT_PARAM_NAMES.has(param)) return true; } if (rec.code !== undefined && rec.code !== null) { - if (typeof rec.code !== "string") return true; - const code = rec.code.trim().toLowerCase().replaceAll("-", "_"); - if (!EFFORT_COMPATIBLE_CODES.has(code)) return true; + if (isHttpStatusNumber(rec.code)) { + // An HTTP status echoed into `code` says nothing about which field was refused. + } else if (typeof rec.code !== "string") return true; + else { + const code = rec.code.trim().toLowerCase().replaceAll("-", "_"); + if (!EFFORT_COMPATIBLE_CODES.has(code)) return true; + } } if (rec.type !== undefined && rec.type !== null) { if (typeof rec.type !== "string") return true; @@ -1007,13 +1193,13 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo } } if (inner && typeof inner === "object") { - if (isConflictingDiagnosticProse(innerPrefix) || isConflictingDiagnosticProse(innerSuffix)) { + if (isConflictingDiagnosticProse(innerPrefix, authoritativeEffortSubject) || isConflictingDiagnosticProse(innerSuffix, authoritativeEffortSubject)) { return true; } queue.push(inner); } else if (isIncompleteStructuredWrapper(val)) { return true; - } else if (isConflictingDiagnosticProse(val)) { + } else if (isConflictingDiagnosticProse(val, authoritativeEffortSubject)) { return true; } } @@ -1303,7 +1489,8 @@ export function isReasoningEffortRejection(text: string | undefined, depth = 0): (unescaped.includes("[") && unescaped.includes("]") && (unescaped.includes('"') || unescaped.includes("'") || unescaped.includes(","))); - if (hasBraces || hasBrackets || isIncompleteStructuredWrapper(unescaped)) return false; + if (hasBraces || (hasBrackets && !carriesOnlyEffortEnumerations(unescaped))) return false; + if (isIncompleteStructuredWrapper(unescaped)) return false; return diagnosticTextBlamesReasoningEffort(unescaped); } diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index a8346c32bbe..91c1f28bdf4 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -92,13 +92,19 @@ export async function refusedReasoningEffortDowngrade( /** * The passthrough rebuild serializes `_rawBody`, so a downgraded rung has to be written there as - * well or the replay resends the refused effort. `reasoning_effort` is kept in step only when the - * caller sent it. + * well or the replay resends the refused effort. + * + * Only shapes that already carry the control are rewritten. An absent `reasoning`, or one the + * caller sent as a string or `null`, is left exactly as it was: inventing `{ effort }` here would + * add a reasoning control to a body that never asked for one, and overriding a string would + * replace whatever that wire meant by it. `reasoning_effort` is kept in step only when present. */ export function withRawBodyReasoningEffort(rawBody: unknown, effort: string): unknown { if (!isPlainObject(rawBody)) return rawBody; - const reasoning = isPlainObject(rawBody.reasoning) ? { ...rawBody.reasoning, effort } : { effort }; - return { ...rawBody, reasoning, ...("reasoning_effort" in rawBody ? { reasoning_effort: effort } : {}) }; + const next: Record = { ...rawBody }; + if (isPlainObject(rawBody.reasoning)) next.reasoning = { ...rawBody.reasoning, effort }; + if ("reasoning_effort" in rawBody) next.reasoning_effort = effort; + return next; } diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index e553408c9a9..f99225647ca 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses } from "../../src/server/responses/core"; +import { withRawBodyReasoningEffort } from "../../src/server/responses/core-opaque-recovery"; import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCooldown } from "../../src/combos"; import { hasConflictingReasoningEffortCode, @@ -1154,3 +1155,182 @@ describe("rejected reasoning rungs", () => { }, ); }); + +/** + * Canonical effort refusals from real upstreams (#8173). Each one names reasoning effort through + * its structured param, its prose, or both, and quotes the refused rung. The classifier used to + * read the leading diagnostic adjective ("Unsupported parameter") or the quoted rung + * ("Unsupported value: 'max'") as a competing field, so every one of these was terminal even + * with a structured `param: reasoning_effort`. + */ +const CANONICAL_EFFORT_REFUSALS = [ + [ + "openai unsupported_value for a max rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'max' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_value for an xhigh rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'xhigh' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_value for a low rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'low' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_value for a minimal rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'minimal' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_parameter", + JSON.stringify({ + error: { + message: "Unsupported parameter: 'reasoning_effort' is not supported with this model.", + param: "reasoning_effort", + code: "unsupported_parameter", + }, + }), + ], + [ + "anthropic thinking budget", + JSON.stringify({ error: { message: "thinking budget 32000 is not supported for this model" } }), + ], + [ + "vllm enumeration prose", + "reasoning_effort must be one of ['low','medium','high']", + ], + [ + "google numeric status", + JSON.stringify({ code: 400, error: { message: "reasoning effort max is unsupported" } }), + ], +] as const; + +describe("canonical upstream effort refusals", () => { + test.each(CANONICAL_EFFORT_REFUSALS)("classifies %s as an effort rejection", (_label, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(false); + expect(isReasoningEffortRejection(refusal)).toBe(true); + }); + + test.each(CANONICAL_EFFORT_REFUSALS.flatMap(([label, refusal]) => ([ + ["openai-chat", label, refusal] as const, + ["openai-responses", label, refusal] as const, + ])))( + "repairs once from max to high on a direct request (%s, %s)", + async (adapter, _label, refusal) => { + writeSnapshot(["low", "medium", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + } + return adapter === "openai-chat" + ? chatSuccess("served at high effort", MODEL) + : Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(response.status).toBe(200); + if (adapter === "openai-chat") expect(text).toContain("served at high effort"); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }, + ); + + test.each(CANONICAL_EFFORT_REFUSALS.flatMap(([label, refusal]) => ([ + ["openai-chat", label, refusal] as const, + ["openai-responses", label, refusal] as const, + ])))( + "combo walks max to high on the same target (%s, %s)", + async (adapter, _label, refusal) => { + writeSnapshot(["low", "medium", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("served at high effort", MODEL); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("served at high effort"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(true); + }, + ); +}); + +describe("raw body effort rewrite", () => { + test("writes reasoning.effort only when the incoming body carried an object reasoning", () => { + expect(withRawBodyReasoningEffort({ input: [], reasoning: { summary: "auto" } }, "high")) + .toEqual({ input: [], reasoning: { summary: "auto", effort: "high" } }); + }); + + test("keeps a reasoning_effort field in step when the caller sent one", () => { + expect(withRawBodyReasoningEffort({ input: [], reasoning_effort: "max" }, "high")) + .toEqual({ input: [], reasoning_effort: "high" }); + }); + + test("does not invent a reasoning object when the body carried none", () => { + expect(withRawBodyReasoningEffort({ input: [], model: "m" }, "high")) + .toEqual({ input: [], model: "m" }); + }); + + test("leaves a string or null reasoning untouched", () => { + expect(withRawBodyReasoningEffort({ input: [], reasoning: "max" }, "high")) + .toEqual({ input: [], reasoning: "max" }); + expect(withRawBodyReasoningEffort({ input: [], reasoning: null }, "high")) + .toEqual({ input: [], reasoning: null }); + }); +}); From 0503252b9480b558fda682e8b66ec1ec53455dab Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:51:08 +0900 Subject: [PATCH 39/39] fix(providers): read PascalCase error codes and 'not valid' as effort-compatible Volcengine Ark answers an unsupported effort with {code:'InvalidParameter', message:'The parameter reasoning_effort specified in the request are not valid'}. dev repaired it; this branch read 'invalidparameter' as a code naming some other field and had no 'not valid' rejection wording, so the request stopped. Codes and types are now folded from PascalCase/kebab-case to snake_case before the compatibility lookup, and 'not valid' is rejection language. --- src/providers/reasoning-metadata.ts | 25 +++++++++++++------ ...sponses-reasoning-effort-downgrade.test.ts | 9 +++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index f5368ff7a05..ba4e281b962 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -434,7 +434,7 @@ export async function flushReasoningSupportCache(): Promise { * these beside the effort term is what separates "the gateway rejected reasoning effort" from * "the gateway rejected something else and echoed the request back". */ -const REJECTION_LANGUAGE = /unsupported|not supported|does not support|invalid|unrecognized|unknown|not allowed|not permitted|must be|requires|required|cannot|can't|out of range/i; +const REJECTION_LANGUAGE = /unsupported|not supported|does not support|invalid|not valid|unrecognized|unknown|not allowed|not permitted|must be|requires|required|cannot|can't|out of range/i; /** * How far from the effort term the rejection language may sit and still be about it. Kept @@ -455,6 +455,7 @@ const EFFORT_COMPATIBLE_CODES = new Set([ "", "invalid_request_error", "invalid_argument", + "invalid_parameter", "unsupported_parameter", "unsupported_value", "invalid_parameter_error", @@ -463,8 +464,18 @@ const EFFORT_COMPATIBLE_TYPES = new Set([ "", "invalid_request_error", "upstream_error", + "bad_request", ]); +/** + * Error codes and types arrive as snake_case, kebab-case or PascalCase depending on the gateway + * (Volcengine Ark sends `InvalidParameter` / `BadRequest`). Fold all three to snake_case before the + * compatibility lookup so a generic vendor code is not read as naming some other field. + */ +function normalizeErrorTag(value: string): string { + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replaceAll("-", "_"); +} + function isIncompleteStructuredWrapper(text: string): boolean { let s = text.trim(); for (let d = 0; d < 3; d += 1) { @@ -959,7 +970,7 @@ function diagnosticTextNamesOtherParameter(text: string, authoritativeEffortSubj } const subjectIsRejection = - /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:is|are)\s+(?:invalid|unsupported|unrecognized|unknown|not\s+allowed|not\s+permitted|not\s+supported|required|missing)\b/gi; + /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:is|are)\s+(?:invalid|not\s+valid|unsupported|unrecognized|unknown|not\s+allowed|not\s+permitted|not\s+supported|required|missing)\b/gi; for (let m = subjectIsRejection.exec(s); m; m = subjectIsRejection.exec(s)) { const tokenIndex = tokenStartIndex(m, m[1], true); if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; @@ -1005,12 +1016,12 @@ function isConflictingDiagnosticProse(str: string | undefined, authoritativeEffo } const codeMatch = /\bcode\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); if (codeMatch && codeMatch[1]) { - const code = codeMatch[1].trim().toLowerCase().replaceAll("-", "_"); + const code = normalizeErrorTag(codeMatch[1]); if (!EFFORT_COMPATIBLE_CODES.has(code) && !isHttpStatusText(code)) return true; } const typeMatch = /\btype\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); if (typeMatch && typeMatch[1]) { - const type = typeMatch[1].trim().toLowerCase().replaceAll("-", "_"); + const type = normalizeErrorTag(typeMatch[1]); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } if (diagnosticTextNamesOtherParameter(s, authoritativeEffortSubject)) { @@ -1130,7 +1141,7 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo return true; } } else { - const codeLike = trimmed.toLowerCase().replaceAll("-", "_"); + const codeLike = normalizeErrorTag(trimmed); if ( !codeLike.includes(" ") && (codeLike.includes("_") || codeLike.length < 32) && @@ -1165,13 +1176,13 @@ export function hasConflictingReasoningEffortCode(text: string | undefined): boo // An HTTP status echoed into `code` says nothing about which field was refused. } else if (typeof rec.code !== "string") return true; else { - const code = rec.code.trim().toLowerCase().replaceAll("-", "_"); + const code = normalizeErrorTag(rec.code); if (!EFFORT_COMPATIBLE_CODES.has(code)) return true; } } if (rec.type !== undefined && rec.type !== null) { if (typeof rec.type !== "string") return true; - const type = rec.type.trim().toLowerCase().replaceAll("-", "_"); + const type = normalizeErrorTag(rec.type); if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; } for (const field of ["message", "error", "details", "detail", "response", "last_error", "errors"] as const) { diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index f99225647ca..04fe9a87f6d 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -1226,6 +1226,15 @@ const CANONICAL_EFFORT_REFUSALS = [ "google numeric status", JSON.stringify({ code: 400, error: { message: "reasoning effort max is unsupported" } }), ], + [ + "volcengine ark PascalCase code", + JSON.stringify({ + error: { + code: "InvalidParameter", + message: "The parameter reasoning_effort specified in the request are not valid: max", + }, + }), + ], ] as const; describe("canonical upstream effort refusals", () => {