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>
This commit is contained in:
Eyejoker
2026-03-25 17:24:10 +09:00
parent 3e1e032346
commit 60a9fe86ec
11 changed files with 372 additions and 16 deletions

View File

@@ -54,13 +54,26 @@ export function isClaudeAuthExpiredMessage(text: string): boolean {
);
}
export function isClaudeOrgAccessDeniedMessage(text: string): boolean {
const normalized = text.trim().toLowerCase().replace(/\s+/g, ' ');
const hasOrgAccessDeniedMarker = normalized.includes(
'does not have access to claude',
);
const hasRecoveryHint =
normalized.includes('please login again') ||
normalized.includes('contact your administrator');
return hasOrgAccessDeniedMarker && hasRecoveryHint;
}
// ── Rotation decision ───────────────────────────────────────────
export function shouldRotateClaudeToken(reason: string): boolean {
return (
reason === '429' ||
reason === 'usage-exhausted' ||
reason === 'auth-expired'
reason === 'auth-expired' ||
reason === 'org-access-denied'
);
}
@@ -69,13 +82,14 @@ export function shouldRotateClaudeToken(reason: string): boolean {
export type ErrorCategory =
| 'rate-limit'
| 'auth-expired'
| 'org-access-denied'
| 'overloaded'
| 'network-error'
| 'none';
export interface AgentErrorClassification {
category: ErrorCategory;
reason: string; // '429' | 'auth-expired' | 'overloaded' | 'network-error' | ''
reason: string; // '429' | 'auth-expired' | 'org-access-denied' | 'overloaded' | 'network-error' | ''
retryAfterMs?: number;
}
@@ -142,6 +156,19 @@ export function classifyClaudeAuthError(
if (!error) return NONE;
const lower = error.toLowerCase();
const hasOrgAccessDeniedMarker =
lower.includes('your organization does not have access to claude') ||
(lower.includes('does not have access to claude') &&
lower.includes('contact your administrator'));
const hasTerminated403AuthFailure =
lower.includes('failed to authenticate') &&
lower.includes('403') &&
lower.includes('terminated');
if (hasOrgAccessDeniedMarker || hasTerminated403AuthFailure) {
return { category: 'org-access-denied', reason: 'org-access-denied' };
}
if (
(lower.includes('failed to authenticate') ||
lower.includes('authentication_error')) &&