/** * OpenCode Go key 选路与轮换。 * 零 pi 依赖的纯业务模块。 * * 核心规则: * - 会话内粘合:同一个 sessionId 优先复用同一个 key(避免中途换 key 丢前缀缓存)。 * - 失败轮换:429 / 配额耗尽 / 静默卡死时切到下一个「非冷却 / 非封禁」的 key。 * - 冷却 / 封禁优先级:永久(配额)封禁 > 瞬态冷却;全封禁时停止并报最早解禁。 */ import type { Config } from "./config.ts"; import { getCooldownMs } from "./config.ts"; export type RateLimitKind = "transient" | "fixed-window-quota"; const FIXED_WINDOW_QUOTA_RE = /\b(?:5[- ]hour|weekly|monthly)\b[\s\S]*\b(?:usage\s+)?(?:quota|limit)\b|\b(?:usage|plan)\s+allocated\s+quota\s+exceeded\b|\b(?:quota|limit)\b[\s\S]*\b(?:will\s+reset|resets?\s+at|fixed[- ]window)\b/i; const TRANSIENT_RATE_LIMIT_RE = /\b429\b|rate.?limit|too many requests|quota|usage limit|limit reached/i; export function classifyRateLimitError(message: string): RateLimitKind | undefined { if (FIXED_WINDOW_QUOTA_RE.test(message)) return "fixed-window-quota"; if (TRANSIENT_RATE_LIMIT_RE.test(message)) return "transient"; return undefined; } export interface KeyState { index: number; entry: { name: string; key: string } | undefined; /** 是否瞬态冷却中 */ coolingDown: boolean; /** 是否配额永久封禁中 */ quotaBlocked: boolean; /** 当前 key 距解禁/冷却结束还有多少 ms(0 表示未受限) */ blockedMs: number; } /** 计算一个 key 的状态(now 为 epoch ms) */ export function keyStateAt(config: Config, index: number, now: number): KeyState { const cooldownStart = config.cooldowns[index]; const blockedUntil = config.quotaBlockedUntil[index]; const coolingDown = cooldownStart !== undefined && now < cooldownStart + getCooldownMs(config); const quotaBlocked = blockedUntil !== undefined && now < blockedUntil; let blockedMs = 0; if (quotaBlocked) blockedMs = blockedUntil! - now; else if (coolingDown) blockedMs = cooldownStart! + getCooldownMs(config) - now; return { index, entry: config.keys[index], coolingDown, quotaBlocked, blockedMs, }; } /** 判断一个 key 当前是否可用(不受限) */ export function isKeyUsable(config: Config, index: number, now: number): boolean { const st = keyStateAt(config, index, now); return !st.coolingDown && !st.quotaBlocked; } interface EligibleResult { index: number; usable: boolean; blockedMs: number; } /** * 挑一个真正可用的 key(非冷却、非封禁)。Quota 封禁不参与。 * 返回 -1 表示当前无任何可用 key。 */ function pickEligible(config: Config, now: number, avoidIndex?: number): EligibleResult { for (let i = 0; i < config.keys.length; i++) { if (i === avoidIndex) continue; const st = keyStateAt(config, i, now); if (st.quotaBlocked) continue; // 配额封禁不参与轮换 const usable = !st.coolingDown && !st.quotaBlocked; if (usable) return { index: i, usable: true, blockedMs: 0 }; } return { index: -1, usable: false, blockedMs: 0 }; } export interface PickKeyResult { index: number; changed: boolean; reason: "affinity" | "rotate" | "new" | "none"; } /** * 为一次请求挑选 key。 * - sessionId 已粘合到的 key 若仍可用 → 继续用(affinity)。 * - sessionId 粘合 key 不可用 → 切换并更新粘合(rotate)。 * - 无粘合 → 选一个可用 key 并建立粘合(new)。 * - 无任何可用 key → reason "none",index=-1。 */ /** * 为一次请求挑选 key。 * - sessionId 已粘合到的 key 若仍可用 → 继续用(affinity)。 * - sessionId 粘合 key 不可用 → 切换并更新粘合(rotate)。 * - 无粘合 → 选一个可用 key 并建立粘合(new)。 * - 无任何可用 key → reason "none",index=-1(不硬挑冷却/封禁中的 key)。 */ export function pickKeyForSession(config: Config, sessionId: string | undefined, now: number): PickKeyResult { const bound = sessionId !== undefined ? config.sessionAffinity[sessionId] : undefined; if (bound !== undefined && bound >= 0 && bound < config.keys.length && config.keys[bound]) { if (isKeyUsable(config, bound, now)) { return { index: bound, changed: false, reason: "affinity" }; } // 已绑定但不可用 → 轮换到下一个真正可用的 key const next = pickEligible(config, now, bound); if (next.index >= 0) { if (sessionId !== undefined) config.sessionAffinity[sessionId] = next.index; return { index: next.index, changed: true, reason: "rotate" }; } // 无可用 key 可切换:不硬切,保留原绑定 key(由外层决定如何处理),标记为 none return { index: bound, changed: false, reason: "none" }; } // 无粘合 → 新建 const next = pickEligible(config, now); if (next.index < 0) { return { index: -1, changed: false, reason: "none" }; } if (sessionId !== undefined) config.sessionAffinity[sessionId] = next.index; return { index: next.index, changed: true, reason: "new" }; } /** * 报告一次失败:根据错误类型把 key 标记为冷却或配额封禁。 * - fixed-window-quota → 配额封禁,持续到解析出的 reset 时间(或默认冷却时长)。 * - transient → 瞬态冷却。 * 返回是否真正新增了限制(false 表示该 key 已在限制中)。 */ export function markFailure( config: Config, index: number, kind: RateLimitKind, now: number, resetAt?: string, io: { now(): number } = { now: () => Date.now() }, ): boolean { if (index < 0 || index >= config.keys.length) return false; if (kind === "fixed-window-quota") { let until: number; if (resetAt) { const t = Date.parse(resetAt); until = isNaN(t) ? now + getCooldownMs(config) : t; } else { until = now + getCooldownMs(config); } const existing = config.quotaBlockedUntil[index]; if (existing !== undefined && existing >= now) return false; // 已封禁 config.quotaBlockedUntil[index] = Math.max(existing ?? 0, until); delete config.cooldowns[index]; return true; } // transient const existingCd = config.cooldowns[index]; if (existingCd !== undefined && now < existingCd + getCooldownMs(config)) return false; config.cooldowns[index] = now; return true; } /** 手动切换 key,并清除其冷却与封禁。返回是否成功(index 越界返回 false)。 */ export function forceUse(config: Config, index: number): boolean { if (index < 0 || index >= config.keys.length) return false; config.activeKeyIndex = index; delete config.cooldowns[index]; delete config.quotaBlockedUntil[index]; return true; } /** 清除某 key 的冷却与封禁(供 /opencode use next 等)。 */ export function clearRestrictions(config: Config, index: number): void { if (index < 0 || index >= config.keys.length) return; delete config.cooldowns[index]; delete config.quotaBlockedUntil[index]; } /** 全 key 都没有可用时,返回最早解禁时刻(epoch ms),否则 undefined。 */ export function earliestUnblock(config: Config, now: number): number | undefined { let earliest: number | undefined; for (let i = 0; i < config.keys.length; i++) { const st = keyStateAt(config, i, now); if (st.quotaBlocked) { const until = config.quotaBlockedUntil[i]!; if (earliest === undefined || until < earliest) earliest = until; } else if (st.coolingDown) { const end = config.cooldowns[i]! + getCooldownMs(config); if (earliest === undefined || end < earliest) earliest = end; } } return earliest; } /** 解绑某个 session 的粘合(会话结束时调用)。 */ export function clearSessionAffinity(config: Config, sessionId: string): void { delete config.sessionAffinity[sessionId]; }