Files
EJClaw/src/streamed-output-evaluator.ts
Eyejoker 60a9fe86ec fix: detect org-access-denied errors, suppress chat output, and rotate Claude tokens
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>
2026-03-25 17:24:10 +09:00

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