From ea2e0256d2704a2e2772c7deee31dce8f30d4cdf Mon Sep 17 00:00:00 2001 From: anuragg-saxenaa Date: Tue, 24 Mar 2026 00:08:32 -0400 Subject: [PATCH] fix: skip quota-exhausted models across combo requests (closes #373) --- open-sse/services/combo.js | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index cefbac38582..5d2854b87c9 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -11,6 +11,42 @@ import { unavailableResponse } from "../utils/error.js"; */ const comboRotationState = new Map(); +/** + * Track per-model quota exhaustion: modelStr → timestamp when it becomes available again. + * This persists across requests so exhausted models are skipped until their cooldown expires, + * rather than being retried from the top of the combo on every new request. + * @type {Map} + */ +const modelSkipUntil = new Map(); + +/** + * Mark a model as quota-exhausted for the given cooldown window. + * @param {string} modelStr - The model identifier + * @param {number} cooldownMs - How long (ms) to skip this model + */ +function markModelExhausted(modelStr, cooldownMs) { + if (!cooldownMs || cooldownMs <= 0) return; + const existing = modelSkipUntil.get(modelStr) || 0; + const next = Date.now() + cooldownMs; + // Only extend the window, never shorten it + if (next > existing) modelSkipUntil.set(modelStr, next); +} + +/** + * Return true if the model is still within its quota-exhaustion cooldown. + * @param {string} modelStr - The model identifier + * @returns {boolean} + */ +function isModelExhausted(modelStr) { + const until = modelSkipUntil.get(modelStr); + if (!until) return false; + if (Date.now() >= until) { + modelSkipUntil.delete(modelStr); // expired — clean up + return false; + } + return true; +} + /** * Get rotated model list based on strategy * @param {string[]} models - Array of model strings @@ -80,6 +116,15 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co for (let i = 0; i < rotatedModels.length; i++) { const modelStr = rotatedModels[i]; + + // Skip models that are still within their quota-exhaustion cooldown from a previous request + if (isModelExhausted(modelStr)) { + log.info("COMBO", `Skipping exhausted model ${i + 1}/${rotatedModels.length}: ${modelStr} (quota cooldown active)`); + lastError = lastError || `${modelStr} quota exhausted`; + if (!lastStatus) lastStatus = 429; + continue; + } + log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); try { @@ -129,6 +174,14 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co await new Promise(r => setTimeout(r, cooldownMs)); } + // For quota/auth errors with a long cooldown, mark the model as exhausted so + // subsequent requests skip it immediately instead of wasting time retrying. + // Only apply to substantial cooldowns (>5 s) to avoid skipping on transient errors. + if (cooldownMs && cooldownMs > 5000) { + markModelExhausted(modelStr, cooldownMs); + log.info("COMBO", `Model ${modelStr} marked exhausted for ${Math.round(cooldownMs / 1000)}s`); + } + // Fallback to next model lastError = errorText || String(result.status); if (!lastStatus) lastStatus = result.status;