From 02146ab4e9f0272f44331bc0506b7abe26ce5366 Mon Sep 17 00:00:00 2001 From: Svetznaniy33 Date: Thu, 18 Jun 2026 22:54:09 +0300 Subject: [PATCH] fix(combo): keep passthrough quota fallback scoped (#4194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.29 — reconciled onto post-split combo.ts (#4186); RED->GREEN validated. Thanks @Svetznaniy33. --- open-sse/services/combo.ts | 9 +++++++ tests/unit/combo-prescreen.test.ts | 38 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a6139ed5c7..d871d248d0 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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); diff --git a/tests/unit/combo-prescreen.test.ts b/tests/unit/combo-prescreen.test.ts index aa5008ea1f..3db96b85e5 100644 --- a/tests/unit/combo-prescreen.test.ts +++ b/tests/unit/combo-prescreen.test.ts @@ -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" + ); +});