| |
| |
| |
|
|
| import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; |
| import { unavailableResponse } from "../utils/error.js"; |
|
|
| |
| |
| |
| |
| const comboRotationState = new Map(); |
|
|
| function normalizeStickyLimit(stickyLimit) { |
| const parsed = Number.parseInt(stickyLimit, 10); |
| return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; |
| } |
|
|
| function rotateModelsFromIndex(models, currentIndex) { |
| const rotatedModels = [...models]; |
| for (let i = 0; i < currentIndex; i++) { |
| const moved = rotatedModels.shift(); |
| rotatedModels.push(moved); |
| } |
| return rotatedModels; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { |
| if (!models || models.length <= 1 || strategy !== "round-robin") { |
| return models; |
| } |
|
|
| const rotationKey = comboName || "__default__"; |
| const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); |
| const existingState = comboRotationState.get(rotationKey); |
| const state = typeof existingState === "number" |
| ? { index: existingState, consecutiveUseCount: 0 } |
| : (existingState || { index: 0, consecutiveUseCount: 0 }); |
|
|
| const currentIndex = state.index % models.length; |
| const rotatedModels = rotateModelsFromIndex(models, currentIndex); |
| const nextUseCount = state.consecutiveUseCount + 1; |
|
|
| if (nextUseCount >= normalizedStickyLimit) { |
| comboRotationState.set(rotationKey, { |
| index: (currentIndex + 1) % models.length, |
| consecutiveUseCount: 0, |
| }); |
| } else { |
| comboRotationState.set(rotationKey, { |
| index: currentIndex, |
| consecutiveUseCount: nextUseCount, |
| }); |
| } |
|
|
| return rotatedModels; |
| } |
|
|
| |
| |
| |
| |
| export function resetComboRotation(comboName) { |
| if (comboName) comboRotationState.delete(comboName); |
| else comboRotationState.clear(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function getComboModelsFromData(modelStr, combosData) { |
| |
| if (modelStr.includes("/")) return null; |
| |
| |
| const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []); |
| |
| const combo = combos.find(c => c.name === modelStr); |
| if (combo && combo.models && combo.models.length > 0) { |
| return combo.models; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1 }) { |
| |
| const rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); |
| |
| let lastError = null; |
| let earliestRetryAfter = null; |
| let lastStatus = null; |
|
|
| for (let i = 0; i < rotatedModels.length; i++) { |
| const modelStr = rotatedModels[i]; |
| log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); |
|
|
| try { |
| const result = await handleSingleModel(body, modelStr); |
| |
| |
| if (result.ok) { |
| log.info("COMBO", `Model ${modelStr} succeeded`); |
| return result; |
| } |
|
|
| |
| let errorText = result.statusText || ""; |
| let retryAfter = null; |
| try { |
| const errorBody = await result.clone().json(); |
| errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; |
| retryAfter = errorBody?.retryAfter || null; |
| } catch { |
| |
| } |
|
|
| |
| if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) { |
| earliestRetryAfter = retryAfter; |
| } |
|
|
| |
| if (typeof errorText !== "string") { |
| try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } |
| } |
|
|
| |
| const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText); |
|
|
| if (!shouldFallback) { |
| log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); |
| return result; |
| } |
|
|
| |
| |
| |
| if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 && |
| (result.status === 503 || result.status === 502 || result.status === 504)) { |
| log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`); |
| await new Promise(r => setTimeout(r, cooldownMs)); |
| } |
|
|
| |
| lastError = errorText || String(result.status); |
| if (!lastStatus) lastStatus = result.status; |
| log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); |
| } catch (error) { |
| |
| lastError = error.message || String(error); |
| if (!lastStatus) lastStatus = 500; |
| log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError }); |
| } |
| } |
|
|
| |
| |
| |
| |
| const allDisabled = lastError && lastError.toLowerCase().includes("no credentials"); |
| const status = allDisabled ? 503 : (lastStatus || 503); |
| const msg = lastError || "All combo models unavailable"; |
|
|
| if (earliestRetryAfter) { |
| const retryHuman = formatRetryAfter(earliestRetryAfter); |
| log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); |
| return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); |
| } |
|
|
| log.warn("COMBO", `All models failed | ${msg}`); |
| return new Response( |
| JSON.stringify({ error: { message: msg } }), |
| { status, headers: { "Content-Type": "application/json" } } |
| ); |
| } |
|
|