From d89b409add4412ca594ab6f4e9bf77bc917d7aa8 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 16:55:42 +0800 Subject: [PATCH 1/7] feat(agent): add auth profile rotation and cooldown module Implement multi-profile auth system with exponential-backoff cooldowns, modeled after OpenClaw's auth-profiles system. Includes types, constants, persistent store (auth-profiles.json), usage tracking with cooldown calculation, and profile ordering with round-robin and explicit modes. Co-Authored-By: Claude Opus 4.5 --- src/agent/auth-profiles/constants.ts | 45 +++++++ src/agent/auth-profiles/index.ts | 45 +++++++ src/agent/auth-profiles/order.test.ts | 176 ++++++++++++++++++++++++++ src/agent/auth-profiles/order.ts | 94 ++++++++++++++ src/agent/auth-profiles/store.ts | 84 ++++++++++++ src/agent/auth-profiles/types.ts | 47 +++++++ src/agent/auth-profiles/usage.test.ts | 147 +++++++++++++++++++++ src/agent/auth-profiles/usage.ts | 169 +++++++++++++++++++++++++ 8 files changed, 807 insertions(+) create mode 100644 src/agent/auth-profiles/constants.ts create mode 100644 src/agent/auth-profiles/index.ts create mode 100644 src/agent/auth-profiles/order.test.ts create mode 100644 src/agent/auth-profiles/order.ts create mode 100644 src/agent/auth-profiles/store.ts create mode 100644 src/agent/auth-profiles/types.ts create mode 100644 src/agent/auth-profiles/usage.test.ts create mode 100644 src/agent/auth-profiles/usage.ts diff --git a/src/agent/auth-profiles/constants.ts b/src/agent/auth-profiles/constants.ts new file mode 100644 index 00000000..d519a246 --- /dev/null +++ b/src/agent/auth-profiles/constants.ts @@ -0,0 +1,45 @@ +/** + * Auth Profile Constants + * + * Cooldown timings, store version, and file names. + */ + +/** Store format version */ +export const AUTH_STORE_VERSION = 1; + +/** Runtime store filename (inside ~/.super-multica/) */ +export const AUTH_PROFILE_STORE_FILENAME = "auth-profiles.json"; + +// ============================================================ +// Non-billing cooldown (rate_limit, auth, timeout, unknown) +// Progression: 1min -> 5min -> 25min -> 1hr (cap) +// Formula: min(MAX, BASE * FACTOR ^ min(errorCount - 1, 3)) +// ============================================================ + +/** Base cooldown duration in milliseconds (1 minute) */ +export const COOLDOWN_BASE_MS = 60_000; + +/** Exponential factor for cooldown progression */ +export const COOLDOWN_FACTOR = 5; + +/** Maximum cooldown duration in milliseconds (1 hour) */ +export const COOLDOWN_MAX_MS = 3_600_000; + +// ============================================================ +// Billing disable (longer backoff for payment/quota issues) +// Progression: 5h -> 10h -> 20h -> 24h (cap) +// Formula: min(MAX_HOURS, BASE_HOURS * 2 ^ (count - 1)) +// ============================================================ + +/** Base billing disable duration in hours */ +export const BILLING_BACKOFF_HOURS = 5; + +/** Maximum billing disable duration in hours */ +export const BILLING_MAX_HOURS = 24; + +// ============================================================ +// Failure window +// ============================================================ + +/** Failure window in milliseconds (24 hours) — errors older than this are forgotten */ +export const FAILURE_WINDOW_MS = 24 * 60 * 60 * 1000; diff --git a/src/agent/auth-profiles/index.ts b/src/agent/auth-profiles/index.ts new file mode 100644 index 00000000..bd87cbb7 --- /dev/null +++ b/src/agent/auth-profiles/index.ts @@ -0,0 +1,45 @@ +/** + * Auth Profiles — barrel export + */ + +export type { + AuthProfileFailureReason, + AuthProfileStore, + ProfileUsageStats, + ResolvedProfileAuth, +} from "./types.js"; + +export { + AUTH_STORE_VERSION, + AUTH_PROFILE_STORE_FILENAME, + COOLDOWN_BASE_MS, + COOLDOWN_FACTOR, + COOLDOWN_MAX_MS, + BILLING_BACKOFF_HOURS, + BILLING_MAX_HOURS, + FAILURE_WINDOW_MS, +} from "./constants.js"; + +export { + resolveAuthStorePath, + loadAuthProfileStore, + saveAuthProfileStore, + updateAuthProfileStore, +} from "./store.js"; + +export { + listProfilesForProvider, + resolveAuthProfileOrder, +} from "./order.js"; + +export { + isProfileInCooldown, + resolveProfileUnusableUntil, + calculateCooldownMs, + calculateBillingDisableMs, + computeNextProfileUsageStats, + markAuthProfileFailure, + markAuthProfileUsed, + markAuthProfileGood, + clearAuthProfileCooldown, +} from "./usage.js"; diff --git a/src/agent/auth-profiles/order.test.ts b/src/agent/auth-profiles/order.test.ts new file mode 100644 index 00000000..dd729719 --- /dev/null +++ b/src/agent/auth-profiles/order.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveAuthProfileOrder, listProfilesForProvider } from "./order.js"; +import type { AuthProfileStore } from "./types.js"; + +// Mock credentialManager +vi.mock("../credentials.js", () => { + let _profiles: Record = {}; + let _order: Record = {}; + + return { + credentialManager: { + listProfileIdsForProvider(provider: string): string[] { + return Object.keys(_profiles).filter( + (key) => key === provider || key.startsWith(`${provider}:`), + ); + }, + getLlmOrder(provider: string): string[] | undefined { + return _order[provider]; + }, + // Test helpers + __setProfiles(profiles: Record) { + _profiles = profiles; + }, + __setOrder(order: Record) { + _order = order; + }, + }, + }; +}); + +// Import the mock to use test helpers +import { credentialManager } from "../credentials.js"; +const mock = credentialManager as unknown as { + __setProfiles: (p: Record) => void; + __setOrder: (o: Record) => void; +}; + +beforeEach(() => { + mock.__setProfiles({}); + mock.__setOrder({}); +}); + +// ============================================================ +// listProfilesForProvider +// ============================================================ + +describe("listProfilesForProvider", () => { + it("returns profiles matching the provider", () => { + mock.__setProfiles({ + anthropic: { apiKey: "sk-1" }, + "anthropic:backup": { apiKey: "sk-2" }, + openai: { apiKey: "sk-3" }, + }); + expect(listProfilesForProvider("anthropic")).toEqual([ + "anthropic", + "anthropic:backup", + ]); + }); + + it("returns empty array when no profiles match", () => { + mock.__setProfiles({ openai: { apiKey: "sk-1" } }); + expect(listProfilesForProvider("anthropic")).toEqual([]); + }); +}); + +// ============================================================ +// resolveAuthProfileOrder +// ============================================================ + +describe("resolveAuthProfileOrder", () => { + const now = 1_000_000; + + it("returns round-robin order by lastUsed when no explicit order", () => { + mock.__setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { + version: 1, + usageStats: { + "anthropic": { lastUsed: 300 }, + "anthropic:b": { lastUsed: 100 }, + "anthropic:c": { lastUsed: 200 }, + }, + }; + + const order = resolveAuthProfileOrder("anthropic", store, now); + // Sorted by lastUsed ascending: b(100) -> c(200) -> default(300) + expect(order).toEqual(["anthropic:b", "anthropic:c", "anthropic"]); + }); + + it("respects explicit order from config", () => { + mock.__setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + mock.__setOrder({ anthropic: ["anthropic:c", "anthropic", "anthropic:b"] }); + + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + expect(order).toEqual(["anthropic:c", "anthropic", "anthropic:b"]); + }); + + it("pushes cooldown profiles to the end", () => { + mock.__setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { + version: 1, + usageStats: { + "anthropic": { lastUsed: 100 }, + "anthropic:b": { lastUsed: 200, cooldownUntil: now + 5000 }, + "anthropic:c": { lastUsed: 300 }, + }, + }; + + const order = resolveAuthProfileOrder("anthropic", store, now); + // anthropic and anthropic:c are available; anthropic:b is in cooldown -> pushed to end + expect(order).toEqual(["anthropic", "anthropic:c", "anthropic:b"]); + }); + + it("sorts cooldown profiles by earliest recovery", () => { + mock.__setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { + version: 1, + usageStats: { + "anthropic": { cooldownUntil: now + 10_000 }, + "anthropic:b": { cooldownUntil: now + 1_000 }, + "anthropic:c": { cooldownUntil: now + 5_000 }, + }, + }; + + const order = resolveAuthProfileOrder("anthropic", store, now); + // All in cooldown, sorted by soonest recovery + expect(order).toEqual(["anthropic:b", "anthropic:c", "anthropic"]); + }); + + it("deduplicates profile IDs", () => { + mock.__setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + }); + // Explicit order has duplicate + mock.__setOrder({ anthropic: ["anthropic", "anthropic", "anthropic:b"] }); + + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + expect(order).toEqual(["anthropic", "anthropic:b"]); + }); + + it("appends unlisted profiles to explicit order", () => { + mock.__setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + // Only lists one profile in explicit order + mock.__setOrder({ anthropic: ["anthropic:b"] }); + + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + // anthropic:b first (explicit), then the rest + expect(order[0]).toBe("anthropic:b"); + expect(order).toHaveLength(3); + expect(order).toContain("anthropic"); + expect(order).toContain("anthropic:c"); + }); +}); diff --git a/src/agent/auth-profiles/order.ts b/src/agent/auth-profiles/order.ts new file mode 100644 index 00000000..16055e81 --- /dev/null +++ b/src/agent/auth-profiles/order.ts @@ -0,0 +1,94 @@ +/** + * Auth Profile Ordering + * + * Determines the order in which auth profiles are tried for a given provider. + * Supports explicit ordering (from credentials.json5) and automatic round-robin + * based on lastUsed time. Profiles in cooldown are pushed to the end. + */ + +import { credentialManager } from "../credentials.js"; +import type { AuthProfileStore, ProfileUsageStats } from "./types.js"; +import { isProfileInCooldown, resolveProfileUnusableUntil } from "./usage.js"; + +// ============================================================ +// Profile discovery +// ============================================================ + +/** + * List all profile IDs from credentials.json5 that belong to a given provider. + * A profile matches if its key equals the provider exactly or starts with "provider:". + */ +export function listProfilesForProvider(provider: string): string[] { + return credentialManager.listProfileIdsForProvider(provider); +} + +// ============================================================ +// Ordering +// ============================================================ + +/** + * Resolve the ordered list of profile IDs to try for a given provider. + * + * Strategy: + * 1. If credentials.json5 has `llm.order[provider]`, use that explicit order. + * 2. Otherwise, use round-robin ordered by `lastUsed` ascending (oldest first). + * + * In both cases, profiles currently in cooldown are pushed to the end, + * sorted by earliest cooldown expiry (soonest-to-recover first). + */ +export function resolveAuthProfileOrder( + provider: string, + store: AuthProfileStore, + now?: number, +): string[] { + const ts = now ?? Date.now(); + + // Gather candidates + const explicitOrder = credentialManager.getLlmOrder(provider); + const allProfiles = listProfilesForProvider(provider); + + let candidates: string[]; + if (explicitOrder && explicitOrder.length > 0) { + // Use explicit order, filter to only existing profiles + const profileSet = new Set(allProfiles); + candidates = explicitOrder.filter((id) => profileSet.has(id)); + // Append any profiles not in the explicit order + for (const id of allProfiles) { + if (!candidates.includes(id)) { + candidates.push(id); + } + } + } else { + // Round-robin by lastUsed (oldest first) + candidates = [...allProfiles].sort((a, b) => { + const statsA = store.usageStats?.[a]; + const statsB = store.usageStats?.[b]; + return (statsA?.lastUsed ?? 0) - (statsB?.lastUsed ?? 0); + }); + } + + // Deduplicate + candidates = [...new Set(candidates)]; + + // Partition into available and in-cooldown + const available: string[] = []; + const inCooldown: string[] = []; + + for (const id of candidates) { + const stats = store.usageStats?.[id]; + if (stats && isProfileInCooldown(stats, ts)) { + inCooldown.push(id); + } else { + available.push(id); + } + } + + // Sort cooldown profiles by soonest recovery + inCooldown.sort((a, b) => { + const statsA = store.usageStats?.[a] ?? {}; + const statsB = store.usageStats?.[b] ?? {}; + return resolveProfileUnusableUntil(statsA) - resolveProfileUnusableUntil(statsB); + }); + + return [...available, ...inCooldown]; +} diff --git a/src/agent/auth-profiles/store.ts b/src/agent/auth-profiles/store.ts new file mode 100644 index 00000000..3bb0480f --- /dev/null +++ b/src/agent/auth-profiles/store.ts @@ -0,0 +1,84 @@ +/** + * Auth Profile Store + * + * Persistence layer for auth profile runtime state. + * Stores usage stats, cooldowns, and last-good info in ~/.super-multica/auth-profiles.json. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { DATA_DIR } from "../../shared/paths.js"; +import { AUTH_STORE_VERSION, AUTH_PROFILE_STORE_FILENAME } from "./constants.js"; +import type { AuthProfileStore } from "./types.js"; + +// ============================================================ +// Paths +// ============================================================ + +/** Resolve the auth profile store file path */ +export function resolveAuthStorePath(): string { + return join(DATA_DIR, AUTH_PROFILE_STORE_FILENAME); +} + +// ============================================================ +// Load / Save +// ============================================================ + +function createEmptyStore(): AuthProfileStore { + return { version: AUTH_STORE_VERSION }; +} + +function coerceStore(raw: unknown): AuthProfileStore { + if (!raw || typeof raw !== "object") return createEmptyStore(); + + const obj = raw as Record; + const store: AuthProfileStore = { + version: typeof obj.version === "number" ? obj.version : AUTH_STORE_VERSION, + }; + + if (obj.lastGood && typeof obj.lastGood === "object") { + store.lastGood = obj.lastGood as Record; + } + if (obj.usageStats && typeof obj.usageStats === "object") { + store.usageStats = obj.usageStats as AuthProfileStore["usageStats"]; + } + + return store; +} + +/** Load auth profile store from disk. Returns empty store if file doesn't exist. */ +export function loadAuthProfileStore(): AuthProfileStore { + const storePath = resolveAuthStorePath(); + if (!existsSync(storePath)) return createEmptyStore(); + + try { + const raw = readFileSync(storePath, "utf8"); + return coerceStore(JSON.parse(raw)); + } catch { + return createEmptyStore(); + } +} + +/** Save auth profile store to disk */ +export function saveAuthProfileStore(store: AuthProfileStore): void { + const storePath = resolveAuthStorePath(); + const dir = dirname(storePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8"); +} + +/** + * Atomic load-update-save cycle. + * The updater receives the current store and should mutate it in place. + * Returns the updated store. + */ +export function updateAuthProfileStore( + updater: (store: AuthProfileStore) => void, +): AuthProfileStore { + const store = loadAuthProfileStore(); + updater(store); + saveAuthProfileStore(store); + return store; +} diff --git a/src/agent/auth-profiles/types.ts b/src/agent/auth-profiles/types.ts new file mode 100644 index 00000000..e4e34eb2 --- /dev/null +++ b/src/agent/auth-profiles/types.ts @@ -0,0 +1,47 @@ +/** + * Auth Profile Types + * + * Type definitions for the auth profile rotation and cooldown system. + */ + +/** Reason for an auth profile failure, determines cooldown behavior */ +export type AuthProfileFailureReason = + | "auth" + | "rate_limit" + | "billing" + | "timeout" + | "unknown"; + +/** Per-profile usage and cooldown state (persisted in auth-profiles.json) */ +export type ProfileUsageStats = { + /** Timestamp of last successful use */ + lastUsed?: number | undefined; + /** Cooldown expiry for non-billing failures (rate_limit, auth, timeout, unknown) */ + cooldownUntil?: number | undefined; + /** Disable expiry for billing failures (longer backoff) */ + disabledUntil?: number | undefined; + /** Reason for the current disable period */ + disabledReason?: AuthProfileFailureReason | undefined; + /** Consecutive error count (resets on success or after failure window) */ + errorCount?: number | undefined; + /** Per-reason failure counts within the failure window */ + failureCounts?: Partial> | undefined; + /** Timestamp of the last failure (used for failure window expiry) */ + lastFailureAt?: number | undefined; +}; + +/** Persisted runtime store for auth profile state */ +export type AuthProfileStore = { + version: number; + /** Last known good profile per provider */ + lastGood?: Record | undefined; + /** Per-profile usage/cooldown stats */ + usageStats?: Record | undefined; +}; + +/** Resolved auth info returned by profile-aware key resolution */ +export type ResolvedProfileAuth = { + apiKey: string; + profileId: string; + provider: string; +}; diff --git a/src/agent/auth-profiles/usage.test.ts b/src/agent/auth-profiles/usage.test.ts new file mode 100644 index 00000000..daaeed8e --- /dev/null +++ b/src/agent/auth-profiles/usage.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { + calculateCooldownMs, + calculateBillingDisableMs, + computeNextProfileUsageStats, + isProfileInCooldown, + resolveProfileUnusableUntil, +} from "./usage.js"; +import { + COOLDOWN_BASE_MS, + COOLDOWN_MAX_MS, + FAILURE_WINDOW_MS, +} from "./constants.js"; +import type { ProfileUsageStats } from "./types.js"; + +// ============================================================ +// calculateCooldownMs +// ============================================================ + +describe("calculateCooldownMs", () => { + it("applies exponential backoff with a 1h cap", () => { + expect(calculateCooldownMs(1)).toBe(60_000); // 1 min + expect(calculateCooldownMs(2)).toBe(5 * 60_000); // 5 min + expect(calculateCooldownMs(3)).toBe(25 * 60_000); // 25 min + expect(calculateCooldownMs(4)).toBe(60 * 60_000); // 1 hour (cap) + expect(calculateCooldownMs(5)).toBe(60 * 60_000); // 1 hour (cap) + expect(calculateCooldownMs(100)).toBe(60 * 60_000); // still capped + }); + + it("returns 0 for errorCount <= 0", () => { + expect(calculateCooldownMs(0)).toBe(0); + expect(calculateCooldownMs(-1)).toBe(0); + }); +}); + +// ============================================================ +// calculateBillingDisableMs +// ============================================================ + +describe("calculateBillingDisableMs", () => { + it("applies exponential backoff with a 24h cap", () => { + const h = 60 * 60 * 1000; + expect(calculateBillingDisableMs(1)).toBe(5 * h); // 5h + expect(calculateBillingDisableMs(2)).toBe(10 * h); // 10h + expect(calculateBillingDisableMs(3)).toBe(20 * h); // 20h + expect(calculateBillingDisableMs(4)).toBe(24 * h); // 24h (cap) + expect(calculateBillingDisableMs(5)).toBe(24 * h); // still capped + }); + + it("returns 0 for count <= 0", () => { + expect(calculateBillingDisableMs(0)).toBe(0); + expect(calculateBillingDisableMs(-1)).toBe(0); + }); +}); + +// ============================================================ +// isProfileInCooldown / resolveProfileUnusableUntil +// ============================================================ + +describe("isProfileInCooldown", () => { + const now = 1_000_000; + + it("returns false for empty stats", () => { + expect(isProfileInCooldown({}, now)).toBe(false); + }); + + it("returns true when cooldownUntil is in the future", () => { + expect(isProfileInCooldown({ cooldownUntil: now + 1000 }, now)).toBe(true); + }); + + it("returns false when cooldownUntil has passed", () => { + expect(isProfileInCooldown({ cooldownUntil: now - 1 }, now)).toBe(false); + }); + + it("returns true when disabledUntil is in the future", () => { + expect(isProfileInCooldown({ disabledUntil: now + 1000 }, now)).toBe(true); + }); + + it("uses max of cooldownUntil and disabledUntil", () => { + const stats: ProfileUsageStats = { + cooldownUntil: now - 1, + disabledUntil: now + 5000, + }; + expect(isProfileInCooldown(stats, now)).toBe(true); + expect(resolveProfileUnusableUntil(stats)).toBe(now + 5000); + }); +}); + +// ============================================================ +// computeNextProfileUsageStats +// ============================================================ + +describe("computeNextProfileUsageStats", () => { + const now = 1_700_000_000_000; + + it("increments errorCount and sets cooldown for non-billing failure", () => { + const next = computeNextProfileUsageStats({}, "rate_limit", now); + expect(next.errorCount).toBe(1); + expect(next.lastFailureAt).toBe(now); + expect(next.cooldownUntil).toBe(now + COOLDOWN_BASE_MS); + expect(next.failureCounts?.rate_limit).toBe(1); + expect(next.disabledUntil).toBeUndefined(); + }); + + it("applies exponential backoff on consecutive failures", () => { + const stats: ProfileUsageStats = { + errorCount: 2, + lastFailureAt: now - 1000, + failureCounts: { rate_limit: 2 }, + }; + const next = computeNextProfileUsageStats(stats, "rate_limit", now); + expect(next.errorCount).toBe(3); + // Error 3 -> 25 min cooldown + expect(next.cooldownUntil).toBe(now + 25 * 60_000); + }); + + it("sets disabledUntil for billing failures (~5h by default)", () => { + const next = computeNextProfileUsageStats({}, "billing", now); + expect(next.errorCount).toBe(1); + expect(next.disabledUntil).toBe(now + 5 * 60 * 60 * 1000); + expect(next.disabledReason).toBe("billing"); + expect(next.failureCounts?.billing).toBe(1); + }); + + it("resets counters when lastFailureAt is outside the failure window", () => { + const oldFailure = now - FAILURE_WINDOW_MS - 1000; + const stats: ProfileUsageStats = { + errorCount: 5, + lastFailureAt: oldFailure, + failureCounts: { auth: 3, rate_limit: 2 }, + }; + const next = computeNextProfileUsageStats(stats, "auth", now); + // Counters reset, so this is treated as error #1 + expect(next.errorCount).toBe(1); + expect(next.failureCounts?.auth).toBe(1); + expect(next.cooldownUntil).toBe(now + COOLDOWN_BASE_MS); + }); + + it("caps cooldown at COOLDOWN_MAX_MS", () => { + const stats: ProfileUsageStats = { + errorCount: 10, + lastFailureAt: now - 1000, + }; + const next = computeNextProfileUsageStats(stats, "unknown", now); + expect(next.cooldownUntil).toBe(now + COOLDOWN_MAX_MS); + }); +}); diff --git a/src/agent/auth-profiles/usage.ts b/src/agent/auth-profiles/usage.ts new file mode 100644 index 00000000..181c78de --- /dev/null +++ b/src/agent/auth-profiles/usage.ts @@ -0,0 +1,169 @@ +/** + * Auth Profile Usage Tracking + * + * Tracks per-profile usage, computes cooldown durations with exponential backoff, + * and manages failure/success state transitions. + */ + +import { + COOLDOWN_BASE_MS, + COOLDOWN_FACTOR, + COOLDOWN_MAX_MS, + BILLING_BACKOFF_HOURS, + BILLING_MAX_HOURS, + FAILURE_WINDOW_MS, +} from "./constants.js"; +import { updateAuthProfileStore } from "./store.js"; +import type { + AuthProfileFailureReason, + AuthProfileStore, + ProfileUsageStats, +} from "./types.js"; + +// ============================================================ +// Cooldown checks +// ============================================================ + +/** Returns the timestamp until which a profile is unusable (0 if available) */ +export function resolveProfileUnusableUntil(stats: ProfileUsageStats): number { + return Math.max(stats.cooldownUntil ?? 0, stats.disabledUntil ?? 0); +} + +/** Check if a profile is currently in cooldown or disabled */ +export function isProfileInCooldown(stats: ProfileUsageStats, now?: number): boolean { + return resolveProfileUnusableUntil(stats) > (now ?? Date.now()); +} + +// ============================================================ +// Cooldown duration calculation +// ============================================================ + +/** + * Calculate non-billing cooldown duration in milliseconds. + * Exponential backoff: 1min -> 5min -> 25min -> 1hr (cap). + * + * Formula: min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * COOLDOWN_FACTOR ^ min(errorCount - 1, 3)) + */ +export function calculateCooldownMs(errorCount: number): number { + if (errorCount <= 0) return 0; + const exponent = Math.min(errorCount - 1, 3); + return Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * COOLDOWN_FACTOR ** exponent); +} + +/** + * Calculate billing disable duration in milliseconds. + * Exponential backoff: 5h -> 10h -> 20h -> 24h (cap). + * + * Formula: min(BILLING_MAX_HOURS, BILLING_BACKOFF_HOURS * 2 ^ (count - 1)) * hours_to_ms + */ +export function calculateBillingDisableMs(billingFailCount: number): number { + if (billingFailCount <= 0) return 0; + const hours = Math.min( + BILLING_MAX_HOURS, + BILLING_BACKOFF_HOURS * 2 ** (billingFailCount - 1), + ); + return hours * 60 * 60 * 1000; +} + +// ============================================================ +// State transitions +// ============================================================ + +function ensureUsageStats(store: AuthProfileStore, profileId: string): ProfileUsageStats { + if (!store.usageStats) store.usageStats = {}; + if (!store.usageStats[profileId]) store.usageStats[profileId] = {}; + return store.usageStats[profileId]; +} + +/** + * Compute updated usage stats after a failure. + * Pure function — does not mutate the input stats. + */ +export function computeNextProfileUsageStats( + stats: ProfileUsageStats, + reason: AuthProfileFailureReason, + now?: number, +): ProfileUsageStats { + const ts = now ?? Date.now(); + const next = { ...stats }; + + // Reset counters if last failure is outside the failure window + if (next.lastFailureAt && ts - next.lastFailureAt > FAILURE_WINDOW_MS) { + next.errorCount = 0; + next.failureCounts = {}; + } + + // Increment counters + next.errorCount = (next.errorCount ?? 0) + 1; + next.lastFailureAt = ts; + + if (!next.failureCounts) next.failureCounts = {}; + next.failureCounts = { + ...next.failureCounts, + [reason]: (next.failureCounts[reason] ?? 0) + 1, + }; + + // Apply cooldown based on failure reason + if (reason === "billing") { + const billingCount = next.failureCounts.billing ?? 1; + const disableMs = calculateBillingDisableMs(billingCount); + next.disabledUntil = ts + disableMs; + next.disabledReason = "billing"; + } else { + const cooldownMs = calculateCooldownMs(next.errorCount); + next.cooldownUntil = ts + cooldownMs; + } + + return next; +} + +/** + * Mark a profile as having failed. Persists updated stats to disk. + */ +export function markAuthProfileFailure( + profileId: string, + reason: AuthProfileFailureReason, + now?: number, +): void { + updateAuthProfileStore((store) => { + const current = ensureUsageStats(store, profileId); + const next = computeNextProfileUsageStats(current, reason, now); + store.usageStats![profileId] = next; + }); +} + +/** + * Mark a profile as successfully used. Resets all cooldown/error state. + */ +export function markAuthProfileUsed(profileId: string, now?: number): void { + updateAuthProfileStore((store) => { + const stats = ensureUsageStats(store, profileId); + stats.lastUsed = now ?? Date.now(); + stats.errorCount = 0; + stats.cooldownUntil = undefined; + stats.disabledUntil = undefined; + stats.disabledReason = undefined; + stats.failureCounts = undefined; + }); +} + +/** + * Mark a profile as the last known good for a provider. + */ +export function markAuthProfileGood(provider: string, profileId: string): void { + updateAuthProfileStore((store) => { + if (!store.lastGood) store.lastGood = {}; + store.lastGood[provider] = profileId; + }); +} + +/** + * Clear cooldown for a specific profile. + */ +export function clearAuthProfileCooldown(profileId: string): void { + updateAuthProfileStore((store) => { + const stats = ensureUsageStats(store, profileId); + stats.errorCount = 0; + stats.cooldownUntil = undefined; + }); +} From dcc336f2d03101d15edc14be4cd1611c093b74bb Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 16:56:04 +0800 Subject: [PATCH 2/7] feat(agent): integrate auth profile rotation into runner and resolver Extend credentials.ts with llm.order and listProfileIdsForProvider. Add resolveApiKeyForProfile and resolveApiKeyForProvider to resolver. Modify runner to support dynamic API key swapping and automatic rotation on auth/rate_limit/billing errors with retry. Co-Authored-By: Claude Opus 4.5 --- src/agent/credentials.ts | 26 +++++ src/agent/providers/index.ts | 2 + src/agent/providers/resolver.ts | 71 ++++++++++++ src/agent/runner.ts | 184 +++++++++++++++++++++++++++++--- src/agent/types.ts | 2 + 5 files changed, 273 insertions(+), 12 deletions(-) diff --git a/src/agent/credentials.ts b/src/agent/credentials.ts index 6e1e3dc5..5f4c7555 100644 --- a/src/agent/credentials.ts +++ b/src/agent/credentials.ts @@ -21,6 +21,8 @@ export type CredentialsConfig = { llm?: { provider?: string | undefined; providers?: Record | undefined; + /** Explicit profile ordering per provider (e.g. { anthropic: ["anthropic", "anthropic:backup"] }) */ + order?: Record | undefined; } | undefined; tools?: Record | undefined; }; @@ -185,6 +187,30 @@ export class CredentialManager { return name in process.env; } + /** + * Get explicit profile order for a provider from credentials.json5 `llm.order`. + * Returns undefined if no explicit order is configured. + */ + getLlmOrder(provider: string): string[] | undefined { + this.loadCore(); + return this.coreConfig?.llm?.order?.[provider]; + } + + /** + * List all profile IDs from `llm.providers` that belong to a given provider. + * A profile matches if its key equals the provider exactly or starts with "provider:". + */ + listProfileIdsForProvider(provider: string): string[] { + this.loadCore(); + const providers = this.coreConfig?.llm?.providers; + if (!providers) return []; + + const prefix = `${provider}:`; + return Object.keys(providers).filter( + (key) => key === provider || key.startsWith(prefix), + ); + } + getResolvedEnvSnapshot(): Record { return { ...this.getResolvedSkillsEnv() }; } diff --git a/src/agent/providers/index.ts b/src/agent/providers/index.ts index 25108916..9cce3f56 100644 --- a/src/agent/providers/index.ts +++ b/src/agent/providers/index.ts @@ -28,6 +28,8 @@ export { type ProviderConfig, resolveProviderConfig, resolveApiKey, + resolveApiKeyForProfile, + resolveApiKeyForProvider, resolveBaseUrl, resolveModelId, resolveModel, diff --git a/src/agent/providers/resolver.ts b/src/agent/providers/resolver.ts index 7ec8dd14..7a18ef88 100644 --- a/src/agent/providers/resolver.ts +++ b/src/agent/providers/resolver.ts @@ -18,6 +18,12 @@ import { isOAuthProvider, } from "./registry.js"; import type { AgentOptions } from "../types.js"; +import { + loadAuthProfileStore, + resolveAuthProfileOrder, + isProfileInCooldown, +} from "../auth-profiles/index.js"; +import type { ResolvedProfileAuth } from "../auth-profiles/index.js"; // ============================================================ // Types @@ -128,6 +134,71 @@ export function resolveModelId(provider: string, explicitModel?: string): string return credentialManager.getLlmProviderConfig(provider)?.model ?? getDefaultModel(provider); } +// ============================================================ +// Profile-aware API Key Resolution +// ============================================================ + +/** + * Resolve API key for a specific auth profile ID. + * Profile IDs follow the convention: "provider" or "provider:label". + */ +export function resolveApiKeyForProfile(profileId: string): string | undefined { + const config = credentialManager.getLlmProviderConfig(profileId); + return config?.apiKey; +} + +/** + * Resolve API key by iterating auth profiles for a provider. + * Returns the first available (non-cooldown) profile with a valid key. + * Falls back to the legacy single-key resolution if no profiles are configured. + */ +export function resolveApiKeyForProvider( + provider: string, + explicitKey?: string, +): ResolvedProfileAuth | undefined { + if (explicitKey) { + return { apiKey: explicitKey, profileId: provider, provider }; + } + + // Try OAuth providers first + const providerConfig = resolveProviderConfig(provider); + if (providerConfig?.apiKey || providerConfig?.accessToken) { + const key = providerConfig.apiKey ?? providerConfig.accessToken; + if (key) return { apiKey: key, profileId: provider, provider }; + } + + // Try auth profiles (multi-key rotation) + const store = loadAuthProfileStore(); + const candidates = resolveAuthProfileOrder(provider, store); + + if (candidates.length > 0) { + for (const profileId of candidates) { + const stats = store.usageStats?.[profileId]; + if (stats && isProfileInCooldown(stats)) continue; + + const apiKey = resolveApiKeyForProfile(profileId); + if (apiKey) { + return { apiKey, profileId, provider }; + } + } + // All in cooldown — return the first one (will be retried when cooldown expires) + for (const profileId of candidates) { + const apiKey = resolveApiKeyForProfile(profileId); + if (apiKey) { + return { apiKey, profileId, provider }; + } + } + } + + // Fall back to single-key credentials.json5 + const fallbackKey = credentialManager.getLlmProviderConfig(provider)?.apiKey; + if (fallbackKey) { + return { apiKey: fallbackKey, profileId: provider, provider }; + } + + return undefined; +} + // ============================================================ // Model Resolution // ============================================================ diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 90f5416a..695a0467 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -3,7 +3,13 @@ import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult } from "./types.js"; import { createAgentOutput } from "./cli/output.js"; import { resolveModel, resolveTools } from "./tools.js"; -import { resolveApiKey, resolveBaseUrl, resolveModelId } from "./providers/index.js"; +import { + resolveApiKey, + resolveApiKeyForProfile, + resolveApiKeyForProvider, + resolveBaseUrl, + resolveModelId, +} from "./providers/index.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; @@ -14,6 +20,42 @@ import { type ContextWindowGuardResult, } from "./context-window/index.js"; import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js"; +import { + loadAuthProfileStore, + resolveAuthProfileOrder, + isProfileInCooldown, + markAuthProfileFailure, + markAuthProfileUsed, + markAuthProfileGood, +} from "./auth-profiles/index.js"; +import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; + +// ============================================================ +// Error classification for auth profile rotation +// ============================================================ + +function classifyError(error: unknown): AuthProfileFailureReason { + const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + + if (msg.includes("401") || msg.includes("403") || msg.includes("unauthorized") || msg.includes("invalid api key") || msg.includes("authentication")) { + return "auth"; + } + if (msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests")) { + return "rate_limit"; + } + if (msg.includes("billing") || msg.includes("quota") || msg.includes("insufficient") || msg.includes("payment")) { + return "billing"; + } + if (msg.includes("timeout") || msg.includes("timed out") || msg.includes("econnreset") || msg.includes("etimedout")) { + return "timeout"; + } + return "unknown"; +} + +/** Check if an error is potentially retryable via profile rotation */ +function isRotatableError(reason: AuthProfileFailureReason): boolean { + return reason === "auth" || reason === "rate_limit" || reason === "billing"; +} export class Agent { private readonly agent: PiAgentCore; @@ -23,24 +65,67 @@ export class Agent { private readonly skillManager?: SkillManager; private readonly contextWindowGuard: ContextWindowGuardResult; private readonly debug: boolean; + private readonly stderr: NodeJS.WritableStream; + + // Auth profile rotation state + private readonly resolvedProvider: string; + private currentApiKey: string | undefined; + private currentProfileId: string | undefined; + private profileCandidates: string[]; + private profileIndex: number; + private readonly pinnedProfile: boolean; /** Current session ID */ readonly sessionId: string; constructor(options: AgentOptions = {}) { const stdout = options.logger?.stdout ?? process.stdout; - const stderr = options.logger?.stderr ?? process.stderr; - this.output = createAgentOutput({ stdout, stderr }); + this.stderr = options.logger?.stderr ?? process.stderr; + this.output = createAgentOutput({ stdout, stderr: this.stderr }); this.debug = options.debug ?? false; // Resolve provider and model from options > env vars > defaults - const resolvedProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; - const resolvedModel = resolveModelId(resolvedProvider, options.model); - const apiKey = resolveApiKey(resolvedProvider, options.apiKey); + this.resolvedProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; + const resolvedModel = resolveModelId(this.resolvedProvider, options.model); + + // === Auth profile resolution === + this.pinnedProfile = !!(options.authProfileId || options.apiKey); + + if (options.apiKey) { + // Explicit API key — no rotation + this.currentApiKey = options.apiKey; + this.currentProfileId = this.resolvedProvider; + this.profileCandidates = []; + this.profileIndex = 0; + } else if (options.authProfileId) { + // Pinned profile — no rotation + this.currentApiKey = resolveApiKeyForProfile(options.authProfileId) + ?? resolveApiKey(this.resolvedProvider); + this.currentProfileId = options.authProfileId; + this.profileCandidates = []; + this.profileIndex = 0; + } else { + // Profile-aware resolution with rotation support + const resolved = resolveApiKeyForProvider(this.resolvedProvider); + if (resolved) { + this.currentApiKey = resolved.apiKey; + this.currentProfileId = resolved.profileId; + } else { + this.currentApiKey = undefined; + this.currentProfileId = undefined; + } + + // Load full candidate list for rotation + const store = loadAuthProfileStore(); + this.profileCandidates = resolveAuthProfileOrder(this.resolvedProvider, store); + this.profileIndex = this.currentProfileId + ? Math.max(0, this.profileCandidates.indexOf(this.currentProfileId)) + : 0; + } this.agent = new PiAgentCore( - apiKey - ? { getApiKey: (_provider: string) => apiKey } + this.currentApiKey + ? { getApiKey: (_provider: string) => this.currentApiKey! } : {}, ); @@ -86,7 +171,7 @@ export class Agent { return tempSession.getMeta(); })(); - const effectiveProvider = resolvedModel ? resolvedProvider : (options.provider ?? storedMeta?.provider); + const effectiveProvider = resolvedModel ? this.resolvedProvider : (options.provider ?? storedMeta?.provider); const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model; let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel }); @@ -112,7 +197,7 @@ export class Agent { // 警告:context window 较小 if (this.contextWindowGuard.shouldWarn) { - stderr.write( + this.stderr.write( `[Context Window Guard] WARNING: Low context window: ${this.contextWindowGuard.tokens} tokens (source: ${this.contextWindowGuard.source})\n`, ); } @@ -130,7 +215,7 @@ export class Agent { // 获取 API Key(用于 summary 模式) const summaryApiKey = compactionMode === "summary" - ? resolveApiKey(resolvedProvider, options.apiKey) + ? resolveApiKey(this.resolvedProvider, options.apiKey) : undefined; // 创建 SessionManager(带 context window 配置) @@ -208,6 +293,10 @@ export class Agent { this.output.handleEvent(event); this.handleSessionEvent(event); }); + + if (this.debug && this.currentProfileId) { + console.error(`[debug] Auth profile: ${this.currentProfileId} (pinned=${this.pinnedProfile}, candidates=${this.profileCandidates.length})`); + } } /** Subscribe to raw AgentEvent from the underlying engine */ @@ -217,10 +306,81 @@ export class Agent { async run(prompt: string): Promise { this.output.state.lastAssistantText = ""; - await this.agent.prompt(prompt); + + try { + await this.agent.prompt(prompt); + } catch (error) { + // Attempt auth profile rotation on retryable errors + if (!this.pinnedProfile && this.profileCandidates.length > 1 && this.currentProfileId) { + const reason = classifyError(error); + if (isRotatableError(reason)) { + markAuthProfileFailure(this.currentProfileId, reason); + + if (this.debug) { + this.stderr.write( + `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, + ); + } + + if (this.advanceAuthProfile()) { + if (this.debug) { + this.stderr.write( + `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, + ); + } + // Retry with new profile + this.output.state.lastAssistantText = ""; + await this.agent.prompt(prompt); + } else { + throw error; // No more profiles to try + } + } else { + throw error; // Non-rotatable error + } + } else { + throw error; // Pinned profile or single profile + } + } + + // Mark success + if (this.currentProfileId) { + markAuthProfileUsed(this.currentProfileId); + markAuthProfileGood(this.resolvedProvider, this.currentProfileId); + } + return { text: this.output.state.lastAssistantText, error: this.agent.state.error }; } + /** + * Advance to the next non-cooldown auth profile. + * Returns true if a new profile was activated, false if exhausted. + */ + private advanceAuthProfile(): boolean { + const store = loadAuthProfileStore(); + const startIndex = this.profileIndex; + + for (let i = 1; i < this.profileCandidates.length; i++) { + const nextIndex = (startIndex + i) % this.profileCandidates.length; + const candidateId = this.profileCandidates[nextIndex] as string | undefined; + if (!candidateId) continue; + + // Skip profiles in cooldown + const stats = store.usageStats?.[candidateId]; + if (stats && isProfileInCooldown(stats)) continue; + + // Try to resolve API key + const apiKey = resolveApiKeyForProfile(candidateId); + if (!apiKey) continue; + + this.currentApiKey = apiKey; + this.currentProfileId = candidateId; + this.profileIndex = nextIndex; + return true; + } + + return false; + } + private handleSessionEvent(event: AgentEvent) { if (event.type === "message_end") { const message = event.message as AgentMessage; diff --git a/src/agent/types.ts b/src/agent/types.ts index 75e53ad1..c7e37658 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -21,6 +21,8 @@ export type AgentOptions = { model?: string | undefined; /** Custom API key (overrides environment variable) */ apiKey?: string | undefined; + /** Pin a specific auth profile ID (e.g. "anthropic:backup"). Disables rotation. */ + authProfileId?: string | undefined; /** Custom base URL for the provider endpoint */ baseUrl?: string | undefined; /** System prompt, if profileId is set will auto-construct from profile */ From a8c50425543d7ee02fb39996b085fc096542cc04 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 17:26:09 +0800 Subject: [PATCH 3/7] fix(auth-profiles): add file locking, type priority sort, credential filtering - Add proper-lockfile for concurrent-safe store updates with fallback - Add "format" to AuthProfileFailureReason - Two-level round-robin sort: credential type priority (OAuth > API key), then lastUsed - Filter out profiles with missing/invalid credentials from candidates - Add preferredProfile option to resolveAuthProfileOrder - Export coerceStore and ensureAuthStoreFile for testing - Add store.test.ts with coerceStore, load/save round-trip, corruption handling - Update order.test.ts mocks for resolver and registry dependencies Co-Authored-By: Claude Opus 4.5 --- package.json | 4 +- pnpm-lock.yaml | 142 ++++++++++++++++++++++---- src/agent/auth-profiles/index.ts | 3 + src/agent/auth-profiles/order.test.ts | 88 +++++++++++----- src/agent/auth-profiles/order.ts | 67 ++++++++++-- src/agent/auth-profiles/store.test.ts | 131 ++++++++++++++++++++++++ src/agent/auth-profiles/store.ts | 62 +++++++++-- src/agent/auth-profiles/types.ts | 1 + 8 files changed, 433 insertions(+), 65 deletions(-) create mode 100644 src/agent/auth-profiles/store.test.ts diff --git a/package.json b/package.json index 68fa350c..d0952bbf 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "packageManager": "pnpm@10.28.2", "devDependencies": { "@types/node": "catalog:", + "@types/proper-lockfile": "^4.1.4", "@types/turndown": "^5.0.6", "@types/uuid": "^11.0.0", "@vitest/coverage-v8": "^4.0.18", @@ -42,11 +43,11 @@ "vitest": "^4.0.18" }, "dependencies": { - "@multica/sdk": "workspace:*", "@mariozechner/pi-agent-core": "^0.50.3", "@mariozechner/pi-ai": "^0.50.3", "@mariozechner/pi-coding-agent": "^0.50.3", "@mozilla/readability": "^0.6.0", + "@multica/sdk": "workspace:*", "@nestjs/common": "^11.1.12", "@nestjs/core": "^11.1.12", "@nestjs/platform-express": "^11.1.12", @@ -61,6 +62,7 @@ "pino": "^10.3.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", + "proper-lockfile": "^4.1.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "socket.io": "^4.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a71a43c2..5d0fc218 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,13 +34,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -89,6 +89,9 @@ importers: pino-pretty: specifier: ^13.1.3 version: 13.1.3 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -117,6 +120,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.0.10 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/turndown': specifier: ^5.0.6 version: 5.0.6 @@ -1200,89 +1206,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1404,24 +1426,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.0': resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.0': resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.0': resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.0': resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==} @@ -1583,24 +1609,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -1700,66 +1730,79 @@ packages: resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.0': resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.0': resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.0': resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.0': resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.0': resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.0': resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.0': resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.0': resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.0': resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.0': resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.0': resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.0': resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.0': resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} @@ -2075,24 +2118,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2222,6 +2269,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2233,6 +2283,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -2417,41 +2470,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4442,24 +4503,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6441,11 +6506,11 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/sdk@0.71.2(zod@3.25.76)': + '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 3.25.76 + zod: 4.3.6 '@aws-crypto/crc32@5.2.0': dependencies: @@ -7468,12 +7533,12 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))': + '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))': dependencies: google-auth-library: 10.5.0 ws: 8.18.3 optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6) transitivePeerDependencies: - bufferutil - supports-color @@ -7728,9 +7793,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -7741,21 +7806,21 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) + '@anthropic-ai/sdk': 0.71.2(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.978.0 - '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76)) + '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)) '@mistralai/mistralai': 1.10.0 '@sinclair/typebox': 0.34.48 ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) chalk: 5.6.2 - openai: 6.10.0(ws@8.18.3)(zod@3.25.76) + openai: 6.10.0(ws@8.18.3)(zod@4.3.6) partial-json: 0.1.7 proxy-agent: 6.5.0 undici: 7.19.2 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -7765,12 +7830,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -7828,6 +7893,29 @@ snapshots: - hono - supports-color + '@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.7) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - hono + - supports-color + optional: true + '@mozilla/readability@0.6.0': {} '@mswjs/interceptors@0.40.0': @@ -8616,6 +8704,10 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/react-dom@19.2.3(@types/react@19.2.10)': dependencies: '@types/react': 19.2.10 @@ -8628,6 +8720,8 @@ snapshots: dependencies: '@types/node': 25.0.10 + '@types/retry@0.12.5': {} + '@types/statuses@2.0.6': {} '@types/turndown@5.0.6': {} @@ -10031,7 +10125,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10064,7 +10158,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -10079,7 +10173,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -12067,10 +12161,10 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 - openai@6.10.0(ws@8.18.3)(zod@3.25.76): + openai@6.10.0(ws@8.18.3)(zod@4.3.6): optionalDependencies: ws: 8.18.3 - zod: 3.25.76 + zod: 4.3.6 optionator@0.9.4: dependencies: @@ -13702,6 +13796,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/src/agent/auth-profiles/index.ts b/src/agent/auth-profiles/index.ts index bd87cbb7..9458ea55 100644 --- a/src/agent/auth-profiles/index.ts +++ b/src/agent/auth-profiles/index.ts @@ -22,6 +22,8 @@ export { export { resolveAuthStorePath, + coerceStore, + ensureAuthStoreFile, loadAuthProfileStore, saveAuthProfileStore, updateAuthProfileStore, @@ -30,6 +32,7 @@ export { export { listProfilesForProvider, resolveAuthProfileOrder, + type AuthProfileOrderOptions, } from "./order.js"; export { diff --git a/src/agent/auth-profiles/order.test.ts b/src/agent/auth-profiles/order.test.ts index dd729719..905724d8 100644 --- a/src/agent/auth-profiles/order.test.ts +++ b/src/agent/auth-profiles/order.test.ts @@ -2,11 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { resolveAuthProfileOrder, listProfilesForProvider } from "./order.js"; import type { AuthProfileStore } from "./types.js"; +// Track mock profiles for credential validation +let _profiles: Record = {}; +let _order: Record = {}; + // Mock credentialManager vi.mock("../credentials.js", () => { - let _profiles: Record = {}; - let _order: Record = {}; - return { credentialManager: { listProfileIdsForProvider(provider: string): string[] { @@ -17,27 +18,33 @@ vi.mock("../credentials.js", () => { getLlmOrder(provider: string): string[] | undefined { return _order[provider]; }, - // Test helpers - __setProfiles(profiles: Record) { - _profiles = profiles; - }, - __setOrder(order: Record) { - _order = order; + getLlmProviderConfig(profileId: string): { apiKey?: string } | undefined { + return _profiles[profileId]; }, }, }; }); -// Import the mock to use test helpers -import { credentialManager } from "../credentials.js"; -const mock = credentialManager as unknown as { - __setProfiles: (p: Record) => void; - __setOrder: (o: Record) => void; -}; +// Mock providers/registry — all test profiles are API-key based +vi.mock("../providers/registry.js", () => ({ + isOAuthProvider: (_provider: string) => false, +})); + +// Mock providers/resolver — delegate to our mock profiles +vi.mock("../providers/resolver.js", () => ({ + resolveApiKeyForProfile: (profileId: string) => _profiles[profileId]?.apiKey, +})); + +function setProfiles(profiles: Record) { + _profiles = profiles; +} +function setOrder(order: Record) { + _order = order; +} beforeEach(() => { - mock.__setProfiles({}); - mock.__setOrder({}); + _profiles = {}; + _order = {}; }); // ============================================================ @@ -46,7 +53,7 @@ beforeEach(() => { describe("listProfilesForProvider", () => { it("returns profiles matching the provider", () => { - mock.__setProfiles({ + setProfiles({ anthropic: { apiKey: "sk-1" }, "anthropic:backup": { apiKey: "sk-2" }, openai: { apiKey: "sk-3" }, @@ -58,7 +65,7 @@ describe("listProfilesForProvider", () => { }); it("returns empty array when no profiles match", () => { - mock.__setProfiles({ openai: { apiKey: "sk-1" } }); + setProfiles({ openai: { apiKey: "sk-1" } }); expect(listProfilesForProvider("anthropic")).toEqual([]); }); }); @@ -71,7 +78,7 @@ describe("resolveAuthProfileOrder", () => { const now = 1_000_000; it("returns round-robin order by lastUsed when no explicit order", () => { - mock.__setProfiles({ + setProfiles({ "anthropic": { apiKey: "sk-1" }, "anthropic:b": { apiKey: "sk-2" }, "anthropic:c": { apiKey: "sk-3" }, @@ -91,12 +98,12 @@ describe("resolveAuthProfileOrder", () => { }); it("respects explicit order from config", () => { - mock.__setProfiles({ + setProfiles({ "anthropic": { apiKey: "sk-1" }, "anthropic:b": { apiKey: "sk-2" }, "anthropic:c": { apiKey: "sk-3" }, }); - mock.__setOrder({ anthropic: ["anthropic:c", "anthropic", "anthropic:b"] }); + setOrder({ anthropic: ["anthropic:c", "anthropic", "anthropic:b"] }); const store: AuthProfileStore = { version: 1 }; const order = resolveAuthProfileOrder("anthropic", store, now); @@ -104,7 +111,7 @@ describe("resolveAuthProfileOrder", () => { }); it("pushes cooldown profiles to the end", () => { - mock.__setProfiles({ + setProfiles({ "anthropic": { apiKey: "sk-1" }, "anthropic:b": { apiKey: "sk-2" }, "anthropic:c": { apiKey: "sk-3" }, @@ -124,7 +131,7 @@ describe("resolveAuthProfileOrder", () => { }); it("sorts cooldown profiles by earliest recovery", () => { - mock.__setProfiles({ + setProfiles({ "anthropic": { apiKey: "sk-1" }, "anthropic:b": { apiKey: "sk-2" }, "anthropic:c": { apiKey: "sk-3" }, @@ -144,12 +151,12 @@ describe("resolveAuthProfileOrder", () => { }); it("deduplicates profile IDs", () => { - mock.__setProfiles({ + setProfiles({ "anthropic": { apiKey: "sk-1" }, "anthropic:b": { apiKey: "sk-2" }, }); // Explicit order has duplicate - mock.__setOrder({ anthropic: ["anthropic", "anthropic", "anthropic:b"] }); + setOrder({ anthropic: ["anthropic", "anthropic", "anthropic:b"] }); const store: AuthProfileStore = { version: 1 }; const order = resolveAuthProfileOrder("anthropic", store, now); @@ -157,13 +164,13 @@ describe("resolveAuthProfileOrder", () => { }); it("appends unlisted profiles to explicit order", () => { - mock.__setProfiles({ + setProfiles({ "anthropic": { apiKey: "sk-1" }, "anthropic:b": { apiKey: "sk-2" }, "anthropic:c": { apiKey: "sk-3" }, }); // Only lists one profile in explicit order - mock.__setOrder({ anthropic: ["anthropic:b"] }); + setOrder({ anthropic: ["anthropic:b"] }); const store: AuthProfileStore = { version: 1 }; const order = resolveAuthProfileOrder("anthropic", store, now); @@ -173,4 +180,29 @@ describe("resolveAuthProfileOrder", () => { expect(order).toContain("anthropic"); expect(order).toContain("anthropic:c"); }); + + it("filters out profiles with no valid API key", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:empty": {}, // no apiKey + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now); + expect(order).toEqual(["anthropic", "anthropic:c"]); + }); + + it("moves preferredProfile to front", () => { + setProfiles({ + "anthropic": { apiKey: "sk-1" }, + "anthropic:b": { apiKey: "sk-2" }, + "anthropic:c": { apiKey: "sk-3" }, + }); + const store: AuthProfileStore = { version: 1 }; + const order = resolveAuthProfileOrder("anthropic", store, now, { + preferredProfile: "anthropic:c", + }); + expect(order[0]).toBe("anthropic:c"); + expect(order).toHaveLength(3); + }); }); diff --git a/src/agent/auth-profiles/order.ts b/src/agent/auth-profiles/order.ts index 16055e81..09c99a32 100644 --- a/src/agent/auth-profiles/order.ts +++ b/src/agent/auth-profiles/order.ts @@ -3,11 +3,14 @@ * * Determines the order in which auth profiles are tried for a given provider. * Supports explicit ordering (from credentials.json5) and automatic round-robin - * based on lastUsed time. Profiles in cooldown are pushed to the end. + * with two-level sort: credential type priority (OAuth > API key), then lastUsed. + * Profiles in cooldown are pushed to the end. */ import { credentialManager } from "../credentials.js"; -import type { AuthProfileStore, ProfileUsageStats } from "./types.js"; +import { isOAuthProvider } from "../providers/registry.js"; +import { resolveApiKeyForProfile } from "../providers/resolver.js"; +import type { AuthProfileStore } from "./types.js"; import { isProfileInCooldown, resolveProfileUnusableUntil } from "./usage.js"; // ============================================================ @@ -22,24 +25,51 @@ export function listProfilesForProvider(provider: string): string[] { return credentialManager.listProfileIdsForProvider(provider); } +// ============================================================ +// Type priority +// ============================================================ + +/** + * Get the type-based priority for a profile. + * OAuth providers (e.g. claude-code, openai-codex) get priority 0 (preferred), + * API-key providers get priority 1. + * Lower number = higher priority. + */ +function getProfileTypePriority(profileId: string): number { + // Extract the provider portion from profileId (before ":" if present) + const provider = profileId.includes(":") ? profileId.split(":")[0]! : profileId; + return isOAuthProvider(provider) ? 0 : 1; +} + // ============================================================ // Ordering // ============================================================ +export interface AuthProfileOrderOptions { + /** Preferred profile to put first (used when user or agent selects a profile) */ + preferredProfile?: string | undefined; +} + /** * Resolve the ordered list of profile IDs to try for a given provider. * * Strategy: * 1. If credentials.json5 has `llm.order[provider]`, use that explicit order. - * 2. Otherwise, use round-robin ordered by `lastUsed` ascending (oldest first). + * 2. Otherwise, use round-robin with two-level sort: + * - First by credential type priority (OAuth > API key) + * - Then by `lastUsed` ascending within each type (oldest first) * - * In both cases, profiles currently in cooldown are pushed to the end, - * sorted by earliest cooldown expiry (soonest-to-recover first). + * In both cases: + * - Profiles with invalid/missing credentials are filtered out + * - Profiles currently in cooldown are pushed to the end, + * sorted by earliest cooldown expiry (soonest-to-recover first) + * - If `preferredProfile` is set, it is moved to the front */ export function resolveAuthProfileOrder( provider: string, store: AuthProfileStore, now?: number, + options?: AuthProfileOrderOptions, ): string[] { const ts = now ?? Date.now(); @@ -59,8 +89,11 @@ export function resolveAuthProfileOrder( } } } else { - // Round-robin by lastUsed (oldest first) + // Two-level sort: type priority first, then lastUsed within same type candidates = [...allProfiles].sort((a, b) => { + const priorityDiff = getProfileTypePriority(a) - getProfileTypePriority(b); + if (priorityDiff !== 0) return priorityDiff; + const statsA = store.usageStats?.[a]; const statsB = store.usageStats?.[b]; return (statsA?.lastUsed ?? 0) - (statsB?.lastUsed ?? 0); @@ -70,6 +103,16 @@ export function resolveAuthProfileOrder( // Deduplicate candidates = [...new Set(candidates)]; + // Filter out profiles with invalid/missing credentials + candidates = candidates.filter((id) => { + // For OAuth providers, resolveApiKeyForProfile won't find them in credentials.json5 + // but they are still valid candidates (resolved at runtime via OAuth flow) + const provider = id.includes(":") ? id.split(":")[0]! : id; + if (isOAuthProvider(provider)) return true; + + return resolveApiKeyForProfile(id) !== undefined; + }); + // Partition into available and in-cooldown const available: string[] = []; const inCooldown: string[] = []; @@ -90,5 +133,15 @@ export function resolveAuthProfileOrder( return resolveProfileUnusableUntil(statsA) - resolveProfileUnusableUntil(statsB); }); - return [...available, ...inCooldown]; + let result = [...available, ...inCooldown]; + + // Move preferred profile to front if specified + if (options?.preferredProfile && result.includes(options.preferredProfile)) { + result = [ + options.preferredProfile, + ...result.filter((id) => id !== options.preferredProfile), + ]; + } + + return result; } diff --git a/src/agent/auth-profiles/store.test.ts b/src/agent/auth-profiles/store.test.ts new file mode 100644 index 00000000..4e2d78f5 --- /dev/null +++ b/src/agent/auth-profiles/store.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { coerceStore, loadAuthProfileStore, saveAuthProfileStore, updateAuthProfileStore } from "./store.js"; +import { AUTH_STORE_VERSION } from "./constants.js"; +import type { AuthProfileStore } from "./types.js"; + +// Use a temp directory for tests to avoid touching real store +const TEST_DIR = join(import.meta.dirname ?? ".", "__test_store_tmp__"); +const TEST_STORE_PATH = join(TEST_DIR, "auth-profiles.json"); + +// We need to mock resolveAuthStorePath to point to our test dir +import { vi } from "vitest"; + +vi.mock("../../shared/paths.js", () => ({ + DATA_DIR: join(import.meta.dirname ?? ".", "__test_store_tmp__"), +})); + +beforeEach(() => { + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }); + } +}); + +afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +// ============================================================ +// coerceStore +// ============================================================ + +describe("coerceStore", () => { + it("returns empty store for null", () => { + const store = coerceStore(null); + expect(store.version).toBe(AUTH_STORE_VERSION); + expect(store.lastGood).toBeUndefined(); + expect(store.usageStats).toBeUndefined(); + }); + + it("returns empty store for non-object", () => { + expect(coerceStore("hello").version).toBe(AUTH_STORE_VERSION); + expect(coerceStore(42).version).toBe(AUTH_STORE_VERSION); + expect(coerceStore(undefined).version).toBe(AUTH_STORE_VERSION); + }); + + it("preserves valid store data", () => { + const raw = { + version: 1, + lastGood: { anthropic: "anthropic:backup" }, + usageStats: { + "anthropic": { lastUsed: 1000, errorCount: 0 }, + }, + }; + const store = coerceStore(raw); + expect(store.version).toBe(1); + expect(store.lastGood?.anthropic).toBe("anthropic:backup"); + expect(store.usageStats?.anthropic?.lastUsed).toBe(1000); + }); + + it("defaults version when missing", () => { + const store = coerceStore({ lastGood: {} }); + expect(store.version).toBe(AUTH_STORE_VERSION); + }); +}); + +// ============================================================ +// loadAuthProfileStore / saveAuthProfileStore +// ============================================================ + +describe("loadAuthProfileStore / saveAuthProfileStore", () => { + it("returns empty store when file does not exist", () => { + const store = loadAuthProfileStore(); + expect(store.version).toBe(AUTH_STORE_VERSION); + }); + + it("round-trips save and load", () => { + const original: AuthProfileStore = { + version: 1, + lastGood: { anthropic: "anthropic:main" }, + usageStats: { + "anthropic:main": { lastUsed: 5000, errorCount: 1 }, + }, + }; + saveAuthProfileStore(original); + const loaded = loadAuthProfileStore(); + expect(loaded).toEqual(original); + }); + + it("handles corrupted JSON gracefully", () => { + writeFileSync(TEST_STORE_PATH, "not valid json{{{", "utf8"); + const store = loadAuthProfileStore(); + expect(store.version).toBe(AUTH_STORE_VERSION); + }); +}); + +// ============================================================ +// updateAuthProfileStore +// ============================================================ + +describe("updateAuthProfileStore", () => { + it("creates file and applies update when file does not exist", () => { + const result = updateAuthProfileStore((store) => { + if (!store.lastGood) store.lastGood = {}; + store.lastGood.openai = "openai:primary"; + }); + expect(result.lastGood?.openai).toBe("openai:primary"); + + // Verify persisted + const loaded = loadAuthProfileStore(); + expect(loaded.lastGood?.openai).toBe("openai:primary"); + }); + + it("preserves existing data across updates", () => { + saveAuthProfileStore({ + version: 1, + lastGood: { anthropic: "anthropic" }, + }); + + updateAuthProfileStore((store) => { + if (!store.usageStats) store.usageStats = {}; + store.usageStats["anthropic"] = { lastUsed: 9999 }; + }); + + const loaded = loadAuthProfileStore(); + expect(loaded.lastGood?.anthropic).toBe("anthropic"); + expect(loaded.usageStats?.anthropic?.lastUsed).toBe(9999); + }); +}); diff --git a/src/agent/auth-profiles/store.ts b/src/agent/auth-profiles/store.ts index 3bb0480f..1d0ab0ca 100644 --- a/src/agent/auth-profiles/store.ts +++ b/src/agent/auth-profiles/store.ts @@ -3,14 +3,31 @@ * * Persistence layer for auth profile runtime state. * Stores usage stats, cooldowns, and last-good info in ~/.super-multica/auth-profiles.json. + * Uses proper-lockfile for safe concurrent access across multiple agent processes. */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; +import lockfile from "proper-lockfile"; import { DATA_DIR } from "../../shared/paths.js"; import { AUTH_STORE_VERSION, AUTH_PROFILE_STORE_FILENAME } from "./constants.js"; import type { AuthProfileStore } from "./types.js"; +// ============================================================ +// Lock options (matches OpenClaw's AUTH_STORE_LOCK_OPTIONS) +// ============================================================ + +const LOCK_OPTIONS = { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10_000, + randomize: true, + }, + stale: 30_000, +} as const; + // ============================================================ // Paths // ============================================================ @@ -28,7 +45,8 @@ function createEmptyStore(): AuthProfileStore { return { version: AUTH_STORE_VERSION }; } -function coerceStore(raw: unknown): AuthProfileStore { +/** Coerce raw JSON into a valid AuthProfileStore, defensive against malformed data */ +export function coerceStore(raw: unknown): AuthProfileStore { if (!raw || typeof raw !== "object") return createEmptyStore(); const obj = raw as Record; @@ -46,6 +64,19 @@ function coerceStore(raw: unknown): AuthProfileStore { return store; } +/** Ensure the store file exists on disk (creates it if missing) */ +export function ensureAuthStoreFile(): string { + const storePath = resolveAuthStorePath(); + const dir = dirname(storePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!existsSync(storePath)) { + writeFileSync(storePath, JSON.stringify(createEmptyStore(), null, 2), "utf8"); + } + return storePath; +} + /** Load auth profile store from disk. Returns empty store if file doesn't exist. */ export function loadAuthProfileStore(): AuthProfileStore { const storePath = resolveAuthStorePath(); @@ -70,15 +101,32 @@ export function saveAuthProfileStore(store: AuthProfileStore): void { } /** - * Atomic load-update-save cycle. - * The updater receives the current store and should mutate it in place. + * Atomic load-update-save cycle with file locking. + * Acquires a lock on the store file, loads current state, runs the updater, + * and saves. Falls back to unlocked update if the lock cannot be acquired. * Returns the updated store. */ export function updateAuthProfileStore( updater: (store: AuthProfileStore) => void, ): AuthProfileStore { - const store = loadAuthProfileStore(); - updater(store); - saveAuthProfileStore(store); - return store; + const storePath = ensureAuthStoreFile(); + + try { + // Acquire file lock + const release = lockfile.lockSync(storePath, LOCK_OPTIONS); + try { + const store = loadAuthProfileStore(); + updater(store); + saveAuthProfileStore(store); + return store; + } finally { + release(); + } + } catch { + // Fallback: unlocked update (better than losing the write entirely) + const store = loadAuthProfileStore(); + updater(store); + saveAuthProfileStore(store); + return store; + } } diff --git a/src/agent/auth-profiles/types.ts b/src/agent/auth-profiles/types.ts index e4e34eb2..036341be 100644 --- a/src/agent/auth-profiles/types.ts +++ b/src/agent/auth-profiles/types.ts @@ -7,6 +7,7 @@ /** Reason for an auth profile failure, determines cooldown behavior */ export type AuthProfileFailureReason = | "auth" + | "format" | "rate_limit" | "billing" | "timeout" From e8815dbb97041a829e042da11e57d1db377b902b Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 17:26:16 +0800 Subject: [PATCH 4/7] fix(agent): loop-based rotation, timeout rotatable, error classification tests - Replace single-retry with while loop that exhausts all candidate profiles - Add "format" detection to classifyError (400, malformed, bad request, schema) - Make timeout errors rotatable (some providers hang on rate limit) - Export classifyError and isRotatableError for testing - Add error-classification.test.ts with coverage for all failure reasons Co-Authored-By: Claude Opus 4.5 --- .../error-classification.test.ts | 65 +++++++++++++++++ src/agent/runner.ts | 72 +++++++++++-------- 2 files changed, 106 insertions(+), 31 deletions(-) create mode 100644 src/agent/auth-profiles/error-classification.test.ts diff --git a/src/agent/auth-profiles/error-classification.test.ts b/src/agent/auth-profiles/error-classification.test.ts new file mode 100644 index 00000000..c771c6b4 --- /dev/null +++ b/src/agent/auth-profiles/error-classification.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { classifyError, isRotatableError } from "../runner.js"; + +// ============================================================ +// classifyError +// ============================================================ + +describe("classifyError", () => { + it("classifies 401/403/unauthorized as auth", () => { + expect(classifyError(new Error("HTTP 401 Unauthorized"))).toBe("auth"); + expect(classifyError(new Error("403 Forbidden"))).toBe("auth"); + expect(classifyError(new Error("Invalid API key provided"))).toBe("auth"); + expect(classifyError(new Error("Authentication failed"))).toBe("auth"); + }); + + it("classifies 400/malformed as format", () => { + expect(classifyError(new Error("400 Bad Request"))).toBe("format"); + expect(classifyError(new Error("Invalid request body"))).toBe("format"); + expect(classifyError(new Error("Malformed JSON in request"))).toBe("format"); + expect(classifyError(new Error("Schema validation failed"))).toBe("format"); + }); + + it("classifies 429/rate limit as rate_limit", () => { + expect(classifyError(new Error("429 Too Many Requests"))).toBe("rate_limit"); + expect(classifyError(new Error("Rate limit exceeded"))).toBe("rate_limit"); + expect(classifyError(new Error("rate_limit_error"))).toBe("rate_limit"); + }); + + it("classifies billing/quota as billing", () => { + expect(classifyError(new Error("Billing quota exceeded"))).toBe("billing"); + expect(classifyError(new Error("Insufficient credits"))).toBe("billing"); + expect(classifyError(new Error("Payment required"))).toBe("billing"); + }); + + it("classifies timeout/connection errors as timeout", () => { + expect(classifyError(new Error("Request timed out"))).toBe("timeout"); + expect(classifyError(new Error("ETIMEDOUT"))).toBe("timeout"); + expect(classifyError(new Error("ECONNRESET"))).toBe("timeout"); + expect(classifyError(new Error("Connection timeout"))).toBe("timeout"); + }); + + it("classifies unknown errors as unknown", () => { + expect(classifyError(new Error("Something went wrong"))).toBe("unknown"); + expect(classifyError("string error")).toBe("unknown"); + expect(classifyError(42)).toBe("unknown"); + }); +}); + +// ============================================================ +// isRotatableError +// ============================================================ + +describe("isRotatableError", () => { + it("considers auth, rate_limit, billing, timeout as rotatable", () => { + expect(isRotatableError("auth")).toBe(true); + expect(isRotatableError("rate_limit")).toBe(true); + expect(isRotatableError("billing")).toBe(true); + expect(isRotatableError("timeout")).toBe(true); + }); + + it("does not rotate on format or unknown errors", () => { + expect(isRotatableError("format")).toBe(false); + expect(isRotatableError("unknown")).toBe(false); + }); +}); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 695a0467..7605b27a 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -34,12 +34,16 @@ import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; // Error classification for auth profile rotation // ============================================================ -function classifyError(error: unknown): AuthProfileFailureReason { +/** Classify an error into an auth profile failure reason */ +export function classifyError(error: unknown): AuthProfileFailureReason { const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); if (msg.includes("401") || msg.includes("403") || msg.includes("unauthorized") || msg.includes("invalid api key") || msg.includes("authentication")) { return "auth"; } + if (msg.includes("400") || msg.includes("invalid request") || msg.includes("malformed") || msg.includes("bad request") || msg.includes("schema")) { + return "format"; + } if (msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests")) { return "rate_limit"; } @@ -53,8 +57,9 @@ function classifyError(error: unknown): AuthProfileFailureReason { } /** Check if an error is potentially retryable via profile rotation */ -function isRotatableError(reason: AuthProfileFailureReason): boolean { - return reason === "auth" || reason === "rate_limit" || reason === "billing"; +export function isRotatableError(reason: AuthProfileFailureReason): boolean { + // timeout is rotatable because some providers hang on rate limit instead of returning 429 + return reason === "auth" || reason === "rate_limit" || reason === "billing" || reason === "timeout"; } export class Agent { @@ -307,38 +312,43 @@ export class Agent { async run(prompt: string): Promise { this.output.state.lastAssistantText = ""; - try { - await this.agent.prompt(prompt); - } catch (error) { - // Attempt auth profile rotation on retryable errors - if (!this.pinnedProfile && this.profileCandidates.length > 1 && this.currentProfileId) { + const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; + let lastError: unknown; + + // Loop to exhaust all candidate profiles on rotatable errors + while (true) { + try { + await this.agent.prompt(prompt); + break; // success — exit loop + } catch (error) { + lastError = error; + + if (!canRotate || !this.currentProfileId) throw error; + const reason = classifyError(error); - if (isRotatableError(reason)) { - markAuthProfileFailure(this.currentProfileId, reason); + if (!isRotatableError(reason)) throw error; - if (this.debug) { - this.stderr.write( - `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, - ); - } + markAuthProfileFailure(this.currentProfileId, reason); - if (this.advanceAuthProfile()) { - if (this.debug) { - this.stderr.write( - `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, - ); - } - // Retry with new profile - this.output.state.lastAssistantText = ""; - await this.agent.prompt(prompt); - } else { - throw error; // No more profiles to try - } - } else { - throw error; // Non-rotatable error + if (this.debug) { + this.stderr.write( + `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, + ); } - } else { - throw error; // Pinned profile or single profile + + if (!this.advanceAuthProfile()) { + throw lastError; // All profiles exhausted + } + + if (this.debug) { + this.stderr.write( + `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, + ); + } + + // Reset output for retry + this.output.state.lastAssistantText = ""; + // continue loop with new profile } } From 617ddfbfeab36adebeea316a27e75899f287bf4a Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 18:05:00 +0800 Subject: [PATCH 5/7] refactor(auth-profiles): replace proper-lockfile with custom file lock Unify locking strategy across the project by using a custom synchronous file lock (exclusive-create based, with PID stale detection) instead of the proper-lockfile dependency, matching the pattern in session-write-lock. Co-Authored-By: Claude Opus 4.5 --- package.json | 2 - pnpm-lock.yaml | 33 --------- src/agent/auth-profiles/store.ts | 114 ++++++++++++++++++++++++++----- 3 files changed, 98 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index d0952bbf..fe1d242a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "packageManager": "pnpm@10.28.2", "devDependencies": { "@types/node": "catalog:", - "@types/proper-lockfile": "^4.1.4", "@types/turndown": "^5.0.6", "@types/uuid": "^11.0.0", "@vitest/coverage-v8": "^4.0.18", @@ -62,7 +61,6 @@ "pino": "^10.3.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", - "proper-lockfile": "^4.1.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "socket.io": "^4.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 035c9790..7e74a4c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,24 +9,9 @@ catalogs: '@types/node': specifier: ^25.0.10 version: 25.0.10 - '@types/react': - specifier: ^19 - version: 19.2.10 - '@types/react-dom': - specifier: ^19 - version: 19.2.3 - react: - specifier: 19.2.3 - version: 19.2.3 - react-dom: - specifier: 19.2.3 - version: 19.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 - zustand: - specifier: ^5.0.0 - version: 5.0.10 importers: @@ -89,9 +74,6 @@ importers: pino-pretty: specifier: ^13.1.3 version: 13.1.3 - proper-lockfile: - specifier: ^4.1.2 - version: 4.1.2 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -120,9 +102,6 @@ importers: '@types/node': specifier: 'catalog:' version: 25.0.10 - '@types/proper-lockfile': - specifier: ^4.1.4 - version: 4.1.4 '@types/turndown': specifier: ^5.0.6 version: 5.0.6 @@ -3341,9 +3320,6 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} - '@types/proper-lockfile@4.1.4': - resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3358,9 +3334,6 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/retry@0.12.5': - resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} - '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -12578,10 +12551,6 @@ snapshots: xmlbuilder: 15.1.1 optional: true - '@types/proper-lockfile@4.1.4': - dependencies: - '@types/retry': 0.12.5 - '@types/react-dom@19.2.3(@types/react@19.1.17)': dependencies: '@types/react': 19.1.17 @@ -12603,8 +12572,6 @@ snapshots: dependencies: '@types/node': 25.0.10 - '@types/retry@0.12.5': {} - '@types/stack-utils@2.0.3': {} '@types/statuses@2.0.6': {} diff --git a/src/agent/auth-profiles/store.ts b/src/agent/auth-profiles/store.ts index 1d0ab0ca..f50f2788 100644 --- a/src/agent/auth-profiles/store.ts +++ b/src/agent/auth-profiles/store.ts @@ -3,30 +3,113 @@ * * Persistence layer for auth profile runtime state. * Stores usage stats, cooldowns, and last-good info in ~/.super-multica/auth-profiles.json. - * Uses proper-lockfile for safe concurrent access across multiple agent processes. + * Uses a custom file lock (exclusive-create based) for safe concurrent access. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + openSync, + closeSync, + rmSync, + statSync, + constants as fsConstants, +} from "node:fs"; import { join, dirname } from "node:path"; -import lockfile from "proper-lockfile"; import { DATA_DIR } from "../../shared/paths.js"; import { AUTH_STORE_VERSION, AUTH_PROFILE_STORE_FILENAME } from "./constants.js"; import type { AuthProfileStore } from "./types.js"; // ============================================================ -// Lock options (matches OpenClaw's AUTH_STORE_LOCK_OPTIONS) +// Custom file lock (synchronous, exclusive-create based) // ============================================================ -const LOCK_OPTIONS = { - retries: { - retries: 10, - factor: 2, - minTimeout: 100, - maxTimeout: 10_000, - randomize: true, - }, - stale: 30_000, -} as const; +const LOCK_STALE_MS = 30_000; +const LOCK_RETRY_COUNT = 10; +const LOCK_RETRY_BASE_MS = 50; +const LOCK_RETRY_MAX_MS = 1_000; + +type LockPayload = { pid: number; createdAt: string }; + +function isProcessAlive(pid: number): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function readLockPayloadSync(lockPath: string): LockPayload | null { + try { + const raw = readFileSync(lockPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string") return null; + return { pid: parsed.pid, createdAt: parsed.createdAt }; + } catch { + return null; + } +} + +function isLockStale(lockPath: string): boolean { + const payload = readLockPayloadSync(lockPath); + if (payload) { + const age = Date.now() - Date.parse(payload.createdAt); + if (!Number.isFinite(age) || age > LOCK_STALE_MS) return true; + return !isProcessAlive(payload.pid); + } + // No payload readable — check file mtime + try { + const stat = statSync(lockPath); + return Date.now() - stat.mtimeMs > LOCK_STALE_MS; + } catch { + return true; // Can't stat — treat as stale + } +} + +/** + * Acquire a synchronous exclusive file lock. + * Returns a release function. Throws if lock cannot be acquired after retries. + */ +function acquireLockSync(filePath: string): () => void { + const lockPath = `${filePath}.lock`; + const payload = JSON.stringify( + { pid: process.pid, createdAt: new Date().toISOString() }, + null, + 2, + ); + + for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt++) { + try { + // O_WRONLY | O_CREAT | O_EXCL — fails if file already exists + const fd = openSync(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL); + writeFileSync(fd, payload, "utf8"); + closeSync(fd); + return () => { + try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } + }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code !== "EEXIST") throw err; + + // Lock file exists — check if stale + if (isLockStale(lockPath)) { + try { rmSync(lockPath, { force: true }); } catch { /* ignore */ } + continue; + } + + // Wait and retry (synchronous busy-wait via Atomics for minimal overhead) + const delay = Math.min(LOCK_RETRY_MAX_MS, LOCK_RETRY_BASE_MS * (attempt + 1)); + const buf = new SharedArrayBuffer(4); + Atomics.wait(new Int32Array(buf), 0, 0, delay); + } + } + + throw new Error(`Failed to acquire lock after ${LOCK_RETRY_COUNT} retries: ${filePath}`); +} // ============================================================ // Paths @@ -112,8 +195,7 @@ export function updateAuthProfileStore( const storePath = ensureAuthStoreFile(); try { - // Acquire file lock - const release = lockfile.lockSync(storePath, LOCK_OPTIONS); + const release = acquireLockSync(storePath); try { const store = loadAuthProfileStore(); updater(store); From 9ed4cdf2b05391e000aeb2465eeb8ad95feebc36 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 18:23:18 +0800 Subject: [PATCH 6/7] fix(agent): handle auth profile failures and provider mismatch --- src/agent/runner.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 0ac3268e..cc49c567 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -91,7 +91,21 @@ export class Agent { this.debug = options.debug ?? false; // Resolve provider and model from options > env vars > defaults - this.resolvedProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; + const defaultProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; + if (options.authProfileId) { + const profileProvider = options.authProfileId.includes(":") + ? options.authProfileId.split(":")[0]! + : options.authProfileId; + if (options.provider && options.provider !== profileProvider) { + throw new Error( + `authProfileId provider mismatch: authProfileId="${options.authProfileId}" ` + + `does not match provider="${options.provider}"`, + ); + } + this.resolvedProvider = profileProvider; + } else { + this.resolvedProvider = defaultProvider; + } const resolvedModel = resolveModelId(this.resolvedProvider, options.model); // === Auth profile resolution === @@ -327,12 +341,13 @@ export class Agent { } catch (error) { lastError = error; - if (!canRotate || !this.currentProfileId) throw error; - const reason = classifyError(error); - if (!isRotatableError(reason)) throw error; + if (this.currentProfileId && isRotatableError(reason)) { + markAuthProfileFailure(this.currentProfileId, reason); + } - markAuthProfileFailure(this.currentProfileId, reason); + if (!canRotate || !this.currentProfileId) throw error; + if (!isRotatableError(reason)) throw error; if (this.debug) { this.stderr.write( From a065d5f64cff8d7dbdb3ce12969600defb388da3 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 18:27:21 +0800 Subject: [PATCH 7/7] fix(auth-profiles): add jittered cooldowns --- src/agent/auth-profiles/usage.test.ts | 39 ++++++++++++++++----------- src/agent/auth-profiles/usage.ts | 22 ++++++++++----- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/agent/auth-profiles/usage.test.ts b/src/agent/auth-profiles/usage.test.ts index daaeed8e..185f8adf 100644 --- a/src/agent/auth-profiles/usage.test.ts +++ b/src/agent/auth-profiles/usage.test.ts @@ -19,18 +19,24 @@ import type { ProfileUsageStats } from "./types.js"; describe("calculateCooldownMs", () => { it("applies exponential backoff with a 1h cap", () => { - expect(calculateCooldownMs(1)).toBe(60_000); // 1 min - expect(calculateCooldownMs(2)).toBe(5 * 60_000); // 5 min - expect(calculateCooldownMs(3)).toBe(25 * 60_000); // 25 min - expect(calculateCooldownMs(4)).toBe(60 * 60_000); // 1 hour (cap) - expect(calculateCooldownMs(5)).toBe(60 * 60_000); // 1 hour (cap) - expect(calculateCooldownMs(100)).toBe(60 * 60_000); // still capped + const max = () => 1; // equal-jitter max + expect(calculateCooldownMs(1, max)).toBe(60_000); // 1 min + expect(calculateCooldownMs(2, max)).toBe(5 * 60_000); // 5 min + expect(calculateCooldownMs(3, max)).toBe(25 * 60_000); // 25 min + expect(calculateCooldownMs(4, max)).toBe(60 * 60_000); // 1 hour (cap) + expect(calculateCooldownMs(5, max)).toBe(60 * 60_000); // 1 hour (cap) + expect(calculateCooldownMs(100, max)).toBe(60 * 60_000); // still capped }); it("returns 0 for errorCount <= 0", () => { expect(calculateCooldownMs(0)).toBe(0); expect(calculateCooldownMs(-1)).toBe(0); }); + + it("applies equal jitter with a 50% floor", () => { + const min = () => 0; + expect(calculateCooldownMs(1, min)).toBe(30_000); // 50% of 1 min + }); }); // ============================================================ @@ -40,11 +46,12 @@ describe("calculateCooldownMs", () => { describe("calculateBillingDisableMs", () => { it("applies exponential backoff with a 24h cap", () => { const h = 60 * 60 * 1000; - expect(calculateBillingDisableMs(1)).toBe(5 * h); // 5h - expect(calculateBillingDisableMs(2)).toBe(10 * h); // 10h - expect(calculateBillingDisableMs(3)).toBe(20 * h); // 20h - expect(calculateBillingDisableMs(4)).toBe(24 * h); // 24h (cap) - expect(calculateBillingDisableMs(5)).toBe(24 * h); // still capped + const max = () => 1; + expect(calculateBillingDisableMs(1, max)).toBe(5 * h); // 5h + expect(calculateBillingDisableMs(2, max)).toBe(10 * h); // 10h + expect(calculateBillingDisableMs(3, max)).toBe(20 * h); // 20h + expect(calculateBillingDisableMs(4, max)).toBe(24 * h); // 24h (cap) + expect(calculateBillingDisableMs(5, max)).toBe(24 * h); // still capped }); it("returns 0 for count <= 0", () => { @@ -94,7 +101,7 @@ describe("computeNextProfileUsageStats", () => { const now = 1_700_000_000_000; it("increments errorCount and sets cooldown for non-billing failure", () => { - const next = computeNextProfileUsageStats({}, "rate_limit", now); + const next = computeNextProfileUsageStats({}, "rate_limit", now, () => 1); expect(next.errorCount).toBe(1); expect(next.lastFailureAt).toBe(now); expect(next.cooldownUntil).toBe(now + COOLDOWN_BASE_MS); @@ -108,14 +115,14 @@ describe("computeNextProfileUsageStats", () => { lastFailureAt: now - 1000, failureCounts: { rate_limit: 2 }, }; - const next = computeNextProfileUsageStats(stats, "rate_limit", now); + const next = computeNextProfileUsageStats(stats, "rate_limit", now, () => 1); expect(next.errorCount).toBe(3); // Error 3 -> 25 min cooldown expect(next.cooldownUntil).toBe(now + 25 * 60_000); }); it("sets disabledUntil for billing failures (~5h by default)", () => { - const next = computeNextProfileUsageStats({}, "billing", now); + const next = computeNextProfileUsageStats({}, "billing", now, () => 1); expect(next.errorCount).toBe(1); expect(next.disabledUntil).toBe(now + 5 * 60 * 60 * 1000); expect(next.disabledReason).toBe("billing"); @@ -129,7 +136,7 @@ describe("computeNextProfileUsageStats", () => { lastFailureAt: oldFailure, failureCounts: { auth: 3, rate_limit: 2 }, }; - const next = computeNextProfileUsageStats(stats, "auth", now); + const next = computeNextProfileUsageStats(stats, "auth", now, () => 1); // Counters reset, so this is treated as error #1 expect(next.errorCount).toBe(1); expect(next.failureCounts?.auth).toBe(1); @@ -141,7 +148,7 @@ describe("computeNextProfileUsageStats", () => { errorCount: 10, lastFailureAt: now - 1000, }; - const next = computeNextProfileUsageStats(stats, "unknown", now); + const next = computeNextProfileUsageStats(stats, "unknown", now, () => 1); expect(next.cooldownUntil).toBe(now + COOLDOWN_MAX_MS); }); }); diff --git a/src/agent/auth-profiles/usage.ts b/src/agent/auth-profiles/usage.ts index 181c78de..5aad992b 100644 --- a/src/agent/auth-profiles/usage.ts +++ b/src/agent/auth-profiles/usage.ts @@ -44,10 +44,18 @@ export function isProfileInCooldown(stats: ProfileUsageStats, now?: number): boo * * Formula: min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * COOLDOWN_FACTOR ^ min(errorCount - 1, 3)) */ -export function calculateCooldownMs(errorCount: number): number { +function applyEqualJitter(baseMs: number, rng?: () => number): number { + if (baseMs <= 0) return 0; + const rand = Math.min(1, Math.max(0, (rng ?? Math.random)())); + const half = Math.floor(baseMs / 2); + return half + Math.floor(rand * (baseMs - half)); +} + +export function calculateCooldownMs(errorCount: number, rng?: () => number): number { if (errorCount <= 0) return 0; const exponent = Math.min(errorCount - 1, 3); - return Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * COOLDOWN_FACTOR ** exponent); + const base = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * COOLDOWN_FACTOR ** exponent); + return applyEqualJitter(base, rng); } /** @@ -56,13 +64,14 @@ export function calculateCooldownMs(errorCount: number): number { * * Formula: min(BILLING_MAX_HOURS, BILLING_BACKOFF_HOURS * 2 ^ (count - 1)) * hours_to_ms */ -export function calculateBillingDisableMs(billingFailCount: number): number { +export function calculateBillingDisableMs(billingFailCount: number, rng?: () => number): number { if (billingFailCount <= 0) return 0; const hours = Math.min( BILLING_MAX_HOURS, BILLING_BACKOFF_HOURS * 2 ** (billingFailCount - 1), ); - return hours * 60 * 60 * 1000; + const base = hours * 60 * 60 * 1000; + return applyEqualJitter(base, rng); } // ============================================================ @@ -83,6 +92,7 @@ export function computeNextProfileUsageStats( stats: ProfileUsageStats, reason: AuthProfileFailureReason, now?: number, + rng?: () => number, ): ProfileUsageStats { const ts = now ?? Date.now(); const next = { ...stats }; @@ -106,11 +116,11 @@ export function computeNextProfileUsageStats( // Apply cooldown based on failure reason if (reason === "billing") { const billingCount = next.failureCounts.billing ?? 1; - const disableMs = calculateBillingDisableMs(billingCount); + const disableMs = calculateBillingDisableMs(billingCount, rng); next.disabledUntil = ts + disableMs; next.disabledReason = "billing"; } else { - const cooldownMs = calculateCooldownMs(next.errorCount); + const cooldownMs = calculateCooldownMs(next.errorCount, rng); next.cooldownUntil = ts + cooldownMs; }