From 58f9fd8c501ee450d605a81d4fe1e5e341e0e59f Mon Sep 17 00:00:00 2001 From: bylinelog-bot Date: Mon, 19 Oct 2026 13:11:13 +0800 Subject: [PATCH 1/2] fix(kiro): rotate on a 400 its validator calls improperly formed Kiro answers `400 ValidationException: Improperly formed request` when the identity a request was routed under cannot serve it. classifyKiroRefusal returned `other`, so the rotation loop stopped: the account stayed selected, no other login was tried, and inside a combo the 400 ended the chain even though a later target could serve the turn. Classify it as the account-scoped kind `profile` (400 only, and both the vendor exception name and the "improperly formed request" wording are required, so a genuinely bad payload or a caller echoing one token stays terminal). Rotation and the existing Kiro cooldown apply unchanged. comboFailureDecision treats the same shape, also inside a transport-wrapped frame, as a hop, before the generic invalid_request_error stop. --- src/adapters/kiro-refusal.ts | 24 ++++++++++++++++++++++- src/combos/failover.ts | 5 +++++ tests/codex-integration/combos.test.ts | 7 +++++++ tests/providers/kiro/kiro-refusal.test.ts | 24 ++++++++++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/adapters/kiro-refusal.ts b/src/adapters/kiro-refusal.ts index 21fdb55317b..42bccb33cce 100644 --- a/src/adapters/kiro-refusal.ts +++ b/src/adapters/kiro-refusal.ts @@ -1,8 +1,13 @@ /** Account-scoped Kiro refusals. Unknown data never convicts an account. */ import { BOUNDED_BODY_MAX_BYTES } from "../lib/bounded-body"; -export type KiroRefusalKind = "rate" | "monthly_quota" | "suspended" | "other"; +export type KiroRefusalKind = "rate" | "monthly_quota" | "suspended" | "profile" | "other"; export interface KiroRefusal { kind: KiroRefusalKind; resetAt?: number } +/** The vendor's own name for the body validator that rejects a request for this identity. */ +const VALIDATION_EXCEPTION = "ValidationException"; +/** Kiro's wording for that rejection; the code alone is also what a genuinely bad payload gets. */ +const IMPROPER_REQUEST_WORD = /improperly formed request/i; + const SUSPENSION_WORDS = [ "temporarily suspended", "temporarily is suspended", "locked your account", "locked it as a", @@ -22,9 +27,26 @@ export function classifyKiroRefusal(status: number, bodyText: string): KiroRefus } catch { /* Unrecognised bodies cannot establish an account verdict. */ } if ((status === 400 || status === 429) && reason === "MONTHLY_REQUEST_COUNT") return { kind: "monthly_quota" }; + // A 400 whose own validator calls the body improperly formed is not proof that the request is + // impossible: Kiro answers this shape when the identity a request was routed under cannot serve + // it. Treating it as `other` leaves the account selected and the same 400 reaches the client. + // Both vendor tokens are required, so a bad payload or a caller echoing one token stays terminal. + if (status === 400 && typeof message === "string" && IMPROPER_REQUEST_WORD.test(message) + && (reason === VALIDATION_EXCEPTION || message.includes(VALIDATION_EXCEPTION))) + return { kind: "profile" }; if (status === 403 && (reason === "TEMPORARILY_SUSPENDED" || (typeof message === "string" && SUSPENSION_WORDS.some(word => message.toLowerCase().includes(word))))) return { kind: "suspended" }; if (status === 429) return { kind: "rate" }; return { kind: "other" }; } + +/** + * The same verdict at the combo layer, where the body arrives as wrapped text rather than as a + * live response ("Provider error 400: {...}"), which JSON cannot parse. Both vendor tokens must + * still appear for the shape to count. + */ +export function isKiroAccountProfileRefusal(status: number, bodyText: string): boolean { + if (status !== 400 || Buffer.byteLength(bodyText, "utf8") > BOUNDED_BODY_MAX_BYTES) return false; + return bodyText.includes(VALIDATION_EXCEPTION) && IMPROPER_REQUEST_WORD.test(bodyText); +} diff --git a/src/combos/failover.ts b/src/combos/failover.ts index a586ccb1c58..9b0db65eaa9 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -2,6 +2,7 @@ import { parseResetCooldownMs } from "../codex/routing"; import { classifyError, isCyberPolicyCode } from "../lib/errors"; import { isNonReplayableUpstreamCode } from "../lib/upstream-retry"; import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal"; +import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal"; import type { OcxComboTarget } from "../types"; import { targetKey } from "./types"; import { @@ -856,6 +857,10 @@ export function comboFailureDecision( // could have served the request (#4903). if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop"; if (isAnthropicBillingFailure(status, message, options?.code)) return "hop"; + // Kiro's body validator rejects the request for the identity it was routed under. The account + // layer rotates logins first; this is the no-alternate case, so the next combo target is the + // only remaining chance to serve the turn. Must precede the generic invalid_request_error stop. + if (isKiroAccountProfileRefusal(status, message)) return "hop"; if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index d6c370bf141..800094b3aa0 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -951,6 +951,13 @@ describe("combo failure policy and advancement", () => { expect(comboFailureDecision(410, "resource is gone")).toBe("stop"); expect(comboFailureDecision(410, "The model has reached its end of life and is no longer available.")).toBe("hop"); expect(comboFailureDecision(410, "The model is scheduled for retirement.")).toBe("hop"); + // A Kiro 400 that names its own validator rejection is account-scoped: after the account layer + // has no login left to rotate to, the next combo target is the only chance to serve the turn. + const improper = JSON.stringify({ message: "ValidationException: Improperly formed request" }); + expect(comboFailureDecision(400, improper)).toBe("hop"); + expect(comboFailureDecision(400, `Provider error 400: ${improper}`)).toBe("hop"); + expect(comboFailureDecision(400, JSON.stringify({ message: "Improperly formed request" }))).toBe("stop"); + expect(comboFailureDecision(400, "ValidationException: model not found")).toBe("stop"); expect(comboFailureDecision(410, "gone", { code: "model_retired" })).toBe("hop"); expect(comboFailureDecision(499, "client cancelled")).toBe("stop"); expect(comboFailureDecision(422, "invalid_api_key")).toBe("hop"); diff --git a/tests/providers/kiro/kiro-refusal.test.ts b/tests/providers/kiro/kiro-refusal.test.ts index 82739301b33..6e0188517c2 100644 --- a/tests/providers/kiro/kiro-refusal.test.ts +++ b/tests/providers/kiro/kiro-refusal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { classifyKiroRefusal } from "../../../src/adapters/kiro-refusal"; +import { classifyKiroRefusal, isKiroAccountProfileRefusal } from "../../../src/adapters/kiro-refusal"; import { kiroEvidenceIdentity } from "../../../src/providers/kiro-account-state-disk"; import type { ProviderAccount } from "../../../src/oauth/types"; @@ -23,6 +23,28 @@ describe("Kiro refusal classification", () => { } }); + test("kiro refusal: a 400 the vendor's validator calls improperly formed convicts the account", () => { + const improper = "ValidationException: Improperly formed request"; + for (const body of [ + { message: improper }, + { reason: "ValidationException", message: "Improperly formed request." }, + ]) expect(classified(400, body).kind).toBe("profile"); + expect(classified(429, { message: improper }).kind).toBe("rate"); + expect(classified(403, { message: improper }).kind).toBe("other"); + // Both tokens are required, so a bad payload and a caller echoing one token stay terminal. + expect(classified(400, { message: "Improperly formed request" }).kind).toBe("other"); + expect(classified(400, { message: "ValidationException: model not found" }).kind).toBe("other"); + expect(classified(400, { message: improper, padding: "x".repeat(70_000) }).kind).toBe("other"); + }); + + test("kiro refusal: the combo layer recognises the same shape through a wrapped frame", () => { + const improper = JSON.stringify({ message: "ValidationException: Improperly formed request" }); + expect(isKiroAccountProfileRefusal(400, improper)).toBe(true); + expect(isKiroAccountProfileRefusal(400, `Provider error 400: ${improper}`)).toBe(true); + expect(isKiroAccountProfileRefusal(429, improper)).toBe(false); + expect(isKiroAccountProfileRefusal(400, "Improperly formed request")).toBe(false); + }); + test("kiro refusal: unknown, nested, malformed and oversized shapes never quarantine", () => { for (const body of ["not-json", "{", JSON.stringify({ error: { reason: "TEMPORARILY_SUSPENDED" } }), JSON.stringify({ reason: "INVALID_MODEL_ID" }), From 112b72130590580d8d99e7d3cff2580f93d0f2cd Mon Sep 17 00:00:00 2001 From: Keel Date: Tue, 20 Oct 2026 00:13:31 +0800 Subject: [PATCH 2/2] fix(kiro): fence the improperly-formed hop to Kiro targets; parse refusal fields --- src/adapters/kiro-refusal.ts | 26 ++++- src/combos/failover.ts | 8 +- src/server/responses/core-combo.ts | 1 + structure/providers/kiro.md | 9 +- structure/transports/responses-failover.md | 12 +++ tests/codex-integration/combos.test.ts | 17 ++- tests/providers/kiro/kiro-refusal.test.ts | 18 ++++ tests/server/server-kiro-refusal-e2e.test.ts | 108 +++++++++++++++++++ 8 files changed, 187 insertions(+), 12 deletions(-) diff --git a/src/adapters/kiro-refusal.ts b/src/adapters/kiro-refusal.ts index 42bccb33cce..fbfc0ee784c 100644 --- a/src/adapters/kiro-refusal.ts +++ b/src/adapters/kiro-refusal.ts @@ -41,12 +41,30 @@ export function classifyKiroRefusal(status: number, bodyText: string): KiroRefus return { kind: "other" }; } +/** The adapter's own prefix for a normalized 400 (`normalizedKiroErrorMessage` in kiro-errors.ts). */ +const KIRO_INVALID_REQUEST_PREFIX = "Kiro invalid request: "; + /** - * The same verdict at the combo layer, where the body arrives as wrapped text rather than as a - * live response ("Provider error 400: {...}"), which JSON cannot parse. Both vendor tokens must - * still appear for the shape to count. + * The same verdict at the combo layer. There the text is either the adapter's normalized message + * ("Kiro invalid request: ValidationException: Improperly formed request", what a combo child's + * failure carries) or a raw JSON body, each possibly behind the "Provider error 400: " frame. + * JSON goes through the parsed `reason`/`message` rules of `classifyKiroRefusal` (tokens in + * unrelated fields never count); prose counts only behind the adapter's own prefix. Callers must + * also know the failed target is a Kiro adapter. */ export function isKiroAccountProfileRefusal(status: number, bodyText: string): boolean { if (status !== 400 || Buffer.byteLength(bodyText, "utf8") > BOUNDED_BODY_MAX_BYTES) return false; - return bodyText.includes(VALIDATION_EXCEPTION) && IMPROPER_REQUEST_WORD.test(bodyText); + const text = bodyText.replace(/^Provider error \d{3}: /, ""); + if (text.startsWith(KIRO_INVALID_REQUEST_PREFIX)) { + const detail = text.slice(KIRO_INVALID_REQUEST_PREFIX.length); + return detail.includes(VALIDATION_EXCEPTION) && IMPROPER_REQUEST_WORD.test(detail); + } + try { + const row: unknown = JSON.parse(text); + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + const envelope = (row as Record).error; + const body = envelope && typeof envelope === "object" && !Array.isArray(envelope) ? envelope : row; + const { reason, message } = body as Record; + return classifyKiroRefusal(400, JSON.stringify({ reason, message })).kind === "profile"; + } catch { return false; } } diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 9b0db65eaa9..0bdcc0ece2b 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -773,7 +773,7 @@ export function codexAccountModelRefusal( export function comboFailureDecision( status: number, message: string, - options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal }, + options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal; failedAdapter?: string }, ): ComboFailureDecision { if (status === 499) return "stop"; if (message.toLowerCase().includes("origin_rejected")) return "stop"; @@ -859,8 +859,10 @@ export function comboFailureDecision( if (isAnthropicBillingFailure(status, message, options?.code)) return "hop"; // Kiro's body validator rejects the request for the identity it was routed under. The account // layer rotates logins first; this is the no-alternate case, so the next combo target is the - // only remaining chance to serve the turn. Must precede the generic invalid_request_error stop. - if (isKiroAccountProfileRefusal(status, message)) return "hop"; + // only remaining chance to serve the turn. Only a caller that knows the failed target is a Kiro + // adapter grants it (the words alone prove nothing for another provider); it must precede the + // generic invalid_request_error stop. + if (options?.failedAdapter === "kiro" && isKiroAccountProfileRefusal(status, message)) return "hop"; if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index f390955f010..da8f145a41c 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1165,6 +1165,7 @@ export async function executeComboResponses( : comboFailureDecision(failure.response.status, failure.classificationText, { code: failure.upstreamCode, codexModelRefusal: failure.codexModelRefusal, + failedAdapter: config.providers[pick.target.provider]?.adapter, }); const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true; // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal. diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 285c9aa94dc..a7bd9f1b846 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -76,7 +76,14 @@ observed ID still informs routing preference. `src/adapters/kiro-refusal.ts` recognizes an exact monthly reason on HTTP 400/429 and a confirmed suspension on HTTP 403; ordinary 400/403 remains an error without an account -verdict. `src/providers/kiro-usage.ts` records monthly exhaustion only for the sent +verdict. A 400 whose parsed body carries `ValidationException` (as `reason`, or inside `message`) +and the message "Improperly formed request" is the `profile` kind: the account layer rotates to a +sibling login like any other account-scoped refusal, and both tokens are required in the parsed +fields. `isKiroAccountProfileRefusal` applies the same rule to combo failure text (adapter-normalized +`Kiro invalid request: ...` prose or a JSON body, with or without the `Provider error 400: ` frame). +It is only consulted when the failed target's adapter is `kiro` (see +[combo failover](../transports/responses-failover.md)); coverage is +`tests/providers/kiro/kiro-refusal.test.ts` and `tests/server/server-kiro-refusal-e2e.test.ts`. `src/providers/kiro-usage.ts` records monthly exhaustion only for the sent credential generation and login identity, independently of quota observation time. A completed response from that same live credential clears an older verdict after disk hydration. Suspension is a process-local quarantine; rate refusals use a short cooldown. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 49fd6df56b7..2ef400a2a9a 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -566,3 +566,15 @@ The resend charges the root workflow send counter once without a second request- - **Attempt ownership:** Each attempt captures its adapter binding at the physical-send observation. Recovery passes that binding's key and `_apiKeyAttempt` to the existing API-key 401/429 helpers, so a late failure cannot cool or overwrite a newer manual selection. A replacement rebuilds the adapter and replay scope before dispatch. Failed credentials are not resent in the request; the initial pool size and shared budget bound attempts. - **Cooldown:** Only an eligible 429 API-key recovery enters this cooldown path. When no reset time is known, the failed key receives the existing default cooldown. Nonrecoverable quota classifications do not create a key cooldown through this path; no daily reset is inferred. - **Regression coverage:** `tests/providers/qoder-adapter.test.ts` covers classification; `tests/server/server-key-failover-e2e.test.ts` covers mocked/local key recovery and replay boundaries; `tests/oauth/generic-oauth-failover.test.ts` guards recovery-site accounting. + +## Kiro account-profile hop + +`comboFailureDecision` takes an optional `failedAdapter` (the adapter of the target that failed; +`executeComboResponses` passes `config.providers[target.provider].adapter`). Only `failedAdapter === "kiro"` +may hop on an HTTP 400 that `isKiroAccountProfileRefusal` recognises (`ValidationException` plus +"Improperly formed request" in the parsed Kiro body, reached after the account layer has no sibling login +left to rotate to). Any other provider, or a caller that does not pass the adapter, keeps the generic +terminal `invalid_request_error` stop, so an invalid payload is never resent to another declared target. +Coverage: `tests/codex-integration/combos.test.ts` (unit, with non-Kiro and unrelated-field negatives) and +`tests/server/server-kiro-refusal-e2e.test.ts` (account rotation, combo exhaustion hop, non-Kiro 400 stop, +malformed-payload stop). diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 800094b3aa0..031afa32c4c 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -954,10 +954,19 @@ describe("combo failure policy and advancement", () => { // A Kiro 400 that names its own validator rejection is account-scoped: after the account layer // has no login left to rotate to, the next combo target is the only chance to serve the turn. const improper = JSON.stringify({ message: "ValidationException: Improperly formed request" }); - expect(comboFailureDecision(400, improper)).toBe("hop"); - expect(comboFailureDecision(400, `Provider error 400: ${improper}`)).toBe("hop"); - expect(comboFailureDecision(400, JSON.stringify({ message: "Improperly formed request" }))).toBe("stop"); - expect(comboFailureDecision(400, "ValidationException: model not found")).toBe("stop"); + const kiro = { failedAdapter: "kiro" }; + expect(comboFailureDecision(400, improper, kiro)).toBe("hop"); + expect(comboFailureDecision(400, `Provider error 400: ${improper}`, kiro)).toBe("hop"); + expect(comboFailureDecision(400, JSON.stringify({ message: "Improperly formed request" }), kiro)).toBe("stop"); + expect(comboFailureDecision(400, "ValidationException: model not found", kiro)).toBe("stop"); + // Tokens in unrelated fields, or a failed target that is not Kiro (or unknown), never hop. + expect(comboFailureDecision(400, JSON.stringify({ metadata: "ValidationException", message: "Improperly formed request" }), kiro)).toBe("stop"); + expect(comboFailureDecision(400, JSON.stringify({ error: { message: "Kiro invalid request: ValidationException: Improperly formed request" } }), kiro)).toBe("hop"); + expect(comboFailureDecision(400, "Kiro invalid request: ValidationException: Improperly formed request", kiro)).toBe("hop"); + expect(comboFailureDecision(400, "Kiro invalid request: ValidationException: Improperly formed request", { failedAdapter: "openai-chat" })).toBe("stop"); + expect(comboFailureDecision(400, improper)).toBe("stop"); + expect(comboFailureDecision(400, improper, { failedAdapter: "openai-chat" })).toBe("stop"); + expect(comboFailureDecision(400, improper, { failedAdapter: "anthropic" })).toBe("stop"); expect(comboFailureDecision(410, "gone", { code: "model_retired" })).toBe("hop"); expect(comboFailureDecision(499, "client cancelled")).toBe("stop"); expect(comboFailureDecision(422, "invalid_api_key")).toBe("hop"); diff --git a/tests/providers/kiro/kiro-refusal.test.ts b/tests/providers/kiro/kiro-refusal.test.ts index 6e0188517c2..6b0e6f2353b 100644 --- a/tests/providers/kiro/kiro-refusal.test.ts +++ b/tests/providers/kiro/kiro-refusal.test.ts @@ -43,6 +43,24 @@ describe("Kiro refusal classification", () => { expect(isKiroAccountProfileRefusal(400, `Provider error 400: ${improper}`)).toBe(true); expect(isKiroAccountProfileRefusal(429, improper)).toBe(false); expect(isKiroAccountProfileRefusal(400, "Improperly formed request")).toBe(false); + // The adapter's normalized envelope is what the combo layer actually receives. + const envelope = JSON.stringify({ error: { message: "Kiro invalid request: ValidationException: Improperly formed request", + type: "invalid_request_error", code: "invalid_request_error" } }); + expect(isKiroAccountProfileRefusal(400, envelope)).toBe(true); + expect(isKiroAccountProfileRefusal(400, JSON.stringify({ error: { message: "Kiro invalid request: model not found" } }))).toBe(false); + expect(isKiroAccountProfileRefusal(400, JSON.stringify({ error: { metadata: "ValidationException", message: "Improperly formed request" } }))).toBe(false); + // The adapter's normalized prose, bare or framed; the same words without its prefix do not count. + const prose = "Kiro invalid request: ValidationException: Improperly formed request"; + expect(isKiroAccountProfileRefusal(400, prose)).toBe(true); + expect(isKiroAccountProfileRefusal(400, `Provider error 400: ${prose}`)).toBe(true); + expect(isKiroAccountProfileRefusal(400, "Kiro invalid request: model not found")).toBe(false); + // Split-field case: both tokens present, but not in the fields the account layer reads. + const split = JSON.stringify({ metadata: "ValidationException", message: "Improperly formed request" }); + expect(classified(400, JSON.parse(split)).kind).toBe("other"); + expect(isKiroAccountProfileRefusal(400, split)).toBe(false); + expect(isKiroAccountProfileRefusal(400, `Provider error 400: ${split}`)).toBe(false); + // Unparseable prose that merely contains both tokens is not the vendor's body. + expect(isKiroAccountProfileRefusal(400, "upstream said ValidationException: Improperly formed request")).toBe(false); }); test("kiro refusal: unknown, nested, malformed and oversized shapes never quarantine", () => { diff --git a/tests/server/server-kiro-refusal-e2e.test.ts b/tests/server/server-kiro-refusal-e2e.test.ts index eb0b42b5450..ec2f7fc8c40 100644 --- a/tests/server/server-kiro-refusal-e2e.test.ts +++ b/tests/server/server-kiro-refusal-e2e.test.ts @@ -10,6 +10,7 @@ import { createRequestExecutionBudget } from "../../src/lib/request-execution-bu import { ADAPTER_REGISTRY } from "../../src/adapters/registry"; import { encodeMessage } from "../../src/lib/eventstream-decoder"; import { KIRO_COMPLETION_TOOL_NAME } from "../../src/adapters/kiro-constants"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; import { clearAccountQuotaCache } from "../../src/providers/quota"; import { kiroAccountEvidence, noteKiroMonthlyRefusal } from "../../src/providers/kiro-usage"; @@ -32,11 +33,15 @@ beforeEach(() => { process.env.KIRO_REGION = "us-east-1"; clearAccountQuotaCache(); clearGenericFailoverHealth(); + clearComboSelectionState(); + clearComboTargetCooldowns(); }); afterEach(() => { globalThis.fetch = realFetch; clearAccountQuotaCache(); clearGenericFailoverHealth(); + clearComboSelectionState(); + clearComboTargetCooldowns(); if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; if (oldRegion === undefined) delete process.env.KIRO_REGION; @@ -219,6 +224,109 @@ describe("Kiro refusal recovery through Responses", () => { } + // A 400 the vendor's validator calls improperly formed: account B can serve what A cannot. + const IMPROPER = JSON.stringify({ message: "ValidationException: Improperly formed request" }); + const KIRO_URL = (url: string) => url.startsWith("https://runtime.") && url.endsWith(".kiro.dev/"); + + test("Kiro improperly-formed 400 rotates to the sibling account and is served", async () => { + await seed(); saveConfig(config()); + const auths: Array = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (!KIRO_URL(url)) return realFetch(input, init); + auths.push(new Headers(init?.headers).get("authorization")); + return auths.length === 1 ? new Response(IMPROPER, { status: 400 }) : answer("served by b"); + }) as typeof fetch; + const server = startServer(0); + try { + const response = await post(server, true); + expect(response.status).toBe(200); + expect(await response.text()).toContain("served by b"); + expect(auths).toEqual(["Bearer access-a", "Bearer access-b"]); + } finally { await server.stop(true); } + }); + + const comboConfig = (primary: "kiro" | "other"): OcxConfig => ({ + ...config(), + providers: { ...config().providers, + other: { adapter: "openai-chat", baseUrl: "https://other.example/v1", authMode: "key", + apiKey: "sk-other", models: ["model-other"] }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", + apiKey: "sk-fallback", models: ["model-fallback"] } }, + combos: { fan: { strategy: "failover", targets: [ + primary === "kiro" ? { provider: "kiro", model: "claude-sonnet-4.5" } : { provider: "other", model: "model-other" }, + { provider: "fallback", model: "model-fallback" }, + ] } }, + }) as unknown as OcxConfig; + const comboPost = (server: ReturnType) => realFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/fan", input: "hello", stream: false }) }); + const fallbackOk = () => Response.json({ id: "chatcmpl-1", object: "chat.completion", model: "model-fallback", + choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content: "served by fallback" } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + + test("combo hops to the next target once every Kiro account has refused as improperly formed", async () => { + await seed(); saveConfig(comboConfig("kiro")); + let kiroSends = 0; let fallbackSends = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (KIRO_URL(url)) { kiroSends++; return new Response(IMPROPER, { status: 400 }); } + if (url.startsWith("https://fallback.example/")) { fallbackSends++; return fallbackOk(); } + return realFetch(input, init); + }) as typeof fetch; + const server = startServer(0); + try { + const response = await comboPost(server); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain("served by fallback"); + expect(kiroSends).toBe(2); + expect(fallbackSends).toBe(1); + } finally { await server.stop(true); } + }); + + test("combo never forwards a non-Kiro target's identical 400 to the next target", async () => { + await seed(); saveConfig(comboConfig("other")); + let otherSends = 0; let fallbackSends = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://other.example/")) { + otherSends++; + return Response.json({ error: { message: "ValidationException: Improperly formed request", + type: "invalid_request_error" } }, { status: 400 }); + } + if (url.startsWith("https://fallback.example/")) { fallbackSends++; return fallbackOk(); } + return realFetch(input, init); + }) as typeof fetch; + const server = startServer(0); + try { + const response = await comboPost(server); + await response.text(); + expect(response.status).toBe(400); + expect(otherSends).toBe(1); + expect(fallbackSends).toBe(0); + } finally { await server.stop(true); } + }); + + test("combo stops on a Kiro 400 that is a malformed payload, not an account refusal", async () => { + await seed(); saveConfig(comboConfig("kiro")); + let fallbackSends = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (KIRO_URL(url)) return new Response(JSON.stringify({ message: "Improperly formed request" }), { status: 400 }); + if (url.startsWith("https://fallback.example/")) { fallbackSends++; return fallbackOk(); } + return realFetch(input, init); + }) as typeof fetch; + const server = startServer(0); + try { + const response = await comboPost(server); + await response.text(); + expect(response.status).toBe(400); + expect(fallbackSends).toBe(0); + } finally { await server.stop(true); } + }); + + test("Kiro web-search sidecar completion clears B's older verdict after rotated refusal", async () => { const [a, b] = await seed(); saveConfig({ ...config(), webSearchSidecar: { backend: "exa", exaApiKey: "synthetic" } } as OcxConfig);