diff --git a/src/services/api/errors.planProvisioning.test.ts b/src/services/api/errors.planProvisioning.test.ts new file mode 100644 index 0000000000..189a35f98b --- /dev/null +++ b/src/services/api/errors.planProvisioning.test.ts @@ -0,0 +1,117 @@ +import { APIError } from '@anthropic-ai/sdk' +import { afterEach, expect, test } from 'bun:test' + +import { + CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, + getAssistantMessageFromError, + PLAN_ACCESS_PENDING_ERROR_MESSAGE, + PLAN_PROVISIONING_ERROR_MESSAGE, +} from './errors.js' + +const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL + +afterEach(() => { + if (originalAnthropicBaseUrl === undefined) { + delete process.env.ANTHROPIC_BASE_URL + } else { + process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl + } +}) + +function getFirstText( + message: ReturnType, +): string { + const first = message.message.content[0] + if (!first || typeof first !== 'object' || !('text' in first)) { + return '' + } + return typeof first.text === 'string' ? first.text : '' +} + +test('typed Verboo provisioning code reports plan activation', () => { + const error = APIError.generate( + 401, + { + error: { + code: 'plan_provisioning', + message: 'Plan entitlement is still provisioning', + }, + }, + undefined, + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'deepseek-v4-pro') + const text = getFirstText(message) + + expect(message.isApiErrorMessage).toBe(true) + expect(message.error).toBe('billing_error') + expect(text).toBe(PLAN_PROVISIONING_ERROR_MESSAGE) + expect(text).not.toContain('/login') +}) + +test('legacy exact Verboo credit message uses neutral pending copy', () => { + const error = APIError.generate( + 401, + { error: 'Not Enough Credits' }, + undefined, + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'deepseek-v4-pro') + + expect(message.error).toBe('billing_error') + expect(getFirstText(message)).toBe(PLAN_ACCESS_PENDING_ERROR_MESSAGE) +}) + +test('external provider 401 is never described as Verboo provisioning', () => { + process.env.ANTHROPIC_BASE_URL = 'https://provider.example/v1' + const error = APIError.generate( + 401, + { error: 'Not Enough Credits' }, + undefined, + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'external-model') + const text = getFirstText(message) + + expect(text).not.toContain(PLAN_PROVISIONING_ERROR_MESSAGE) + expect(text).not.toContain(PLAN_ACCESS_PENDING_ERROR_MESSAGE) + expect(message.error).toBe('authentication_failed') +}) + +test('typed exhausted credits stay a billing error without activation copy', () => { + const error = APIError.generate( + 401, + { + error: { + code: 'insufficient_credits', + message: 'No credits remain', + }, + }, + undefined, + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'deepseek-v4-pro') + + expect(message.error).toBe('billing_error') + expect(getFirstText(message)).toBe(CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE) +}) + +test('real authentication 401 still directs the user to login', () => { + const error = APIError.generate( + 401, + undefined, + 'Invalid bearer token', + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'deepseek-v4-pro') + const text = getFirstText(message) + + expect(message.isApiErrorMessage).toBe(true) + expect(message.error).toBe('authentication_failed') + expect(text).toContain('/login') +}) diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index b2e00b95d2..19b0a51516 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -284,6 +284,10 @@ export function isMediaSizeErrorMessage(msg: AssistantMessage): boolean { ) } export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low' +export const PLAN_PROVISIONING_ERROR_MESSAGE = + "Your plan is being activated. This can take a minute or two after an upgrade, so please wait a moment and try again. You don't need to log in." +export const PLAN_ACCESS_PENDING_ERROR_MESSAGE = + "We couldn't confirm access to your plan yet. If you upgraded recently, activation can take a minute or two. Please wait a moment and try again; if this continues, contact support." export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login' export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL = 'Invalid API key · Fix external API key' @@ -305,6 +309,46 @@ export function getCustomOffSwitchMessage(): string { export const CUSTOM_OFF_SWITCH_MESSAGE = 'Opus is experiencing high load, please use /model to switch to Sonnet' export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out' + +function getAPIErrorBodyField( + error: APIError, + field: 'code' | 'error_code' | 'message', +): string | undefined { + const body = error.error + if (!body || typeof body !== 'object') return undefined + + const record = body as Record + const nestedError = record.error + if (nestedError && typeof nestedError === 'object') { + const nestedValue = (nestedError as Record)[field] + if (typeof nestedValue === 'string') return nestedValue + } + + const value = record[field] + if (typeof value === 'string') return value + if (field === 'message' && typeof nestedError === 'string') { + return nestedError + } + return undefined +} + +function getAPIErrorCode(error: APIError): string | undefined { + return ( + getAPIErrorBodyField(error, 'code') ?? + getAPIErrorBodyField(error, 'error_code') + )?.trim().toLowerCase() +} + +function getAPIErrorDetail(error: APIError): string { + const bodyMessage = getAPIErrorBodyField(error, 'message') + if (bodyMessage) return bodyMessage.trim() + return error.message.replace(/^\d{3}\s+/, '').trim() +} + +function isVerbooRouterError(): boolean { + return isVerbooMode() && isFirstPartyAnthropicBaseUrl() +} + export function getPdfTooLargeErrorMessage(): string { const limits = `max ${API_PDF_MAX_PAGES} pages, ${formatFileSize(PDF_TARGET_RAW_SIZE)}` return getIsNonInteractiveSession() @@ -1021,6 +1065,45 @@ export function getAssistantMessageFromError( }) } + // Provisioning and billing codes are meaningful only for the Verboo router. + // Prefer the machine-readable code; legacy text is intentionally exact and + // receives neutral copy because it cannot prove that an upgrade happened. + if ( + error instanceof APIError && + error.status === 401 && + isVerbooRouterError() + ) { + const errorCode = getAPIErrorCode(error) + if (errorCode === 'plan_provisioning') { + return createAssistantAPIErrorMessage({ + content: PLAN_PROVISIONING_ERROR_MESSAGE, + error: 'billing_error', + }) + } + + if ( + errorCode === 'insufficient_credits' || + errorCode === 'credit_balance_too_low' || + errorCode === 'credits_exhausted' + ) { + return createAssistantAPIErrorMessage({ + content: CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, + error: 'billing_error', + }) + } + + const legacyDetail = getAPIErrorDetail(error).toLowerCase() + if ( + legacyDetail === 'not enough credits' || + legacyDetail === 'insufficient credits' + ) { + return createAssistantAPIErrorMessage({ + content: PLAN_ACCESS_PENDING_ERROR_MESSAGE, + error: 'billing_error', + }) + } + } + // Generic handler for other 401/403 authentication errors if ( error instanceof APIError &&