When a Claude account is suspended, the API returns "Your organization does not have access to Claude" on the success path or "Failed to authenticate. API Error: 403 terminated" on the error path. Both are now classified as 'org-access-denied', suppressed from Discord output, and trigger automatic token rotation. If all tokens are exhausted, enters cooldown without Kimi fallback (same as usage-exhausted and auth-expired). Changes: - agent-error-detection: add isClaudeOrgAccessDeniedMessage(), expand classifyClaudeAuthError() and shouldRotateClaudeToken() - streamed-output-evaluator: detect org-access-denied in success-path chain - provider-fallback: add NO_FALLBACK_COOLDOWN_REASONS set and isPrimaryNoFallbackCooldownActive() helper - provider-retry: handle org-access-denied in rotation loop - message-agent-executor / task-scheduler: use generalized no-fallback cooldown check - Tests: +8 test cases across 5 test files (415 total passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
135 lines
3.5 KiB
TypeScript
135 lines
3.5 KiB
TypeScript
import {
|
|
isClaudeAuthError,
|
|
isClaudeAuthExpiredMessage,
|
|
isClaudeOrgAccessDeniedMessage,
|
|
isClaudeUsageExhaustedMessage,
|
|
} from './agent-error-detection.js';
|
|
import type { AgentOutput } from './agent-runner.js';
|
|
import { detectCodexRotationTrigger } from './codex-token-rotation.js';
|
|
import { detectFallbackTrigger } from './provider-fallback.js';
|
|
|
|
export interface StreamedTriggerReason {
|
|
reason: string;
|
|
retryAfterMs?: number;
|
|
}
|
|
|
|
export interface StreamedOutputState {
|
|
sawOutput: boolean;
|
|
sawSuccessNullResultWithoutOutput: boolean;
|
|
streamedTriggerReason?: StreamedTriggerReason;
|
|
}
|
|
|
|
export interface EvaluateStreamedOutputOptions {
|
|
agentType: 'claude-code' | 'codex';
|
|
provider: string;
|
|
suppressClaudeAuthErrorOutput?: boolean;
|
|
trackSuccessNullResult?: boolean;
|
|
shortCircuitTriggeredErrors?: boolean;
|
|
}
|
|
|
|
export interface EvaluateStreamedOutputResult {
|
|
state: StreamedOutputState;
|
|
shouldForwardOutput: boolean;
|
|
newTrigger?: StreamedTriggerReason;
|
|
suppressedAuthError?: boolean;
|
|
}
|
|
|
|
export function evaluateStreamedOutput(
|
|
output: AgentOutput,
|
|
state: StreamedOutputState,
|
|
options: EvaluateStreamedOutputOptions,
|
|
): EvaluateStreamedOutputResult {
|
|
const nextState: StreamedOutputState = { ...state };
|
|
const isPrimaryClaude =
|
|
options.agentType === 'claude-code' && options.provider === 'claude';
|
|
const isPrimaryCodex =
|
|
options.agentType === 'codex' && options.provider === 'codex';
|
|
|
|
if (
|
|
isPrimaryClaude &&
|
|
output.status === 'success' &&
|
|
!state.sawOutput &&
|
|
typeof output.result === 'string'
|
|
) {
|
|
const triggerReason = isClaudeUsageExhaustedMessage(output.result)
|
|
? 'usage-exhausted'
|
|
: isClaudeOrgAccessDeniedMessage(output.result)
|
|
? 'org-access-denied'
|
|
: isClaudeAuthExpiredMessage(output.result)
|
|
? 'auth-expired'
|
|
: undefined;
|
|
|
|
if (triggerReason) {
|
|
const newTrigger = nextState.streamedTriggerReason
|
|
? undefined
|
|
: { reason: triggerReason };
|
|
nextState.streamedTriggerReason =
|
|
nextState.streamedTriggerReason ?? newTrigger;
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: false,
|
|
newTrigger,
|
|
};
|
|
}
|
|
|
|
if (
|
|
options.suppressClaudeAuthErrorOutput &&
|
|
isClaudeAuthError(output.result)
|
|
) {
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: false,
|
|
suppressedAuthError: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (output.result !== null && output.result !== undefined) {
|
|
nextState.sawOutput = true;
|
|
} else if (
|
|
options.trackSuccessNullResult &&
|
|
isPrimaryClaude &&
|
|
output.status === 'success' &&
|
|
!state.sawOutput
|
|
) {
|
|
nextState.sawSuccessNullResultWithoutOutput = true;
|
|
}
|
|
|
|
if (
|
|
output.status === 'error' &&
|
|
!nextState.sawOutput &&
|
|
!nextState.streamedTriggerReason
|
|
) {
|
|
let newTrigger: StreamedTriggerReason | undefined;
|
|
|
|
if (isPrimaryClaude) {
|
|
const trigger = detectFallbackTrigger(output.error);
|
|
if (trigger.shouldFallback) {
|
|
newTrigger = {
|
|
reason: trigger.reason,
|
|
retryAfterMs: trigger.retryAfterMs,
|
|
};
|
|
}
|
|
} else if (isPrimaryCodex) {
|
|
const trigger = detectCodexRotationTrigger(output.error);
|
|
if (trigger.shouldRotate) {
|
|
newTrigger = { reason: trigger.reason };
|
|
}
|
|
}
|
|
|
|
if (newTrigger) {
|
|
nextState.streamedTriggerReason = newTrigger;
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: !options.shortCircuitTriggeredErrors,
|
|
newTrigger,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: true,
|
|
};
|
|
}
|