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 <noreply@anthropic.com>
This commit is contained in:
yushen 2026-02-03 16:55:42 +08:00
parent 09f7e93496
commit d89b409add
8 changed files with 807 additions and 0 deletions

View file

@ -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;
});
}