Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/services/api/errors.planProvisioning.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getAssistantMessageFromError>,
): 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')
})
83 changes: 83 additions & 0 deletions src/services/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, unknown>
const nestedError = record.error
if (nestedError && typeof nestedError === 'object') {
const nestedValue = (nestedError as Record<string, unknown>)[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()
Expand Down Expand Up @@ -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 &&
Expand Down