fix: Claude token rotation, usage-exhausted fallback, and usage API caching

- 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
This commit is contained in:
Eyejoker
2026-03-24 03:40:51 +09:00
parent 2ab6c25f0e
commit 120f16e9e1
12 changed files with 1633 additions and 258 deletions

View File

@@ -5,9 +5,15 @@
* Supports multiple tokens for rotation-aware usage checking.
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import { logger } from './logger.js';
import { getCurrentToken, getAllTokens } from './token-rotation.js';
const USAGE_CACHE_FILE = path.join(DATA_DIR, 'claude-usage-cache.json');
const PROFILE_ENDPOINT = 'https://api.anthropic.com/api/oauth/profile';
export interface ClaudeUsageData {
@@ -35,9 +41,50 @@ function mapWindow(w?: {
return { utilization: w.utilization, resets_at: w.resets_at || '' };
}
// ── Disk cache for usage data (survives restarts, 429s) ──
interface UsageCacheEntry {
usage: ClaudeUsageData;
fetchedAt: number;
}
let usageDiskCache: Record<string, UsageCacheEntry> = {};
let diskCacheLoaded = false;
function loadUsageDiskCache(): void {
if (diskCacheLoaded) return;
diskCacheLoaded = true;
try {
if (fs.existsSync(USAGE_CACHE_FILE)) {
usageDiskCache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf-8'));
}
} catch { /* start fresh */ }
}
function saveUsageDiskCache(): void {
try {
fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(usageDiskCache));
} catch { /* best effort */ }
}
function cacheKey(token: string): string {
return token.slice(0, 12);
}
// Rate limit: at most one API call per token per 5 minutes
const MIN_FETCH_INTERVAL_MS = 300_000;
async function fetchUsageForToken(
token: string,
): Promise<ClaudeUsageData | null> {
loadUsageDiskCache();
// Return cached data if fetched recently (avoid API rate-limit)
const key = cacheKey(token);
const cached = usageDiskCache[key];
if (cached && Date.now() - cached.fetchedAt < MIN_FETCH_INTERVAL_MS) {
return cached.usage;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -57,32 +104,38 @@ async function fetchUsageForToken(
return null;
}
if (res.status === 429) {
logger.warn('Claude usage API: rate limited (429)');
return null;
logger.warn('Claude usage API: rate limited (429), returning cached data');
return usageDiskCache[cacheKey(token)]?.usage ?? null;
}
if (!res.ok) {
logger.warn(
{ status: res.status },
`Claude usage API: unexpected status ${res.status}`,
);
return null;
return usageDiskCache[cacheKey(token)]?.usage ?? null;
}
const data = (await res.json()) as UsageApiResponse;
return {
const result: ClaudeUsageData = {
five_hour: mapWindow(data.five_hour),
seven_day: mapWindow(data.seven_day),
seven_day_sonnet: mapWindow(data.seven_day_sonnet),
seven_day_opus: mapWindow(data.seven_day_opus),
};
// Persist to disk cache
usageDiskCache[cacheKey(token)] = { usage: result, fetchedAt: Date.now() };
saveUsageDiskCache();
return result;
} catch (err) {
if ((err as Error).name === 'AbortError') {
logger.warn('Claude usage API: request timed out');
} else {
logger.warn({ err }, 'Claude usage API: fetch failed');
}
return null;
return usageDiskCache[cacheKey(token)]?.usage ?? null;
} finally {
clearTimeout(timer);
}
@@ -103,12 +156,14 @@ export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
export interface ClaudeAccountProfile {
email: string;
planType: string; // "max", "pro", "free"
planType: string; // "max", "pro", "free"
}
const profileCache = new Map<number, ClaudeAccountProfile>();
async function fetchProfileForToken(token: string): Promise<ClaudeAccountProfile | null> {
async function fetchProfileForToken(
token: string,
): Promise<ClaudeAccountProfile | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
@@ -121,14 +176,20 @@ async function fetchProfileForToken(token: string): Promise<ClaudeAccountProfile
signal: controller.signal,
});
if (!res.ok) return null;
const data = await res.json() as {
account?: { email?: string; has_claude_max?: boolean; has_claude_pro?: boolean };
const data = (await res.json()) as {
account?: {
email?: string;
has_claude_max?: boolean;
has_claude_pro?: boolean;
};
organization?: { organization_type?: string };
};
const orgType = data.organization?.organization_type || '';
const planType = data.account?.has_claude_max ? 'max'
: data.account?.has_claude_pro ? 'pro'
: orgType.replace('claude_', '') || 'free';
const planType = data.account?.has_claude_max
? 'max'
: data.account?.has_claude_pro
? 'pro'
: orgType.replace('claude_', '') || 'free';
return {
email: data.account?.email || '?',
planType,
@@ -157,7 +218,9 @@ export async function fetchAllClaudeProfiles(): Promise<void> {
}
}
export function getClaudeProfile(index: number): ClaudeAccountProfile | undefined {
export function getClaudeProfile(
index: number,
): ClaudeAccountProfile | undefined {
return profileCache.get(index);
}