fix(combo): keep passthrough quota fallback scoped (#4194)

Integrated into release/v3.8.29 — reconciled onto post-split combo.ts (#4186); RED->GREEN validated. Thanks @Svetznaniy33.
This commit is contained in:
Svetznaniy33
2026-06-18 22:54:09 +03:00
committed by GitHub
parent 487621046f
commit 02146ab4e9
2 changed files with 47 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import {
recordModelLockoutFailure,
recordProviderFailure,
isProviderExhaustedReason,
hasPerModelQuota,
type ProviderProfile,
} from "./accountFallback.ts";
import { FETCH_TIMEOUT_MS, RateLimitReason } from "../config/constants.ts";
@@ -2521,8 +2522,12 @@ export async function handleComboChat({
// same-provider targets are skipped immediately. API-key 429s still use
// the short resilience cooldown, but explicit quota text should stop the
// combo from trying another target for the same provider in this request.
// Passthrough/per-model-quota providers multiplex independent upstream
// models behind one provider connection; a quota 429 for one model must
// not skip fallback targets for another model on the same provider.
const providerExhausted =
Boolean(provider && provider !== "unknown") &&
!hasPerModelQuota(provider, rawModel) &&
(isProviderExhaustedReason(fallbackResult) ||
classifyErrorText(errorText) === RateLimitReason.QUOTA_EXHAUSTED);
if (providerExhausted) {
@@ -3269,8 +3274,12 @@ async function handleRoundRobinCombo({
// same-provider targets are skipped immediately. API-key 429s still use
// the short resilience cooldown, but explicit quota text should stop the
// combo from trying another target for the same provider in this request.
// Passthrough/per-model-quota providers multiplex independent upstream
// models behind one provider connection; a quota 429 for one model must
// not skip fallback targets for another model on the same provider.
const providerExhausted =
Boolean(provider && provider !== "unknown") &&
!hasPerModelQuota(provider, parseModel(modelStr).model || modelStr) &&
(isProviderExhaustedReason(fallbackResult) ||
classifyErrorText(errorText) === RateLimitReason.QUOTA_EXHAUSTED ||
isAllAccountsRateLimited);

View File

@@ -203,3 +203,41 @@ test("pre-screen: backward compatible with all targets available", async () => {
assert.equal(calls.length, 1);
assert.equal(calls[0], "p1/m1");
});
test("priority combo: quota 429 on passthrough provider does not skip another model on same provider", async () => {
const calls: string[] = [];
const combo = await combosDb.createCombo({
name: "passthrough-quota-scope",
strategy: "priority",
models: ["antigravity/claude-opus-4-6-thinking", "antigravity/gemini-3-flash-agent"],
});
const response = await handleComboChat({
body: { ...reqBody, model: combo.name },
combo,
allCombos: [combo],
isModelAvailable: async () => true,
relayOptions: undefined,
signal: undefined,
settings: {},
log: makeLog(),
handleSingleModel: async (_body: unknown, modelStr: string) => {
calls.push(modelStr);
if (modelStr.includes("claude-opus")) {
return Response.json(
{ error: { message: "quota exhausted for claude-opus-4-6-thinking" } },
{ status: 429 }
);
}
return okResponse(modelStr);
},
});
assert.equal(response.status, 200);
assert.equal(calls.at(-1), "antigravity/gemini-3-flash-agent");
assert.ok(
calls.includes("antigravity/claude-opus-4-6-thinking"),
"first passthrough model should be attempted before fallback"
);
});