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

@@ -123,9 +123,10 @@ function prepareClaudeEnvironment(args: {
args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || '';
}
{
// Token rotation takes priority over static .env value
const oauthToken =
args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
getCurrentToken() ||
args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (oauthToken) {
args.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;

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

View File

@@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./agent-runner.js', () => ({
runAgentProcess: vi.fn(),
writeGroupsSnapshot: vi.fn(),
writeTasksSnapshot: vi.fn(),
}));
vi.mock('./available-groups.js', () => ({
listAvailableGroups: vi.fn(() => []),
}));
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ejclaw-test-data',
}));
vi.mock('./db.js', () => ({
getAllTasks: vi.fn(() => []),
}));
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('./provider-fallback.js', () => ({
detectFallbackTrigger: vi.fn((error?: string | null) => {
const lower = (error || '').toLowerCase();
if (
lower.includes('429') ||
lower.includes('rate limit') ||
lower.includes('hit your limit')
) {
return { shouldFallback: true, reason: '429' };
}
return { shouldFallback: false, reason: '' };
}),
getActiveProvider: vi.fn(async () => 'claude'),
getFallbackEnvOverrides: vi.fn(() => ({
ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
ANTHROPIC_AUTH_TOKEN: 'test-kimi-key',
ANTHROPIC_MODEL: 'kimi-k2.5',
})),
getFallbackProviderName: vi.fn(() => 'kimi'),
hasGroupProviderOverride: vi.fn(() => false),
isFallbackEnabled: vi.fn(() => true),
markPrimaryCooldown: vi.fn(),
}));
vi.mock('./session-recovery.js', () => ({
shouldResetSessionOnAgentFailure: vi.fn(() => false),
}));
vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1),
markTokenHealthy: vi.fn(),
}));
vi.mock('./codex-token-rotation.js', () => ({
rotateCodexToken: vi.fn(() => false),
getCodexAccountCount: vi.fn(() => 1),
markCodexTokenHealthy: vi.fn(),
}));
import * as agentRunner from './agent-runner.js';
import { runAgentForGroup } from './message-agent-executor.js';
import * as providerFallback from './provider-fallback.js';
import * as tokenRotation from './token-rotation.js';
import type { RegisteredGroup } from './types.js';
function makeGroup(): RegisteredGroup {
return {
name: 'Test Group',
folder: 'test-claude',
trigger: '@Andy',
added_at: new Date().toISOString(),
requiresTrigger: false,
agentType: 'claude-code',
};
}
function makeDeps() {
return {
assistantName: 'Andy',
queue: {
registerProcess: vi.fn(),
},
getRegisteredGroups: () => ({}),
getSessions: () => ({}),
persistSession: vi.fn(),
clearSession: vi.fn(),
};
}
describe('runAgentForGroup Claude rotation', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true);
vi.mocked(providerFallback.hasGroupProviderOverride).mockReturnValue(false);
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1);
vi.mocked(tokenRotation.rotateToken).mockReturnValue(false);
});
it('rotates to another Claude account before falling back to Kimi', async () => {
const outputs: string[] = [];
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2);
vi.mocked(tokenRotation.rotateToken).mockReturnValueOnce(true);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'Youre out of extra usage · resets 4am (Asia/Seoul)',
});
return {
status: 'success',
result: null,
};
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: '회전된 Claude 응답입니다.',
});
return {
status: 'success',
result: null,
};
});
const result = await runAgentForGroup(makeDeps(), {
group: makeGroup(),
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-rotate-claude',
onOutput: async (output) => {
if (typeof output.result === 'string') outputs.push(output.result);
},
});
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(tokenRotation.rotateToken).toHaveBeenCalledTimes(1);
expect(tokenRotation.markTokenHealthy).toHaveBeenCalledTimes(1);
expect(providerFallback.markPrimaryCooldown).not.toHaveBeenCalled();
expect(outputs).toEqual(['회전된 Claude 응답입니다.']);
});
it('falls back to Kimi only after all Claude accounts are exhausted', async () => {
const outputs: string[] = [];
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2);
vi.mocked(tokenRotation.rotateToken)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'Youre out of extra usage · resets 4am (Asia/Seoul)',
});
return {
status: 'success',
result: null,
};
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
return {
status: 'success',
result: null,
};
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'Kimi 폴백 응답입니다.',
});
return {
status: 'success',
result: null,
};
});
const result = await runAgentForGroup(makeDeps(), {
group: makeGroup(),
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-fallback-after-rotation',
onOutput: async (output) => {
if (typeof output.result === 'string') outputs.push(output.result);
},
});
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(3);
expect(tokenRotation.rotateToken).toHaveBeenCalledTimes(2);
expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith(
'usage-exhausted',
undefined,
);
expect(agentRunner.runAgentProcess).toHaveBeenNthCalledWith(
3,
expect.anything(),
expect.anything(),
expect.any(Function),
expect.any(Function),
expect.objectContaining({
ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
ANTHROPIC_MODEL: 'kimi-k2.5',
}),
);
expect(outputs).toEqual(['Kimi 폴백 응답입니다.']);
});
it('does not mistake a normal response quoting the banner text for a usage error', async () => {
const outputs: string[] = [];
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2);
vi.mocked(agentRunner.runAgentProcess).mockImplementationOnce(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result:
"상태 문구 예시: You're out of extra usage · resets 4am (Asia/Seoul) 라는 배너가 뜰 수 있습니다.",
});
return {
status: 'success',
result: null,
};
},
);
const result = await runAgentForGroup(makeDeps(), {
group: makeGroup(),
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-normal-quoted-banner',
onOutput: async (output) => {
if (typeof output.result === 'string') outputs.push(output.result);
},
});
expect(result).toBe('success');
expect(tokenRotation.rotateToken).not.toHaveBeenCalled();
expect(providerFallback.markPrimaryCooldown).not.toHaveBeenCalled();
expect(outputs).toEqual([
"상태 문구 예시: You're out of extra usage · resets 4am (Asia/Seoul) 라는 배너가 뜰 수 있습니다.",
]);
});
});

