From 7bf12e8566da37e703035bd663265f491869725d Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:46:23 +0100 Subject: [PATCH] Surface Billing/Credit Exhaustion Errors to UI (Issue #1580) (#1617) * 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 * auto-claude: subtask-1-4 - Add detectBillingFailure() function to detect billing errors in output Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-1 - Add BillingFailureInfo interface to terminal.ts Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-1 - Add onBillingFailure callback to SubprocessOptions Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-2 - Add checkBillingFailure helper function in runPythonSubprocess Co-Authored-By: Claude Opus 4.5 * 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 * 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 --------- Co-authored-by: Claude Opus 4.5 --- .../__tests__/rate-limit-detector.test.ts | 389 ++++++++++++++++++ .../github/utils/subprocess-runner.ts | 85 +++- apps/frontend/src/main/rate-limit-detector.ts | 135 ++++++ apps/frontend/src/shared/types/terminal.ts | 23 ++ 4 files changed, 630 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts b/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts index f2ed57c7..32b6cf27 100644 --- a/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts +++ b/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts @@ -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); + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts index 74a61e6a..3bf2efe1 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts @@ -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; @@ -130,6 +132,8 @@ export function runPythonSubprocess( 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( } }; + // 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( // 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( // Also check stderr for auth failures checkAuthFailure(line); + + // Also check stderr for billing/credit failures + checkBillingFailure(line); } } }); @@ -260,6 +329,18 @@ export function runPythonSubprocess( 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); diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index efb84840..5a4dba9b 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -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. * diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index 20a50d1d..0e921353 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -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 */