- Fix token rotation not taking effect: getCurrentToken() was shadowed by static .env CLAUDE_CODE_OAUTH_TOKEN value in agent environment - Don't fall back to Kimi on usage-exhausted (only on transient 429/network) - Add disk cache for Claude usage API data (survives restarts, 429s) - Rate-limit usage API calls to 1 per token per 5 minutes - Add ignoreRateLimits option to rotateToken() for exhausted recovery - Rotate to next token in getActiveProvider() when current is exhausted - Add isUsageExhausted() helper to provider-fallback
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
/**
|
|
* Provider Fallback Module
|
|
*
|
|
* Manages automatic fallback from the primary provider (Claude) to a
|
|
* fallback provider (e.g. Kimi K2.5) when 429/rate-limit or network
|
|
* errors are detected.
|
|
*
|
|
* Cooldown-based recovery:
|
|
* Claude 429 → immediate Kimi retry for that turn
|
|
* Claude enters cooldown (retry-after header or default 10 min)
|
|
* During cooldown → skip Claude, route directly to fallback
|
|
* After cooldown → try Claude first again
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
|
|
import { fetchClaudeUsage, type ClaudeUsageData } from './claude-usage.js';
|
|
import { readEnvFile } from './env.js';
|
|
import { logger } from './logger.js';
|
|
import { rotateToken, getTokenCount } from './token-rotation.js';
|
|
|
|
// ── Types ────────────────────────────────────────────────────────
|
|
|
|
export type ProviderName = 'claude' | string; // fallback name is configurable
|
|
|
|
export interface FallbackTriggerResult {
|
|
shouldFallback: boolean;
|
|
reason: string;
|
|
retryAfterMs?: number;
|
|
}
|
|
|
|
interface CooldownState {
|
|
startedAt: number;
|
|
expiresAt: number;
|
|
reason: string;
|
|
}
|
|
|
|
interface FallbackConfig {
|
|
enabled: boolean;
|
|
providerName: string; // e.g. "kimi"
|
|
baseUrl: string;
|
|
authToken: string;
|
|
model: string;
|
|
smallModel: string;
|
|
defaultCooldownMs: number;
|
|
}
|
|
|
|
// ── State ────────────────────────────────────────────────────────
|
|
|
|
let cooldown: CooldownState | null = null;
|
|
let lastUsageAvailabilityCheck:
|
|
| {
|
|
checkedAt: number;
|
|
result: 'available' | 'exhausted' | 'unknown';
|
|
}
|
|
| null = null;
|
|
let usageAvailabilityCheckPromise:
|
|
| Promise<'available' | 'exhausted' | 'unknown'>
|
|
| null = null;
|
|
|
|
const USAGE_RECOVERY_RECHECK_MS = 30_000;
|
|
|
|
// ── Config ───────────────────────────────────────────────────────
|
|
|
|
let _config: FallbackConfig | null = null;
|
|
|
|
function loadConfig(): FallbackConfig {
|
|
if (_config) return _config;
|
|
|
|
const env = readEnvFile([
|
|
'FALLBACK_PROVIDER_NAME',
|
|
'FALLBACK_BASE_URL',
|
|
'FALLBACK_AUTH_TOKEN',
|
|
'FALLBACK_MODEL',
|
|
'FALLBACK_SMALL_MODEL',
|
|
'FALLBACK_COOLDOWN_MS',
|
|
]);
|
|
|
|
const baseUrl = process.env.FALLBACK_BASE_URL || env.FALLBACK_BASE_URL || '';
|
|
const authToken =
|
|
process.env.FALLBACK_AUTH_TOKEN || env.FALLBACK_AUTH_TOKEN || '';
|
|
const model = process.env.FALLBACK_MODEL || env.FALLBACK_MODEL || '';
|
|
|
|
_config = {
|
|
enabled: Boolean(baseUrl && authToken && model),
|
|
providerName:
|
|
process.env.FALLBACK_PROVIDER_NAME ||
|
|
env.FALLBACK_PROVIDER_NAME ||
|
|
'kimi',
|
|
baseUrl,
|
|
authToken,
|
|
model,
|
|
smallModel:
|
|
process.env.FALLBACK_SMALL_MODEL || env.FALLBACK_SMALL_MODEL || model,
|
|
defaultCooldownMs: parseInt(
|
|
process.env.FALLBACK_COOLDOWN_MS || env.FALLBACK_COOLDOWN_MS || '600000',
|
|
10,
|
|
),
|
|
};
|
|
|
|
if (_config.enabled) {
|
|
logger.info(
|
|
{
|
|
provider: _config.providerName,
|
|
model: _config.model,
|
|
cooldownMs: _config.defaultCooldownMs,
|
|
},
|
|
'Provider fallback configured',
|
|
);
|
|
}
|
|
|
|
return _config;
|
|
}
|
|
|
|
/** Force re-read of config (useful after .env changes). */
|
|
export function resetFallbackConfig(): void {
|
|
_config = null;
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────
|
|
|
|
/** Check whether the fallback system is configured and available. */
|
|
export function isFallbackEnabled(): boolean {
|
|
return loadConfig().enabled;
|
|
}
|
|
|
|
/** Get the display name of the fallback provider (e.g. "kimi"). */
|
|
export function getFallbackProviderName(): string {
|
|
return loadConfig().providerName;
|
|
}
|
|
|
|
function normalizeUtilization(utilization: number): number {
|
|
return utilization > 1 ? utilization : utilization * 100;
|
|
}
|
|
|
|
type ClaudeUsageWindow = NonNullable<ClaudeUsageData[keyof ClaudeUsageData]>;
|
|
|
|
function hasExhaustedClaudeUsageWindow(
|
|
usage: ClaudeUsageData | null,
|
|
): boolean | null {
|
|
if (!usage) return null;
|
|
const windows: ClaudeUsageWindow[] = [];
|
|
if (usage.five_hour) windows.push(usage.five_hour);
|
|
if (usage.seven_day) windows.push(usage.seven_day);
|
|
if (usage.seven_day_sonnet) windows.push(usage.seven_day_sonnet);
|
|
if (usage.seven_day_opus) windows.push(usage.seven_day_opus);
|
|
if (windows.length === 0) return null;
|
|
return windows.some(
|
|
(window) => normalizeUtilization(window.utilization) >= 100,
|
|
);
|
|
}
|
|
|
|
function clearUsageAvailabilityCache(): void {
|
|
lastUsageAvailabilityCheck = null;
|
|
usageAvailabilityCheckPromise = null;
|
|
}
|
|
|
|
async function getClaudeUsageAvailability(): Promise<
|
|
'available' | 'exhausted' | 'unknown'
|
|
> {
|
|
const now = Date.now();
|
|
if (
|
|
lastUsageAvailabilityCheck &&
|
|
now - lastUsageAvailabilityCheck.checkedAt < USAGE_RECOVERY_RECHECK_MS
|
|
) {
|
|
return lastUsageAvailabilityCheck.result;
|
|
}
|
|
|
|
if (!usageAvailabilityCheckPromise) {
|
|
usageAvailabilityCheckPromise = (async () => {
|
|
const usage = await fetchClaudeUsage();
|
|
const exhausted = hasExhaustedClaudeUsageWindow(usage);
|
|
const result =
|
|
exhausted === null ? 'unknown' : exhausted ? 'exhausted' : 'available';
|
|
lastUsageAvailabilityCheck = {
|
|
checkedAt: Date.now(),
|
|
result,
|
|
};
|
|
return result;
|
|
})();
|
|
|
|
void usageAvailabilityCheckPromise.finally(() => {
|
|
usageAvailabilityCheckPromise = null;
|
|
});
|
|
}
|
|
|
|
return usageAvailabilityCheckPromise;
|
|
}
|
|
|
|
/**
|
|
* Determine which provider should be used for the next request.
|
|
* Returns 'claude' when Claude is healthy or cooldown has expired,
|
|
* or the fallback provider name during an active cooldown.
|
|
*/
|
|
export async function getActiveProvider(): Promise<string> {
|
|
const config = loadConfig();
|
|
if (!config.enabled) return 'claude';
|
|
|
|
if (cooldown) {
|
|
if (cooldown.reason === 'usage-exhausted') {
|
|
const usageAvailability = await getClaudeUsageAvailability();
|
|
if (usageAvailability === 'available') {
|
|
logger.info(
|
|
{
|
|
provider: 'claude',
|
|
reason: cooldown.reason,
|
|
},
|
|
'Claude usage recovered, retrying primary provider',
|
|
);
|
|
cooldown = null;
|
|
clearUsageAvailabilityCache();
|
|
return 'claude';
|
|
}
|
|
if (usageAvailability === 'exhausted') {
|
|
// Current token exhausted — try rotating to another token (ignore cooldowns)
|
|
if (getTokenCount() > 1 && rotateToken(undefined, { ignoreRateLimits: true })) {
|
|
logger.info(
|
|
'Claude current token exhausted, rotated to next token — retrying',
|
|
);
|
|
cooldown = null;
|
|
clearUsageAvailabilityCache();
|
|
return 'claude';
|
|
}
|
|
logger.debug(
|
|
{
|
|
provider: config.providerName,
|
|
reason: cooldown.reason,
|
|
},
|
|
'All Claude tokens exhausted, keeping cooldown active',
|
|
);
|
|
return config.providerName;
|
|
}
|
|
}
|
|
|
|
if (Date.now() < cooldown.expiresAt) {
|
|
return config.providerName;
|
|
}
|
|
// Cooldown expired — try Claude again
|
|
logger.info(
|
|
{
|
|
provider: 'claude',
|
|
cooldownDurationMs: cooldown.expiresAt - cooldown.startedAt,
|
|
reason: cooldown.reason,
|
|
},
|
|
'Claude cooldown expired, retrying primary provider',
|
|
);
|
|
cooldown = null;
|
|
}
|
|
|
|
return 'claude';
|
|
}
|
|
|
|
/**
|
|
* Mark Claude as rate-limited. All subsequent requests will route to
|
|
* the fallback provider until the cooldown expires.
|
|
*/
|
|
export function markPrimaryCooldown(
|
|
reason: string,
|
|
retryAfterMs?: number,
|
|
): void {
|
|
const config = loadConfig();
|
|
const durationMs = retryAfterMs || config.defaultCooldownMs;
|
|
const now = Date.now();
|
|
|
|
cooldown = {
|
|
startedAt: now,
|
|
expiresAt: now + durationMs,
|
|
reason,
|
|
};
|
|
clearUsageAvailabilityCache();
|
|
|
|
logger.info(
|
|
{
|
|
reason,
|
|
cooldownMs: durationMs,
|
|
expiresAt: new Date(cooldown.expiresAt).toISOString(),
|
|
fallbackProvider: config.providerName,
|
|
},
|
|
`Falling back to provider: ${config.providerName} (reason: ${reason}, cooldownMs: ${durationMs})`,
|
|
);
|
|
}
|
|
|
|
/** Manually clear cooldown (e.g. after a successful Claude response). */
|
|
export function clearPrimaryCooldown(): void {
|
|
clearUsageAvailabilityCache();
|
|
if (cooldown) {
|
|
logger.info(
|
|
{ reason: cooldown.reason },
|
|
'Claude cooldown cleared manually',
|
|
);
|
|
cooldown = null;
|
|
}
|
|
}
|
|
|
|
/** Check if Claude is currently in usage-exhausted cooldown. */
|
|
export function isUsageExhausted(): boolean {
|
|
return cooldown?.reason === 'usage-exhausted';
|
|
}
|
|
|
|
/** Get current cooldown info (for diagnostics / status dashboard). */
|
|
export function getCooldownInfo(): {
|
|
active: boolean;
|
|
reason?: string;
|
|
expiresAt?: string;
|
|
remainingMs?: number;
|
|
} {
|
|
if (!cooldown) {
|
|
return { active: false };
|
|
}
|
|
const remainingMs = Math.max(cooldown.expiresAt - Date.now(), 0);
|
|
if (cooldown.reason !== 'usage-exhausted' && remainingMs === 0) {
|
|
return { active: false };
|
|
}
|
|
return {
|
|
active: true,
|
|
reason: cooldown.reason,
|
|
expiresAt: new Date(cooldown.expiresAt).toISOString(),
|
|
remainingMs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the env-var overrides that make Claude Code SDK talk to
|
|
* the fallback provider instead of Claude.
|
|
*/
|
|
export function getFallbackEnvOverrides(): Record<string, string> {
|
|
const config = loadConfig();
|
|
return {
|
|
ANTHROPIC_BASE_URL: config.baseUrl,
|
|
ANTHROPIC_AUTH_TOKEN: config.authToken,
|
|
ANTHROPIC_MODEL: config.model,
|
|
ANTHROPIC_SMALL_FAST_MODEL: config.smallModel,
|
|
// Disable non-essential traffic (usage telemetry etc.) on fallback
|
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
// Generous timeout for third-party APIs
|
|
API_TIMEOUT_MS: '3000000',
|
|
// Disable tool search (not supported by most fallback providers)
|
|
ENABLE_TOOL_SEARCH: 'false',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Inspect an agent error string and decide whether it warrants
|
|
* a provider fallback.
|
|
*
|
|
* Triggers:
|
|
* - 429 / rate limit / too many requests
|
|
* - 503 / overloaded (transient provider issue)
|
|
* - Network / connection errors
|
|
*
|
|
* Does NOT trigger for:
|
|
* - Poisoned sessions
|
|
* - Prompt / tool failures
|
|
* - Timeouts (agent took too long, not a provider issue)
|
|
*/
|
|
export function detectFallbackTrigger(
|
|
error?: string | null,
|
|
): FallbackTriggerResult {
|
|
if (!error) return { shouldFallback: false, reason: '' };
|
|
|
|
const lower = error.toLowerCase();
|
|
|
|
// 429 Rate Limit
|
|
if (
|
|
lower.includes('429') ||
|
|
lower.includes('rate limit') ||
|
|
lower.includes('usage limit') ||
|
|
lower.includes('hit your limit') ||
|
|
lower.includes('too many requests') ||
|
|
lower.includes('rate_limit')
|
|
) {
|
|
// Try to extract retry-after value (seconds → ms)
|
|
const retryMatch = error.match(/retry[\s_-]*after[:\s]*(\d+)/i);
|
|
const retryAfterMs = retryMatch
|
|
? parseInt(retryMatch[1], 10) * 1000
|
|
: undefined;
|
|
return { shouldFallback: true, reason: '429', retryAfterMs };
|
|
}
|
|
|
|
// 503 Overloaded
|
|
if (lower.includes('503') || lower.includes('overloaded')) {
|
|
return { shouldFallback: true, reason: 'overloaded' };
|
|
}
|
|
|
|
// Network / connection errors
|
|
if (
|
|
lower.includes('econnrefused') ||
|
|
lower.includes('econnreset') ||
|
|
lower.includes('etimedout') ||
|
|
lower.includes('enotfound') ||
|
|
lower.includes('fetch failed') ||
|
|
lower.includes('network error')
|
|
) {
|
|
return { shouldFallback: true, reason: 'network-error' };
|
|
}
|
|
|
|
return { shouldFallback: false, reason: '' };
|
|
}
|
|
|
|
/**
|
|
* Check whether a per-group settings.json already overrides the
|
|
* provider (e.g. the Kimi test channel). If so, we should NOT
|
|
* apply fallback env overrides on top — the channel already has
|
|
* its own provider configuration.
|
|
*/
|
|
export function hasGroupProviderOverride(settingsJsonPath: string): boolean {
|
|
try {
|
|
const raw = fs.readFileSync(settingsJsonPath, 'utf-8');
|
|
const settings = JSON.parse(raw);
|
|
const env = settings?.env || {};
|
|
return Boolean(env.ANTHROPIC_BASE_URL || env.ANTHROPIC_MODEL);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|