View File

@@ -18,6 +18,7 @@ import {
getFallbackProviderName,
hasGroupProviderOverride,
isFallbackEnabled,
isUsageExhausted,
markPrimaryCooldown,
} from './provider-fallback.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
@@ -42,6 +43,25 @@ export interface MessageAgentExecutorDeps {
clearSession: (groupFolder: string) => void;
}
function isClaudeUsageExhaustedMessage(text: string): boolean {
const normalized = text
.trim()
.toLowerCase()
.replace(/[`]/g, "'")
.replace(/\s+/g, ' ')
.replace(/^error:\s*/i, '');
const looksLikeBanner =
normalized.startsWith("you're out of extra usage") ||
normalized.startsWith('you are out of extra usage') ||
normalized.startsWith("you've hit your limit") ||
normalized.startsWith('you have hit your limit');
const hasResetHint =
normalized.includes('resets ') ||
normalized.includes('reset at ') ||
normalized.includes('try again');
return looksLikeBanner && hasResetHint && normalized.length <= 160;
}
export async function runAgentForGroup(
deps: MessageAgentExecutorDeps,
args: {
@@ -137,6 +157,30 @@ export async function runAgentForGroup(
) {
resetSessionRequested = true;
}
if (
canFallback &&
provider === 'claude' &&
output.status === 'success' &&
!sawOutput &&
typeof output.result === 'string' &&
isClaudeUsageExhaustedMessage(output.result)
) {
if (!streamedTriggerReason) {
logger.warn(
{
chatJid,
group: group.name,
runId,
resultPreview: output.result.slice(0, 120),
},
'Detected Claude usage exhaustion banner in successful output',
);
}
streamedTriggerReason = {
reason: 'usage-exhausted',
};
return;
}
if (output.result !== null && output.result !== undefined) {
sawOutput = true;
} else if (
@@ -291,7 +335,164 @@ export async function runAgentForGroup(
return 'success';
};
const provider = canFallback ? getActiveProvider() : 'claude';
const shouldRotateClaudeToken = (reason: string): boolean =>
reason === '429' || reason === 'usage-exhausted';
const retryClaudeWithRotation = async (
initialTrigger: {
reason: string;
retryAfterMs?: number;
},
rotationMessage?: string,
): Promise<'success' | 'error'> => {
let trigger = initialTrigger;
let lastRotationMessage = rotationMessage;
while (
shouldRotateClaudeToken(trigger.reason) &&
getTokenCount() > 1 &&
rotateToken(lastRotationMessage)
) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Claude rate-limited, retrying with rotated account',
);
const retryAttempt = await runAttempt('claude');
if (retryAttempt.error) {
if (!retryAttempt.sawOutput) {
const errMsg =
retryAttempt.error instanceof Error
? retryAttempt.error.message
: String(retryAttempt.error);
const retryTrigger = retryAttempt.streamedTriggerReason
? {
shouldFallback: true,
reason: retryAttempt.streamedTriggerReason.reason,
retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs,
}
: detectFallbackTrigger(errMsg);
if (retryTrigger.shouldFallback) {
trigger = {
reason: retryTrigger.reason,
retryAfterMs: retryTrigger.retryAfterMs,
};
lastRotationMessage = errMsg;
continue;
}
}
logger.error(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider: 'claude',
err: retryAttempt.error,
},
'Rotated Claude account also threw',
);
return 'error';
}
const retryOutput = retryAttempt.output;
if (!retryOutput) {
logger.error(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
provider: 'claude',
},
'Rotated Claude account produced no output object',
);
return 'error';
}
if (
!retryAttempt.sawOutput &&
retryAttempt.streamedTriggerReason &&
retryOutput.status !== 'error'
) {
trigger = {
reason: retryAttempt.streamedTriggerReason.reason,
retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs,
};
lastRotationMessage =
typeof retryOutput.result === 'string' ? retryOutput.result : undefined;
continue;
}
if (
!retryAttempt.sawOutput &&
retryAttempt.sawSuccessNullResultWithoutOutput
) {
return runFallbackAttempt('success-null-result');
}
if (retryOutput.status === 'error') {
if (!retryAttempt.sawOutput) {
const retryTrigger = retryAttempt.streamedTriggerReason
? {
shouldFallback: true,
reason: retryAttempt.streamedTriggerReason.reason,
retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs,
}
: detectFallbackTrigger(retryOutput.error);
if (retryTrigger.shouldFallback) {
trigger = {
reason: retryTrigger.reason,
retryAfterMs: retryTrigger.retryAfterMs,
};
lastRotationMessage = retryOutput.error ?? undefined;
continue;
}
}
logger.error(
{
group: group.name,
chatJid,
runId,
provider: 'claude',
error: retryOutput.error,
},
'Rotated Claude account failed',
);
return 'error';
}
markTokenHealthy();
return 'success';
}
// Usage exhausted: don't fall back to Kimi — log only, no response
if (trigger.reason === 'usage-exhausted') {
markPrimaryCooldown(trigger.reason, trigger.retryAfterMs);
logger.info(
{ chatJid, group: group.name, runId },
'All Claude tokens usage-exhausted, silently skipping (no Kimi fallback)',
);
return 'error';
}
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
};
const provider = canFallback ? await getActiveProvider() : 'claude';
// Already in usage-exhausted cooldown — log only, no response
if (provider !== 'claude' && isUsageExhausted()) {
logger.info(
{ chatJid, group: group.name, runId, provider },
'Claude usage exhausted (cooldown active), silently skipping',
);
return 'error';
}
const primaryAttempt = await runAttempt(provider);
if (primaryAttempt.error) {
@@ -308,19 +509,13 @@ export async function runAgentForGroup(
}
: detectFallbackTrigger(errMsg);
if (trigger.shouldFallback) {
// Try rotating token before falling back to another provider
if (getTokenCount() > 1 && rotateToken(errMsg)) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Rate-limited, retrying with rotated token',
);
const retryAttempt = await runAttempt('claude');
if (!retryAttempt.error) {
markTokenHealthy();
return 'success';
}
}
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
return retryClaudeWithRotation(
{
reason: trigger.reason,
retryAfterMs: trigger.retryAfterMs,
},
errMsg,
);
}
}
@@ -353,6 +548,19 @@ export async function runAgentForGroup(
return 'error';
}
if (
canFallback &&
provider === 'claude' &&
!primaryAttempt.sawOutput &&
primaryAttempt.streamedTriggerReason &&
output.status !== 'error'
) {
return retryClaudeWithRotation({
reason: primaryAttempt.streamedTriggerReason.reason,
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
});
}
if (
canFallback &&
provider === 'claude' &&
@@ -383,18 +591,13 @@ export async function runAgentForGroup(
}
: detectFallbackTrigger(output.error);
if (trigger.shouldFallback) {
if (getTokenCount() > 1 && rotateToken(output.error ?? undefined)) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Rate-limited (output error), retrying with rotated token',
);
const retryAttempt = await runAttempt('claude');
if (!retryAttempt.error) {
markTokenHealthy();
return 'success';
}
}
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
return retryClaudeWithRotation(
{
reason: trigger.reason,
retryAfterMs: trigger.retryAfterMs,
},
output.error ?? undefined,
);
}
}

View File

@@ -122,7 +122,7 @@ vi.mock('./logger.js', () => ({
vi.mock('./provider-fallback.js', () => ({
detectFallbackTrigger: vi.fn(() => ({ shouldFallback: false, reason: '' })),
getActiveProvider: vi.fn(() => 'claude'),
getActiveProvider: vi.fn(async () => 'claude'),
getFallbackEnvOverrides: vi.fn(() => ({
ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
ANTHROPIC_AUTH_TOKEN: 'test-kimi-key',
@@ -181,7 +181,7 @@ function makeChannel(chatJid: string): Channel {
describe('createMessageRuntime', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(providerFallback.getActiveProvider).mockReturnValue('claude');
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
vi.mocked(providerFallback.getFallbackProviderName).mockReturnValue('kimi');
vi.mocked(providerFallback.getFallbackEnvOverrides).mockReturnValue({
ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
@@ -1957,6 +1957,171 @@ describe('createMessageRuntime', () => {
);
});
it('retries with the fallback provider when Claude returns only a usage exhaustion banner', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const channel = makeChannel(chatJid);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-24T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
return {
status: 'success',
result: null,
};
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'usage fallback 응답입니다.',
});
return {
status: 'success',
result: null,
};
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-fallback-usage-exhausted',
reason: 'messages',
});
expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith(
'usage-exhausted',
undefined,
);
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'usage fallback 응답입니다.',
);
});
it('suppresses duplicate streamed usage banners before retrying the fallback provider', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const channel = makeChannel(chatJid);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-24T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'intermediate',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
await onOutput?.({
status: 'success',
phase: 'final',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
return {
status: 'success',
result: null,
};
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'duplicate banner fallback 응답입니다.',
});
return {
status: 'success',
result: null,
};
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-fallback-usage-exhausted-duplicate',
reason: 'messages',
});
expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith(
'usage-exhausted',
undefined,
);
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'duplicate banner fallback 응답입니다.',
);
});
it('retries with the fallback provider when Claude ends with success-null-result before any output', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');

View File

@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./claude-usage.js', () => ({
fetchClaudeUsage: vi.fn(),
}));
vi.mock('./env.js', () => ({
readEnvFile: vi.fn(() => ({
FALLBACK_PROVIDER_NAME: 'kimi',
FALLBACK_BASE_URL: 'https://api.kimi.com/coding/',
FALLBACK_AUTH_TOKEN: 'test-kimi-key',
FALLBACK_MODEL: 'kimi-k2.5',
FALLBACK_SMALL_MODEL: 'kimi-k2.5',
FALLBACK_COOLDOWN_MS: '600000',
})),
}));
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import { fetchClaudeUsage } from './claude-usage.js';
import {
clearPrimaryCooldown,
getActiveProvider,
getCooldownInfo,
markPrimaryCooldown,
resetFallbackConfig,
} from './provider-fallback.js';
describe('provider fallback usage recovery', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T00:00:00.000Z'));
vi.clearAllMocks();
clearPrimaryCooldown();
resetFallbackConfig();
delete process.env.FALLBACK_PROVIDER_NAME;
delete process.env.FALLBACK_BASE_URL;
delete process.env.FALLBACK_AUTH_TOKEN;
delete process.env.FALLBACK_MODEL;
delete process.env.FALLBACK_SMALL_MODEL;
delete process.env.FALLBACK_COOLDOWN_MS;
});
afterEach(() => {
clearPrimaryCooldown();
resetFallbackConfig();
vi.useRealTimers();
});
it('keeps the fallback provider active while Claude usage is still exhausted', async () => {
vi.mocked(fetchClaudeUsage).mockResolvedValue({
five_hour: {
utilization: 100,
resets_at: '2026-03-24T04:00:00.000+09:00',
},
});
markPrimaryCooldown('usage-exhausted', 1_000);
vi.advanceTimersByTime(5_000);
await expect(getActiveProvider()).resolves.toBe('kimi');
expect(getCooldownInfo()).toMatchObject({
active: true,
reason: 'usage-exhausted',
remainingMs: 0,
});
});
it('returns to Claude immediately when usage is no longer exhausted', async () => {
vi.mocked(fetchClaudeUsage).mockResolvedValue({
five_hour: {
utilization: 72,
resets_at: '2026-03-24T04:00:00.000+09:00',
},
seven_day: {
utilization: 55,
resets_at: '2026-03-31T04:00:00.000+09:00',
},
});
markPrimaryCooldown('usage-exhausted', 600_000);
await expect(getActiveProvider()).resolves.toBe('claude');
expect(getCooldownInfo()).toEqual({ active: false });
});
it('falls back to time-based retry when usage status cannot be fetched', async () => {
vi.mocked(fetchClaudeUsage).mockResolvedValue(null);
markPrimaryCooldown('usage-exhausted', 1_000);
vi.advanceTimersByTime(5_000);
await expect(getActiveProvider()).resolves.toBe('claude');
expect(getCooldownInfo()).toEqual({ active: false });
});
});

View File

@@ -14,8 +14,10 @@
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 ────────────────────────────────────────────────────────
@@ -46,6 +48,17 @@ interface FallbackConfig {
// ── 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 ───────────────────────────────────────────────────────
@@ -116,16 +129,109 @@ 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 function getActiveProvider(): string {
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;
}
@@ -161,6 +267,7 @@ export function markPrimaryCooldown(
expiresAt: now + durationMs,
reason,
};
clearUsageAvailabilityCache();
logger.info(
{
@@ -175,6 +282,7 @@ export function markPrimaryCooldown(
/** Manually clear cooldown (e.g. after a successful Claude response). */
export function clearPrimaryCooldown(): void {
clearUsageAvailabilityCache();
if (cooldown) {
logger.info(
{ reason: cooldown.reason },
@@ -184,6 +292,11 @@ export function clearPrimaryCooldown(): void {
}
}
/** 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;
@@ -191,14 +304,18 @@ export function getCooldownInfo(): {
expiresAt?: string;
remainingMs?: number;
} {
if (!cooldown || Date.now() >= cooldown.expiresAt) {
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: cooldown.expiresAt - Date.now(),
remainingMs,
};
}

View File

@@ -8,6 +8,42 @@ const { runAgentProcessMock, writeTasksSnapshotMock } = vi.hoisted(() => ({
writeTasksSnapshotMock: vi.fn(),
}));
vi.mock('./provider-fallback.js', () => ({
detectFallbackTrigger: vi.fn((error?: string | null) => {
const lower = (error || '').toLowerCase();
if (
lower.includes('429') ||
lower.includes('rate limit') ||
lower.includes('hit your limit')
) {
return { shouldFallback: true, reason: '429' };
}
return { shouldFallback: false, reason: '' };
}),
getActiveProvider: vi.fn(async () => 'claude'),
getFallbackEnvOverrides: vi.fn(() => ({
ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
ANTHROPIC_AUTH_TOKEN: 'test-kimi-key',
ANTHROPIC_MODEL: 'kimi-k2.5',
})),
getFallbackProviderName: vi.fn(() => 'kimi'),
hasGroupProviderOverride: vi.fn(() => false),
isFallbackEnabled: vi.fn(() => true),
markPrimaryCooldown: vi.fn(),
}));
vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1),
markTokenHealthy: vi.fn(),
}));
vi.mock('./codex-token-rotation.js', () => ({
rotateCodexToken: vi.fn(() => false),
getCodexAccountCount: vi.fn(() => 1),
markCodexTokenHealthy: vi.fn(),
}));
vi.mock('./agent-runner.js', async () => {
const actual =
await vi.importActual<typeof import('./agent-runner.js')>(
@@ -21,8 +57,10 @@ vi.mock('./agent-runner.js', async () => {
});
import { _initTestDatabase, createTask, getTaskById } from './db.js';
import * as providerFallback from './provider-fallback.js';
import { createTaskStatusTracker } from './task-status-tracker.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import * as tokenRotation from './token-rotation.js';
import {
_resetSchedulerLoopForTests,
computeNextRun,
@@ -39,6 +77,11 @@ describe('task scheduler', () => {
_resetSchedulerLoopForTests();
runAgentProcessMock.mockClear();
writeTasksSnapshotMock.mockClear();
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true);
vi.mocked(providerFallback.hasGroupProviderOverride).mockReturnValue(false);
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1);
vi.mocked(tokenRotation.rotateToken).mockReturnValue(false);
vi.useFakeTimers();
});
@@ -232,6 +275,103 @@ Check the run.
expect(enqueueTask.mock.calls[0][1]).toBe('task-watch-group');
});
it('suppresses Claude usage banners for scheduled tasks and retries with a rotated account', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-usage-banner',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2);
vi.mocked(tokenRotation.rotateToken).mockReturnValueOnce(true);
(runAgentProcessMock as any)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
phase: 'intermediate',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
await onOutput?.({
status: 'success',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
return {
status: 'success',
result: null,
};
},
)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
result: 'rotated scheduled task response',
});
return {
status: 'success',
result: null,
};
},
);
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
const sendMessage = vi.fn(async () => {});
startSchedulerLoop({
serviceAgentType: 'claude-code',
registeredGroups: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Claude',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'claude-code',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
});
await vi.advanceTimersByTimeAsync(10);
expect(runAgentProcessMock).toHaveBeenCalledTimes(2);
expect(tokenRotation.rotateToken).toHaveBeenCalledTimes(1);
expect(tokenRotation.markTokenHealthy).toHaveBeenCalledTimes(1);
expect(providerFallback.markPrimaryCooldown).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
'shared@g.us',
'rotated scheduled task response',
);
});
it('picks up newly due tasks immediately when nudged', async () => {
const enqueueTask = vi.fn();
@@ -352,6 +492,7 @@ Check the run.
}),
expect.any(Function),
expect.any(Function),
undefined,
);
expect(writeTasksSnapshotMock).toHaveBeenCalledWith(
'shared-group',
@@ -411,6 +552,7 @@ Check the run.
}),
expect.any(Function),
expect.any(Function),
undefined,
);
expect(writeTasksSnapshotMock).toHaveBeenCalledWith(
'shared-group',

View File

@@ -1,9 +1,11 @@
import { ChildProcess } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
import fs from 'fs';
import path from 'path';
import {
ASSISTANT_NAME,
DATA_DIR,
SCHEDULER_POLL_INTERVAL,
SERVICE_AGENT_TYPE,
TIMEZONE,
@@ -29,7 +31,16 @@ import {
} from './group-folder.js';
import { logger } from './logger.js';
import { createTaskStatusTracker } from './task-status-tracker.js';
import { detectFallbackTrigger } from './provider-fallback.js';
import {
detectFallbackTrigger,
getActiveProvider,
getFallbackEnvOverrides,
getFallbackProviderName,
hasGroupProviderOverride,
isFallbackEnabled,
isUsageExhausted,
markPrimaryCooldown,
} from './provider-fallback.js';
import {
rotateCodexToken,
getCodexAccountCount,
@@ -61,6 +72,25 @@ export {
shouldUseTaskScopedSession,
} from './task-watch-status.js';
function isClaudeUsageExhaustedMessage(text: string): boolean {
const normalized = text
.trim()
.toLowerCase()
.replace(/[`]/g, "'")
.replace(/\s+/g, ' ')
.replace(/^error:\s*/i, '');
const looksLikeBanner =
normalized.startsWith("you're out of extra usage") ||
normalized.startsWith('you are out of extra usage') ||
normalized.startsWith("you've hit your limit") ||
normalized.startsWith('you have hit your limit');
const hasResetHint =
normalized.includes('resets ') ||
normalized.includes('reset at ') ||
normalized.includes('try again');
return looksLikeBanner && hasResetHint && normalized.length <= 160;
}
/**
* Compute the next run time for a recurring task, anchored to the
* task's scheduled time rather than Date.now() to prevent cumulative
@@ -249,52 +279,292 @@ async function runTask(
sendTrackedMessage: deps.sendTrackedMessage,
editTrackedMessage: deps.editTrackedMessage,
});
const settingsPath = path.join(
DATA_DIR,
'sessions',
task.group_folder,
'.claude',
'settings.json',
);
const canFallback =
context.taskAgentType === 'claude-code' &&
isFallbackEnabled() &&
!hasGroupProviderOverride(settingsPath);
try {
await statusTracker.update('checking');
const output = await runAgentProcess(
context.group,
{
prompt: task.prompt,
sessionId: context.sessionId,
groupFolder: task.group_folder,
chatJid: task.chat_jid,
isMain: context.isMain,
isScheduledTask: true,
runtimeTaskId: context.runtimeTaskId,
useTaskScopedSession: context.useTaskScopedSession,
assistantName: ASSISTANT_NAME,
const runTaskAttempt = async (
provider: string,
): Promise<{
output: AgentOutput;
sawOutput: boolean;
streamedTriggerReason?: {
reason: string;
retryAfterMs?: number;
};
attemptResult: string | null;
attemptError: string | null;
}> => {
let sawOutput = false;
let attemptResult: string | null = null;
let attemptError: string | null = null;
let streamedTriggerReason:
| {
reason: string;
retryAfterMs?: number;
}
| undefined;
const output = await runAgentProcess(
context.group,
{
prompt: task.prompt,
sessionId: context.sessionId,
groupFolder: task.group_folder,
chatJid: task.chat_jid,
isMain: context.isMain,
isScheduledTask: true,
runtimeTaskId: context.runtimeTaskId,
useTaskScopedSession: context.useTaskScopedSession,
assistantName: ASSISTANT_NAME,
},
(proc, processName) =>
deps.onProcess(
context.queueJid,
proc,
processName,
context.runtimeIpcDir,
),
async (streamedOutput: AgentOutput) => {
if (streamedOutput.phase === 'progress') {
return;
}
if (
canFallback &&
provider === 'claude' &&
!sawOutput &&
streamedOutput.status === 'success' &&
typeof streamedOutput.result === 'string' &&
isClaudeUsageExhaustedMessage(streamedOutput.result)
) {
if (!streamedTriggerReason) {
logger.warn(
{
taskId: task.id,
taskChatJid: task.chat_jid,
group: context.group.name,
groupFolder: task.group_folder,
resultPreview: streamedOutput.result.slice(0, 120),
},
'Detected Claude usage exhaustion banner during scheduled task output',
);
}
streamedTriggerReason = { reason: 'usage-exhausted' };
return;
}
if (streamedOutput.result) {
sawOutput = true;
attemptResult = streamedOutput.result;
await deps.sendMessage(task.chat_jid, streamedOutput.result);
}
if (streamedOutput.status === 'error') {
attemptError = streamedOutput.error || 'Unknown error';
if (!sawOutput && !streamedTriggerReason) {
const trigger = detectFallbackTrigger(streamedOutput.error);
if (trigger.shouldFallback) {
streamedTriggerReason = {
reason: trigger.reason,
retryAfterMs: trigger.retryAfterMs,
};
}
}
}
},
provider === 'claude' ? undefined : getFallbackEnvOverrides(),
);
if (output.status === 'error' && !attemptError) {
attemptError = output.error || 'Unknown error';
} else if (output.result && !attemptResult) {
attemptResult = output.result;
}
return {
output,
sawOutput,
streamedTriggerReason,
attemptResult,
attemptError,
};
};
const shouldRotateClaudeToken = (reason: string): boolean =>
reason === '429' || reason === 'usage-exhausted';
const runFallbackTaskAttempt = async (
reason: string,
retryAfterMs?: number,
): Promise<void> => {
if (!canFallback) {
error = reason;
return;
}
const fallbackName = getFallbackProviderName();
markPrimaryCooldown(reason, retryAfterMs);
logger.info(
{
taskId: task.id,
group: context.group.name,
groupFolder: task.group_folder,
reason,
retryAfterMs,
fallbackProvider: fallbackName,
},
`Falling back to provider: ${fallbackName} for scheduled task (reason: ${reason})`,
);
const fallbackAttempt = await runTaskAttempt(fallbackName);
result = fallbackAttempt.attemptResult;
error =
fallbackAttempt.output.status === 'error'
? fallbackAttempt.attemptError || 'Unknown error'
: null;
};
const retryClaudeTaskWithRotation = async (
initialTrigger: {
reason: string;
retryAfterMs?: number;
},
(proc, processName) =>
deps.onProcess(
context.queueJid,
proc,
processName,
context.runtimeIpcDir,
),
async (streamedOutput: AgentOutput) => {
if (streamedOutput.phase === 'progress') {
rotationMessage?: string,
): Promise<void> => {
let trigger = initialTrigger;
let lastRotationMessage = rotationMessage;
while (
shouldRotateClaudeToken(trigger.reason) &&
getTokenCount() > 1 &&
rotateToken(lastRotationMessage)
) {
logger.info(
{
taskId: task.id,
group: context.group.name,
groupFolder: task.group_folder,
reason: trigger.reason,
},
'Scheduled task Claude rate-limited, retrying with rotated account',
);
const retryAttempt = await runTaskAttempt('claude');
result = retryAttempt.attemptResult;
error = retryAttempt.attemptError;
if (
retryAttempt.streamedTriggerReason &&
!retryAttempt.sawOutput &&
retryAttempt.output.status !== 'error'
) {
trigger = {
reason: retryAttempt.streamedTriggerReason.reason,
retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs,
};
lastRotationMessage =
typeof retryAttempt.output.result === 'string'
? retryAttempt.output.result
: undefined;
continue;
}
if (retryAttempt.output.status === 'error' && !retryAttempt.sawOutput) {
const retryTrigger = retryAttempt.streamedTriggerReason
? {
shouldFallback: true,
reason: retryAttempt.streamedTriggerReason.reason,
retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs,
}
: detectFallbackTrigger(retryAttempt.attemptError);
if (retryTrigger.shouldFallback) {
trigger = {
reason: retryTrigger.reason,
retryAfterMs: retryTrigger.retryAfterMs,
};
lastRotationMessage = retryAttempt.attemptError || undefined;
continue;
}
}
if (retryAttempt.output.status === 'success') {
markTokenHealthy();
error = null;
return;
}
if (streamedOutput.result) {
result = streamedOutput.result;
// Forward result to user (sendMessage handles formatting)
await deps.sendMessage(task.chat_jid, streamedOutput.result);
}
if (streamedOutput.status === 'error') {
error = streamedOutput.error || 'Unknown error';
}
},
);
if (output.status === 'error') {
error = output.error || 'Unknown error';
} else if (output.result) {
// Result was already forwarded to the user via the streaming callback above
result = output.result;
return;
}
// Usage exhausted: don't fall back to Kimi — just mark cooldown and skip
if (trigger.reason === 'usage-exhausted') {
markPrimaryCooldown(trigger.reason, trigger.retryAfterMs);
logger.info(
{ taskId: task.id, group: context.group.name },
'All Claude tokens usage-exhausted, skipping Kimi fallback for scheduled task',
);
error = 'Claude usage exhausted';
return;
}
await runFallbackTaskAttempt(trigger.reason, trigger.retryAfterMs);
};
const provider = canFallback ? await getActiveProvider() : 'claude';
// Already in usage-exhausted cooldown — skip task instead of running on Kimi
if (provider !== 'claude' && isUsageExhausted()) {
logger.info(
{ taskId: task.id, group: context.group.name, provider },
'Claude usage exhausted (cooldown active), skipping scheduled task',
);
error = 'Claude usage exhausted';
// Fall through to task completion handling below
} else {
const attempt = await runTaskAttempt(provider);
result = attempt.attemptResult;
error = attempt.attemptError;
if (
provider === 'claude' &&
attempt.streamedTriggerReason &&
!attempt.sawOutput
) {
await retryClaudeTaskWithRotation(attempt.streamedTriggerReason);
} else if (attempt.output.status === 'error' && provider === 'claude') {
const trigger = attempt.streamedTriggerReason
? {
shouldFallback: true,
reason: attempt.streamedTriggerReason.reason,
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
}
: detectFallbackTrigger(error);
if (trigger.shouldFallback) {
await retryClaudeTaskWithRotation({
reason: trigger.reason,
retryAfterMs: trigger.retryAfterMs,
});
}
} else if (attempt.output.status === 'error') {
error = attempt.attemptError || 'Unknown error';
}
} // end else (non-exhausted path)
logger.info(
{
taskId: task.id,

View File

@@ -142,21 +142,29 @@ export function getCurrentToken(): string | undefined {
* Try to rotate to the next available (non-rate-limited) token.
* Returns true if a fresh token was found, false if all are exhausted.
*/
export function rotateToken(errorMessage?: string): boolean {
export function rotateToken(
errorMessage?: string,
opts?: { ignoreRateLimits?: boolean },
): boolean {
if (tokens.length <= 1) return false;
const now = Date.now();
tokens[currentIndex].rateLimitedUntil = computeCooldownUntil(errorMessage);
const ignoreRL = opts?.ignoreRateLimits ?? false;
// Find next available token
for (let i = 1; i < tokens.length; i++) {
const idx = (currentIndex + i) % tokens.length;
const state = tokens[idx];
if (!state.rateLimitedUntil || state.rateLimitedUntil <= now) {
if (
ignoreRL ||
!state.rateLimitedUntil ||
state.rateLimitedUntil <= now
) {
state.rateLimitedUntil = null;
currentIndex = idx;
logger.info(
{ tokenIndex: currentIndex, totalTokens: tokens.length },
{ tokenIndex: currentIndex, totalTokens: tokens.length, ignoreRL },
`Rotated to token #${currentIndex + 1}/${tokens.length}`,
);
saveState();

View File

@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest';
import type { ClaudeAccountUsage } from './claude-usage.js';
vi.mock('./claude-usage.js', () => ({
fetchAllClaudeUsage: vi.fn(),
fetchAllClaudeProfiles: vi.fn(),
getClaudeProfile: (index: number) =>
index === 0
? { email: 'a@example.com', planType: 'max' }
: { email: 'b@example.com', planType: 'pro' },
}));
vi.mock('./codex-token-rotation.js', () => ({
getAllCodexAccounts: () => [],
updateCodexAccountUsage: vi.fn(),
}));
import {
buildClaudeUsageRows,
mergeClaudeDashboardAccounts,
} from './unified-dashboard.js';
describe('unified dashboard Claude usage rows', () => {
it('keeps both Claude accounts visible when one account usage is unavailable', () => {
const rows = buildClaudeUsageRows([
{
index: 0,
masked: 'tok-a',
isActive: true,
isRateLimited: false,
usage: {
five_hour: {
utilization: 0.4,
resets_at: '2026-03-24T04:00:00+09:00',
},
seven_day: {
utilization: 0.7,
resets_at: '2026-03-29T04:00:00+09:00',
},
},
},
{
index: 1,
masked: 'tok-b',
isActive: false,
isRateLimited: true,
usage: null,
},
]);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
name: 'Claude1* max',
h5pct: 40,
d7pct: 70,
});
expect(rows[1]).toMatchObject({
name: 'Claude2! pro',
h5pct: -1,
d7pct: -1,
});
});
it('preserves the last successful usage per account instead of collapsing to one cache entry', () => {
const cachedAccounts: ClaudeAccountUsage[] = [
{
index: 0,
masked: 'tok-a',
isActive: false,
isRateLimited: false,
usage: {
five_hour: {
utilization: 0.25,
resets_at: '2026-03-24T04:00:00+09:00',
},
seven_day: {
utilization: 0.5,
resets_at: '2026-03-29T04:00:00+09:00',
},
},
},
{
index: 1,
masked: 'tok-b',
isActive: true,
isRateLimited: false,
usage: {
five_hour: {
utilization: 0.6,
resets_at: '2026-03-24T06:00:00+09:00',
},
seven_day: {
utilization: 0.8,
resets_at: '2026-03-30T04:00:00+09:00',
},
},
},
];
const liveAccounts: ClaudeAccountUsage[] = [
{
index: 0,
masked: 'tok-a',
isActive: true,
isRateLimited: false,
usage: null,
},
{
index: 1,
masked: 'tok-b',
isActive: false,
isRateLimited: true,
usage: {
five_hour: {
utilization: 0.9,
resets_at: '2026-03-24T08:00:00+09:00',
},
seven_day: {
utilization: 0.95,
resets_at: '2026-03-31T04:00:00+09:00',
},
},
},
];
const merged = mergeClaudeDashboardAccounts(liveAccounts, cachedAccounts);
expect(merged).toHaveLength(2);
expect(merged[0]).toMatchObject({
index: 0,
isActive: true,
usage: cachedAccounts[0].usage,
});
expect(merged[1]).toMatchObject({
index: 1,
isRateLimited: true,
usage: liveAccounts[1].usage,
});
});
});

View File

@@ -5,11 +5,9 @@ import path from 'path';
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
import {
fetchClaudeUsage as fetchClaudeUsageApi,
fetchAllClaudeUsage,
fetchAllClaudeProfiles,
getClaudeProfile,
type ClaudeUsageData,
type ClaudeAccountUsage,
} from './claude-usage.js';
import {
@@ -24,7 +22,6 @@ import {
renderCategorizedRoomSections,
} from './dashboard-render.js';
import { getAllChats, updateRegisteredGroupName } from './db.js';
import { readEnvFile } from './env.js';
import type { GroupQueue } from './group-queue.js';
import { logger } from './logger.js';
import {
@@ -69,11 +66,8 @@ const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
let statusMessageId: string | null = null;
let cachedUsageContent = '';
let cachedClaudeUsageData: ClaudeUsageData | null = null;
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
let usageUpdateInProgress = false;
let usageApiBackoffUntil = 0;
let usageApi429Streak = 0;
let usageApiPollingDisabled = false;
let channelMetaCache = new Map<string, ChannelMeta>();
let channelMetaLastRefresh = 0;
@@ -135,22 +129,6 @@ export async function purgeDashboardChannel(
}
}
function parseRetryAfterMs(retryAfter: string | null): number | null {
if (!retryAfter) return null;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds > 0) {
return seconds * 1000;
}
const absolute = Date.parse(retryAfter);
if (!Number.isNaN(absolute)) {
return Math.max(0, absolute - Date.now());
}
return null;
}
async function refreshChannelMeta(
opts: UnifiedDashboardOptions,
): Promise<void> {
@@ -366,85 +344,6 @@ function buildStatusContent(): string {
return `${header}\n\n${sections}`;
}
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
if (usageApiPollingDisabled) {
logger.debug('Skipping usage API call (polling disabled for this process)');
return null;
}
if (Date.now() < usageApiBackoffUntil) {
logger.debug('Skipping usage API call (backoff active)');
return null;
}
const apiUsage = await fetchClaudeUsageApi();
if (apiUsage) {
usageApi429Streak = 0;
return apiUsage;
}
try {
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
let token =
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
envToken.CLAUDE_CODE_OAUTH_TOKEN ||
'';
if (!token) {
const configDir =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const credsPath = path.join(configDir, '.credentials.json');
if (!fs.existsSync(credsPath)) return null;
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
token = creds?.claudeAiOauth?.accessToken || '';
}
if (!token) return null;
const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
},
});
if (!res.ok) {
const body = await res.text().catch(() => '');
if (res.status === 429) {
const retryAfter = res.headers.get('retry-after');
const retryAfterMs = parseRetryAfterMs(retryAfter);
const backoffMs = Math.max(600_000, retryAfterMs ?? 0);
usageApi429Streak += 1;
usageApiBackoffUntil = Date.now() + backoffMs;
if (usageApi429Streak >= 3) {
usageApiPollingDisabled = true;
}
logger.warn(
{
status: 429,
retryAfter,
retryAfterMs,
backoffMs,
consecutive429s: usageApi429Streak,
pollingDisabled: usageApiPollingDisabled,
body: body.slice(0, 200),
},
usageApiPollingDisabled
? 'Usage API rate limited repeatedly (429), disabling usage polling for this process'
: 'Usage API rate limited (429), backing off',
);
} else {
logger.warn(
{ status: res.status, body: body.slice(0, 200) },
'Usage API returned non-OK status',
);
}
return null;
}
usageApi429Streak = 0;
return (await res.json()) as ClaudeUsageData;
} catch (err) {
logger.debug({ err }, 'Usage API fetch failed');
return null;
}
}
async function fetchCodexUsage(
codexHomeOverride?: string,
): Promise<CodexRateLimit[] | null> {
@@ -543,74 +442,46 @@ async function fetchCodexUsage(
});
}
async function buildUsageContent(): Promise<string> {
const activeSnapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
const hasActiveClaudeWork = activeSnapshots.some(
(snapshot) =>
snapshot.agentType === 'claude-code' &&
snapshot.entries.some(
(entry) => entry.status === 'processing' || entry.status === 'waiting',
),
type UsageRow = {
name: string;
h5pct: number;
h5reset: string;
d7pct: number;
d7reset: string;
};
export function mergeClaudeDashboardAccounts(
liveAccounts: ClaudeAccountUsage[] | null | undefined,
cachedAccounts: ClaudeAccountUsage[],
): ClaudeAccountUsage[] {
if (!liveAccounts) return cachedAccounts;
const cachedByIndex = new Map(
cachedAccounts.map((account) => [account.index, account]),
);
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
const [liveClaudeAccounts, codexUsage] = await Promise.all([
shouldFetchClaudeUsage && !hasActiveClaudeWork
? fetchAllClaudeUsage()
: Promise.resolve(null),
fetchCodexUsage(),
]);
return liveAccounts.map((account) => ({
...account,
usage: account.usage || cachedByIndex.get(account.index)?.usage || null,
}));
}
// Update cache with first account's data for backward compat
const claudeUsageIsCached =
shouldFetchClaudeUsage && !liveClaudeAccounts && !!cachedClaudeUsageData;
if (shouldFetchClaudeUsage && liveClaudeAccounts?.length) {
cachedClaudeUsageData = liveClaudeAccounts[0].usage;
}
export function buildClaudeUsageRows(
claudeAccounts: ClaudeAccountUsage[],
): UsageRow[] {
const isMultiAccount = claudeAccounts.length > 1;
const lines: string[] = ['📊 *사용량*'];
const bar = (pct: number) => {
const filled = Math.round(pct / 10);
return '█'.repeat(filled) + '░'.repeat(10 - filled);
};
type UsageRow = {
name: string;
h5pct: number;
h5reset: string;
d7pct: number;
d7reset: string;
};
const rows: UsageRow[] = [];
const claudeAccounts =
liveClaudeAccounts ||
(cachedClaudeUsageData
? [
{
index: 0,
masked: '',
isActive: true,
isRateLimited: false,
usage: cachedClaudeUsageData,
},
]
: []);
for (const account of claudeAccounts) {
const u = account.usage;
if (!u) continue;
const h5 = u.five_hour;
const d7 = u.seven_day;
return claudeAccounts.map((account) => {
const usage = account.usage;
const h5 = usage?.five_hour;
const d7 = usage?.seven_day;
const profile = getClaudeProfile(account.index);
const planSuffix = profile ? ` ${profile.planType}` : '';
const label =
claudeAccounts.length > 1
? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`
: claudeUsageIsCached
? `Claude*${planSuffix}`
: `Claude${planSuffix}`;
rows.push({
const label = isMultiAccount
? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`
: `Claude${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`;
return {
name: label,
h5pct: h5
? h5.utilization > 1
@@ -624,7 +495,38 @@ async function buildUsageContent(): Promise<string> {
: Math.round(d7.utilization * 100)
: -1,
d7reset: d7 ? formatResetRemaining(d7.resets_at) : '',
});
};
});
}
async function buildUsageContent(): Promise<string> {
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
let liveClaudeAccounts: ClaudeAccountUsage[] | null = null;
const codexUsagePromise = fetchCodexUsage();
if (shouldFetchClaudeUsage) {
try {
liveClaudeAccounts = await fetchAllClaudeUsage();
} catch (err) {
logger.warn({ err }, 'Failed to fetch Claude usage for dashboard');
}
}
const codexUsage = await codexUsagePromise;
const lines: string[] = ['📊 *사용량*'];
const bar = (pct: number) => {
const filled = Math.round(pct / 10);
return '█'.repeat(filled) + '░'.repeat(10 - filled);
};
const rows: UsageRow[] = [];
if (shouldFetchClaudeUsage) {
cachedClaudeAccounts = mergeClaudeDashboardAccounts(
liveClaudeAccounts,
cachedClaudeAccounts,
);
rows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
}
const codexAccounts = getAllCodexAccounts();
@@ -712,14 +614,6 @@ async function buildUsageContent(): Promise<string> {
lines.push('_조회 불가_');
}
if (shouldFetchClaudeUsage && usageApiPollingDisabled) {
lines.push(
'_* Claude 사용량 조회는 반복된 429로 이번 프로세스에서 일시 중지_',
);
}
if (claudeUsageIsCached) {
lines.push('_* Claude 사용량은 작업 중일 때는 캐시값 유지_');
}
lines.push('');
lines.push('🖥️ *서버*');