Files
OmniRoute/tests/unit/combo-claude-per-model-scope.test.ts
Kizuno18 6d0dc5a50c fix(combo): scope Claude model failures to the model, not the account (#12340)
* fix(combo): scope Claude model failures to the model, not the account

A priority combo whose steps are five models on one Claude OAuth connection
stops at step 1. hasPerModelQuota() returns false for the claude provider, so
markAccountUnavailable() records a model-specific 404 or 5xx against the
connection row, and getPersistedConnectionCooldownSkipReason() then skips every
sibling step before dispatch. Verified with a combo whose first step names a
model that cannot exist: the 404 lands and step 2 is never tried, while the
same combo works as soon as step 2 points at a different account.

A Claude OAuth connection multiplexes Fable 5, Opus 5/4.8/4.7/4.6, Sonnet and
Haiku behind one credential, which is the multiplexing hasPerModelQuota already
describes. Its quota is a separate question: a 429 on a Max subscription is
account-wide, and shouldMarkAccountExhaustedFrom429 pins that. So this adds
hasPerModelFailureScope() for the non-quota statuses and leaves 429 alone.

Combo exhaustion gets the same treatment for 404, which names one model the
account cannot serve rather than a bad connection. The empty-content 502 was
already exempt through isEmptyContentFailure.

Closes #12334

* fix(combo): extract Claude per-model failure scope to keep file-size caps

Move hasPerModelFailureScope into a leaf so frozen accountFallback.ts and
auth.ts stay at their file-size caps after #12334. Register
combo-claude-per-model-scope.test.ts in Stryker tap.testFiles so the
mutation-test-coverage gate sees the covering unit test.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:28:20 -03:00

60 lines
2.7 KiB
TypeScript

// tests/unit/combo-claude-per-model-scope.test.ts
// Regression test for #12334: a priority combo whose steps are different models on one
// Claude OAuth connection stopped at step 1. The model failure was recorded against the
// connection, so the pre-dispatch cooldown gate skipped every sibling step.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
hasPerModelQuota,
hasPerModelFailureScope,
shouldMarkAccountExhaustedFrom429,
} from "../../open-sse/services/accountFallback.ts";
import { getPersistedConnectionCooldownSkipReason } from "../../open-sse/services/combo/comboPredicates.ts";
test("claude scopes non-quota failures to the model", () => {
assert.equal(hasPerModelFailureScope("claude", "claude-fable-5-max"), true);
assert.equal(hasPerModelFailureScope("claude", "claude-opus-5-max"), true);
});
test("claude quota stays account-wide", () => {
assert.equal(hasPerModelQuota("claude", "claude-opus-5-max"), false);
assert.equal(shouldMarkAccountExhaustedFrom429("claude", "claude-sonnet-4-6"), true);
assert.equal(hasPerModelFailureScope("claude", "claude-opus-5-max", undefined, 429), false);
assert.equal(hasPerModelFailureScope("gemini", "gemma-4-31b-it", undefined, 429), true);
assert.equal(hasPerModelFailureScope("claude", "claude-opus-5-max", undefined, 404), true);
assert.equal(hasPerModelFailureScope("claude", "claude-opus-5-max", undefined, 502), true);
});
test("providers that already scope per model keep both scopes", () => {
assert.equal(hasPerModelFailureScope("gemini", "gemma-4-31b-it"), true);
assert.equal(hasPerModelFailureScope("github", "some-model"), true);
});
test("providers with neither scope are unchanged", () => {
assert.equal(hasPerModelFailureScope("openai", "gpt-4"), false);
});
test("a connection-level override still wins over the provider default", () => {
assert.equal(hasPerModelFailureScope("claude", "claude-opus-5-max", false), false);
});
test("a sibling step is skipped while its connection carries a real cooldown", () => {
const skip = getPersistedConnectionCooldownSkipReason(
{ modelStr: "claude/claude-opus-5-max", connectionId: "conn-1" },
{ rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), testStatus: "unavailable" }
);
assert.ok(skip, "the gate must still protect siblings when the cooldown is connection-wide");
});
test("a sibling step dispatches once no cooldown is recorded", () => {
const skip = getPersistedConnectionCooldownSkipReason(
{ modelStr: "claude/claude-opus-5-max", connectionId: "conn-1" },
{ rateLimitedUntil: null, testStatus: "active" }
);
assert.equal(
skip,
null,
"step 2 of a drop chain must dispatch after step 1 failed with a model-scoped error"
);
});