* auto-claude: subtask-1-1 - Add BILLING_FAILURE_PATTERNS regex array with patterns for credit/billing errors Added comprehensive regex patterns to detect billing and credit failures: - Credit balance patterns (insufficient, low, empty, exhausted) - Billing error patterns (payment failed, subscription expired) - Usage quota patterns (monthly limits, plan limits) - API error patterns (billing_error, insufficient_credits, 402) - Balance/funds patterns and add credits messages * auto-claude: subtask-1-2 - Add BillingFailureDetectionResult interface parallel to AuthFailureDetectionResult Add new TypeScript interface for billing failure detection that mirrors the AuthFailureDetectionResult pattern with: - isBillingFailure: boolean flag - profileId: optional profile identifier - failureType: specific billing failure types (insufficient_credits, payment_required, subscription_inactive, unknown) - message: user-friendly error message - originalError: raw error from process output * auto-claude: subtask-1-3 - Add classifyBillingFailureType() and getBillingFailureMessage() helpers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-4 - Add detectBillingFailure() function to detect billing errors in output Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Add BillingFailureInfo interface to terminal.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-1 - Add onBillingFailure callback to SubprocessOptions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-2 - Add checkBillingFailure helper function in runPythonSubprocess Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-3 - Integrate checkBillingFailure into stdout/stderr handlers and close handler - Added checkBillingFailure(line) call after checkAuthFailure(line) in stdout handler - Added checkBillingFailure(line) call after checkAuthFailure(line) in stderr handler - Added killedDueToBillingFailure check in close handler with proper error message - Follows the exact same pattern as auth failure detection integration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: billing detection patterns and add unit tests (qa-requested) Fixes: - Fix regex for "credit balance is too low" by adding optional (too\s+)? group - Add extra_usage pattern to BILLING_FAILURE_PATTERNS for Claude API errors - Fix 402 false positive by requiring HTTP/status/code/error context prefix - Add 31 unit tests for detectBillingFailure, isBillingFailureError, and classifyBillingFailureType covering spec appendix messages, negative cases, false positive checks, and cross-detection tests Verified: - All 84 rate-limit-detector tests pass (53 existing + 31 new) - TypeScript compilation succeeds with zero errors - Full test suite passes (2599/2599 + 6 skipped) QA Fix Session: 1 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -609,3 +609,392 @@ Please authenticate and try again.`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing Failure Detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - spec appendix messages', () => {
|
||||
it('should detect "Your credit balance is too low to access the Anthropic API"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('Your credit balance is too low to access the Anthropic API');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "insufficient credits"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('insufficient credits');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
});
|
||||
|
||||
it('should detect "billing error"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('billing error');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('payment_required');
|
||||
});
|
||||
|
||||
it('should detect "extra_usage exceeded"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('extra_usage exceeded');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
});
|
||||
|
||||
it('should detect standalone "extra_usage"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('extra_usage');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "payment required"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('payment required');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('payment_required');
|
||||
});
|
||||
|
||||
it('should detect "subscription expired"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('subscription expired');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('subscription_inactive');
|
||||
});
|
||||
|
||||
it('should detect credit balance variations', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const messages = [
|
||||
'credit balance is insufficient',
|
||||
'credit balance is empty',
|
||||
'credit balance is zero',
|
||||
'credit balance is exhausted',
|
||||
'credit balance is too low',
|
||||
];
|
||||
|
||||
for (const msg of messages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - negative cases', () => {
|
||||
it('should return false for normal output', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const normalMessages = [
|
||||
'Task completed successfully',
|
||||
'Processing 100 files',
|
||||
'Build succeeded',
|
||||
'Deploying to production',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const msg of normalMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect rate limit errors as billing failures', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const rateLimitMessages = [
|
||||
'Limit reached · resets Dec 17 at 6am',
|
||||
'rate limit exceeded',
|
||||
'too many requests',
|
||||
];
|
||||
|
||||
for (const msg of rateLimitMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect auth errors as billing failures', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const authMessages = [
|
||||
'authentication required',
|
||||
'not authenticated',
|
||||
'unauthorized',
|
||||
'invalid token',
|
||||
'session expired',
|
||||
];
|
||||
|
||||
for (const msg of authMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT match "line 402" as billing failure (false positive check)', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const falsePositives = [
|
||||
'line 402',
|
||||
'Processing line 402 of 1000',
|
||||
'Found 4020 records',
|
||||
'Error on line 402',
|
||||
'The user has 402 files',
|
||||
];
|
||||
|
||||
for (const msg of falsePositives) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - 402 with proper context', () => {
|
||||
it('should detect "HTTP 402 Payment Required"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('HTTP 402 Payment Required');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "API Error: 402"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('API Error: 402');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "status: 402"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('status: 402');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "error 402"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('error 402');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "402 payment required" (lowercase)', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('402 payment required');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBillingFailureError', () => {
|
||||
it('should return true for billing failure errors', async () => {
|
||||
const { isBillingFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isBillingFailureError('credit balance is too low')).toBe(true);
|
||||
expect(isBillingFailureError('insufficient credits')).toBe(true);
|
||||
expect(isBillingFailureError('billing error')).toBe(true);
|
||||
expect(isBillingFailureError('extra_usage exceeded')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-billing errors', async () => {
|
||||
const { isBillingFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isBillingFailureError('Task completed')).toBe(false);
|
||||
expect(isBillingFailureError('')).toBe(false);
|
||||
expect(isBillingFailureError('rate limit exceeded')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyBillingFailureType via detectBillingFailure', () => {
|
||||
it('should classify credit-related failures as insufficient_credits', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const creditMessages = [
|
||||
'Your credit balance is too low',
|
||||
'insufficient credits',
|
||||
'out of credits',
|
||||
'extra_usage exceeded',
|
||||
];
|
||||
|
||||
for (const msg of creditMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
}
|
||||
});
|
||||
|
||||
it('should classify payment failures as payment_required', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const paymentMessages = [
|
||||
'payment required',
|
||||
'billing error',
|
||||
'billing failure',
|
||||
];
|
||||
|
||||
for (const msg of paymentMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('payment_required');
|
||||
}
|
||||
});
|
||||
|
||||
it('should classify subscription failures as subscription_inactive', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const subscriptionMessages = [
|
||||
'subscription expired',
|
||||
'subscription inactive',
|
||||
'subscription cancelled',
|
||||
'account suspended',
|
||||
];
|
||||
|
||||
for (const msg of subscriptionMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('subscription_inactive');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - result structure', () => {
|
||||
it('should include profile ID in result', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('credit balance is too low', 'custom-profile');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.profileId).toBe('custom-profile');
|
||||
});
|
||||
|
||||
it('should use active profile ID when not specified', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('credit balance is too low');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.profileId).toBe('test-profile-id');
|
||||
});
|
||||
|
||||
it('should include original error in result', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: credit balance is too low to access the API';
|
||||
const result = detectBillingFailure(output);
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.originalError).toBe(output);
|
||||
});
|
||||
|
||||
it('should include user-friendly message', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('credit balance is too low');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.message).toContain('credit');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-detection tests', () => {
|
||||
it('should not detect billing errors as auth failures', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const billingMessages = [
|
||||
'credit balance is too low',
|
||||
'insufficient credits',
|
||||
'billing error',
|
||||
'extra_usage exceeded',
|
||||
'payment required',
|
||||
];
|
||||
|
||||
for (const msg of billingMessages) {
|
||||
const result = detectAuthFailure(msg);
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not detect billing errors as rate limits', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const billingMessages = [
|
||||
'credit balance is too low',
|
||||
'insufficient credits',
|
||||
'billing error',
|
||||
'extra_usage exceeded',
|
||||
];
|
||||
|
||||
for (const msg of billingMessages) {
|
||||
const result = detectRateLimit(msg);
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle case-insensitive billing messages', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'CREDIT BALANCE IS TOO LOW',
|
||||
'Credit Balance Is Too Low',
|
||||
'BILLING ERROR',
|
||||
'Insufficient Credits',
|
||||
'EXTRA_USAGE EXCEEDED',
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectBillingFailure(output);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiline output with billing failure', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Starting API call...
|
||||
Processing request...
|
||||
Error: Your credit balance is too low to access the Anthropic API
|
||||
Please add credits to continue.`;
|
||||
|
||||
const result = detectBillingFailure(output);
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle JSON error responses with billing failure', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = '{"type":"billing_error","message":"Insufficient credits"}';
|
||||
const result = detectBillingFailure(output);
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ import fs from 'fs';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
import type { Project } from '../../../../shared/types';
|
||||
import type { AuthFailureInfo } from '../../../../shared/types/terminal';
|
||||
import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/types/terminal';
|
||||
import { parsePythonCommand } from '../../../python-detector';
|
||||
import { detectAuthFailure } from '../../../rate-limit-detector';
|
||||
import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector';
|
||||
import { getClaudeProfileManager } from '../../../claude-profile-manager';
|
||||
import { isWindows, isMacOS } from '../../../platform';
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface SubprocessOptions {
|
||||
onError?: (error: string) => void;
|
||||
/** Callback when auth failure (401) is detected in output */
|
||||
onAuthFailure?: (authFailureInfo: AuthFailureInfo) => void;
|
||||
/** Callback when billing/credit exhaustion failure is detected in output */
|
||||
onBillingFailure?: (billingFailureInfo: BillingFailureInfo) => void;
|
||||
progressPattern?: RegExp;
|
||||
/** Additional environment variables to pass to the subprocess */
|
||||
env?: Record<string, string>;
|
||||
@@ -130,6 +132,8 @@ export function runPythonSubprocess<T = unknown>(
|
||||
let stderr = '';
|
||||
let authFailureEmitted = false; // Track if we've already emitted an auth failure
|
||||
let killedDueToAuthFailure = false; // Track if subprocess was killed due to auth failure
|
||||
let billingFailureEmitted = false; // Track if we've already emitted a billing failure
|
||||
let killedDueToBillingFailure = false; // Track if subprocess was killed due to billing failure
|
||||
|
||||
// Default progress pattern: [ 30%] message OR [30%] message
|
||||
const progressPattern = options.progressPattern ?? /\[\s*(\d+)%\]\s*(.+)/;
|
||||
@@ -193,6 +197,65 @@ export function runPythonSubprocess<T = unknown>(
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to check for billing/credit failures in output and emit once
|
||||
const checkBillingFailure = (line: string) => {
|
||||
if (billingFailureEmitted || !options.onBillingFailure) return;
|
||||
|
||||
const billingResult = detectBillingFailure(line);
|
||||
if (billingResult.isBillingFailure) {
|
||||
billingFailureEmitted = true;
|
||||
console.log('[SubprocessRunner] Billing failure detected in real-time:', billingResult);
|
||||
|
||||
// Get profile info for display
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = billingResult.profileId
|
||||
? profileManager.getProfile(billingResult.profileId)
|
||||
: profileManager.getActiveProfile();
|
||||
|
||||
const billingFailureInfo: BillingFailureInfo = {
|
||||
profileId: billingResult.profileId || profile?.id || 'unknown',
|
||||
profileName: profile?.name,
|
||||
failureType: billingResult.failureType || 'unknown',
|
||||
message: billingResult.message || 'Billing or credit error. Please check your account.',
|
||||
originalError: billingResult.originalError,
|
||||
detectedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
options.onBillingFailure(billingFailureInfo);
|
||||
} catch (e) {
|
||||
console.error('[SubprocessRunner] onBillingFailure callback threw:', e);
|
||||
}
|
||||
|
||||
// Kill the subprocess to stop the billing failure spam
|
||||
killedDueToBillingFailure = true;
|
||||
// The process is stuck in billing errors - no point continuing
|
||||
console.log('[SubprocessRunner] Killing subprocess due to billing failure, pid:', child.pid);
|
||||
|
||||
// Use process.kill with negative PID to kill the entire process group on Unix
|
||||
// This ensures child processes (like the Claude SDK subprocess) are also killed
|
||||
if (child.pid) {
|
||||
try {
|
||||
// On Unix, negative PID kills the process group
|
||||
if (!isWindows()) {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} else {
|
||||
// On Windows, use taskkill to kill the process tree
|
||||
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], (err: Error | null) => {
|
||||
if (err) console.warn('[SubprocessRunner] taskkill error (process may have already exited):', err.message);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to regular kill if process group kill fails
|
||||
console.log('[SubprocessRunner] Process group kill failed, using regular kill:', err);
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
const text = data.toString('utf-8');
|
||||
stdout += text;
|
||||
@@ -206,6 +269,9 @@ export function runPythonSubprocess<T = unknown>(
|
||||
// Check for auth failures in real-time (only emit once)
|
||||
checkAuthFailure(line);
|
||||
|
||||
// Check for billing/credit failures in real-time (only emit once)
|
||||
checkBillingFailure(line);
|
||||
|
||||
// Parse progress updates
|
||||
const match = line.match(progressPattern);
|
||||
if (match && options.onProgress) {
|
||||
@@ -228,6 +294,9 @@ export function runPythonSubprocess<T = unknown>(
|
||||
|
||||
// Also check stderr for auth failures
|
||||
checkAuthFailure(line);
|
||||
|
||||
// Also check stderr for billing/credit failures
|
||||
checkBillingFailure(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -260,6 +329,18 @@ export function runPythonSubprocess<T = unknown>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if subprocess was killed due to billing/credit failure
|
||||
if (killedDueToBillingFailure) {
|
||||
resolve({
|
||||
success: false,
|
||||
exitCode: exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
error: 'Billing or credit error. Please check your account.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitCode === 0) {
|
||||
try {
|
||||
const data = options.onComplete?.(stdout, stderr);
|
||||
|
||||
@@ -53,6 +53,45 @@ const AUTH_FAILURE_PATTERNS = [
|
||||
/·\s*Please\s+run\s+\/login/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that indicate billing/credit failures
|
||||
* These patterns detect when Claude API fails due to insufficient credits or billing issues
|
||||
*/
|
||||
const BILLING_FAILURE_PATTERNS = [
|
||||
// Credit balance patterns
|
||||
/credit\s*balance\s*(is\s+)?(too\s+)?(insufficient|low|empty|zero|exhausted)/i,
|
||||
/insufficient\s*credit(s)?/i,
|
||||
/no\s*(remaining\s*)?credit(s)?/i,
|
||||
/credit(s)?\s*(are\s*)?(exhausted|depleted|used\s*up)/i,
|
||||
/out\s*of\s*credit(s)?/i,
|
||||
/credit\s*limit\s*(reached|exceeded)/i,
|
||||
// Billing error patterns
|
||||
/billing\s*(error|issue|problem|failure)/i,
|
||||
/payment\s*(required|failed|issue|problem)/i,
|
||||
/subscription\s*(expired|inactive|cancelled|canceled)/i,
|
||||
/account\s*(suspended|inactive)\s*(due\s*to\s*billing)?/i,
|
||||
// Usage limit patterns (billing-related, not rate limits)
|
||||
/usage\s*quota\s*(exceeded|reached)/i,
|
||||
/monthly\s*(usage\s*)?(limit|quota)\s*(exceeded|reached)/i,
|
||||
/plan\s*(limit|quota)\s*(exceeded|reached)/i,
|
||||
// API error patterns for billing
|
||||
/["']?type["']?\s*:\s*["']?billing_error["']?/i,
|
||||
/["']?type["']?\s*:\s*["']?insufficient_credits["']?/i,
|
||||
/["']?error["']?\s*:\s*["']?insufficient_credits["']?/i,
|
||||
// extra_usage patterns from Claude API
|
||||
/extra_usage\s*(exceeded|limit|error)?/i,
|
||||
// Match HTTP 402 Payment Required (require context to avoid false positives on "line 402" etc.)
|
||||
/(?:HTTP|status|code|error)\s*:?\s*402\b/i,
|
||||
/\b402\s+payment\s+required/i,
|
||||
/API\s*Error:\s*402/i,
|
||||
// Balance/funds patterns
|
||||
/insufficient\s*(funds|balance)/i,
|
||||
/balance\s*(is\s*)?(zero|empty|insufficient)/i,
|
||||
// Add funds/credits messages
|
||||
/please\s*(add|purchase)\s*(more\s*)?(credits?|funds)/i,
|
||||
/top\s*up\s*(your\s*)?(account|credits|balance)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Result of rate limit detection
|
||||
*/
|
||||
@@ -90,6 +129,22 @@ export interface AuthFailureDetectionResult {
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of billing failure detection
|
||||
*/
|
||||
export interface BillingFailureDetectionResult {
|
||||
/** Whether a billing failure was detected */
|
||||
isBillingFailure: boolean;
|
||||
/** The profile ID that has billing issues (if known) */
|
||||
profileId?: string;
|
||||
/** The type of billing failure detected */
|
||||
failureType?: 'insufficient_credits' | 'payment_required' | 'subscription_inactive' | 'unknown';
|
||||
/** User-friendly message describing the failure */
|
||||
message?: string;
|
||||
/** Original error message from the process output */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify rate limit type based on reset time string
|
||||
*/
|
||||
@@ -216,6 +271,44 @@ function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' |
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the type of billing failure based on the error message
|
||||
*/
|
||||
function classifyBillingFailureType(output: string): 'insufficient_credits' | 'payment_required' | 'subscription_inactive' | 'unknown' {
|
||||
const lowerOutput = output.toLowerCase();
|
||||
|
||||
// Check for credit-related failures (including extra_usage which indicates usage exhaustion)
|
||||
if (/credit\s*(balance|s)?|insufficient\s*(credit|funds|balance)|out\s*of\s*credit|no\s*(remaining\s*)?credit|extra_usage/.test(lowerOutput)) {
|
||||
return 'insufficient_credits';
|
||||
}
|
||||
// Check for subscription-related failures
|
||||
if (/subscription\s*(expired|inactive|cancelled|canceled)|account\s*(suspended|inactive)/.test(lowerOutput)) {
|
||||
return 'subscription_inactive';
|
||||
}
|
||||
// Check for payment-related failures
|
||||
if (/payment\s*(required|failed)|402|billing\s*(error|issue|problem|failure)/.test(lowerOutput)) {
|
||||
return 'payment_required';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for the billing failure
|
||||
*/
|
||||
function getBillingFailureMessage(failureType: 'insufficient_credits' | 'payment_required' | 'subscription_inactive' | 'unknown'): string {
|
||||
switch (failureType) {
|
||||
case 'insufficient_credits':
|
||||
return 'Your Claude API credit balance is too low. Please add credits to your account or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'payment_required':
|
||||
return 'A billing error occurred with your Claude API account. Please check your payment method or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'subscription_inactive':
|
||||
return 'Your Claude API subscription is inactive or expired. Please renew your subscription or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'A billing issue was detected with your Claude API account. Please check your account status or switch to another profile in Settings > Claude Profiles.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect authentication failure from output (stdout + stderr combined)
|
||||
*/
|
||||
@@ -255,6 +348,48 @@ export function isAuthFailureError(output: string): boolean {
|
||||
return detectAuthFailure(output).isAuthFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect billing failure from output (stdout + stderr combined)
|
||||
*/
|
||||
export function detectBillingFailure(
|
||||
output: string,
|
||||
profileId?: string
|
||||
): BillingFailureDetectionResult {
|
||||
// First, make sure this isn't a rate limit or auth error (those should be handled separately)
|
||||
if (detectRateLimit(output).isRateLimited) {
|
||||
return { isBillingFailure: false };
|
||||
}
|
||||
if (detectAuthFailure(output).isAuthFailure) {
|
||||
return { isBillingFailure: false };
|
||||
}
|
||||
|
||||
// Check for billing failure patterns
|
||||
for (const pattern of BILLING_FAILURE_PATTERNS) {
|
||||
if (pattern.test(output)) {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const effectiveProfileId = profileId || profileManager.getActiveProfile().id;
|
||||
const failureType = classifyBillingFailureType(output);
|
||||
|
||||
return {
|
||||
isBillingFailure: true,
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getBillingFailureMessage(failureType),
|
||||
originalError: output
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isBillingFailure: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains billing failure error
|
||||
*/
|
||||
export function isBillingFailureError(output: string): boolean {
|
||||
return detectBillingFailure(output).isBillingFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for a specific Claude profile.
|
||||
*
|
||||
|
||||
@@ -157,6 +157,29 @@ export interface AuthFailureInfo {
|
||||
detectedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing/credit exhaustion failure information for SDK/CLI operations.
|
||||
* Emitted when Claude API returns billing-related errors (HTTP 400 with
|
||||
* invalid_request_error), indicating the account has insufficient credits,
|
||||
* exceeded usage limits, or has a billing configuration issue.
|
||||
*/
|
||||
export interface BillingFailureInfo {
|
||||
/** The profile ID that has billing issues */
|
||||
profileId: string;
|
||||
/** The profile name for display */
|
||||
profileName?: string;
|
||||
/** Type of billing failure */
|
||||
failureType: 'insufficient_credits' | 'payment_required' | 'subscription_inactive' | 'unknown';
|
||||
/** User-friendly message describing the failure */
|
||||
message: string;
|
||||
/** Original error message from the process output */
|
||||
originalError?: string;
|
||||
/** Task ID if applicable (for task-related billing failures) */
|
||||
taskId?: string;
|
||||
/** When detected (Note: serialized as ISO string over IPC) */
|
||||
detectedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to retry a rate-limited operation with a different profile
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user