From 78bb0c08439bbc62f7b452888ee856a321950f24 Mon Sep 17 00:00:00 2001 From: Mafra Date: Wed, 29 Jul 2026 21:01:35 -0300 Subject: [PATCH 1/2] feat(errors): 401 pos-upgrade mostra "plano sendo ativado" (nao "faca login") Logo apos um upgrade de plano, o entitlement leva 1 a 2 min pra propagar e o router responde 401 "Not Enough Credits". O handler generico de 401/403 mandava o usuario fazer /login, mensagem errada (login nao resolve) e no pior momento (recem pagou). Aconteceu com um cliente MAX e virou ticket. Adiciona um caso antes do handler generico que detecta o 401 de billing/provisionamento e mostra "seu plano esta sendo ativado, tente em instantes" (tipo billing_error, que nao dispara o fluxo de login). Espelha o padrao ja existente do "Credit balance is too low". 401 de autenticacao real segue indo pro /login (sem regressao). Testes: - errors.planProvisioning.test.ts (novo): (1) 401 "Not Enough Credits" produz a mensagem de ativacao e NAO contem "/login"; (2) 401 de auth real continua contendo "/login". - Regressao errors.openaiCompatibility.test.ts: 5 pass. - bun run build ok; tsc 0 erros novos. Nota: match pela mensagem retornada; ideal e o router expor um codigo estavel (registrado no comentario do codigo). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/errors.planProvisioning.test.ts | 47 +++++++++++++++++++ src/services/api/errors.ts | 21 +++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/services/api/errors.planProvisioning.test.ts diff --git a/src/services/api/errors.planProvisioning.test.ts b/src/services/api/errors.planProvisioning.test.ts new file mode 100644 index 0000000000..dcf548fdaf --- /dev/null +++ b/src/services/api/errors.planProvisioning.test.ts @@ -0,0 +1,47 @@ +import { APIError } from '@anthropic-ai/sdk' +import { expect, test } from 'bun:test' + +import { getAssistantMessageFromError } from './errors.js' + +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('401 "Not Enough Credits" (provisionamento pós-upgrade) NÃO manda pro login', () => { + const error = APIError.generate( + 401, + undefined, + 'Not Enough Credits', + new Headers(), + ) + + const message = getAssistantMessageFromError(error, 'deepseek-v4-pro') + const text = getFirstText(message) + + expect(message.isApiErrorMessage).toBe(true) + // mostra que o plano está sendo ativado... + expect(text.toLowerCase()).toContain('plan is being activated') + // ...e NÃO pede login (que não resolveria) + expect(text).not.toContain('/login') +}) + +test('401 de autenticação real CONTINUA orientando /login (sem regressão)', () => { + 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(text).toContain('/login') +}) diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index b2e00b95d2..f60fb52ee2 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -284,6 +284,8 @@ 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 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' @@ -1021,6 +1023,25 @@ export function getAssistantMessageFromError( }) } + // 401 de billing/provisionamento (ex.: logo após um upgrade de plano o + // entitlement ainda não propagou e o router responde "Not Enough Credits"). + // Não é falha de autenticação: pedir /login não resolve e assusta o cliente + // no pior momento (recém pagou). Mostra que o plano está sendo ativado. + // Idealmente o router devolveria um código estável; enquanto isso, casamos + // pela mensagem retornada. + if ( + error instanceof APIError && + error.status === 401 && + /not enough credits|insufficient credits|provision(ing|ed)/i.test( + error.message, + ) + ) { + return createAssistantAPIErrorMessage({ + content: PLAN_PROVISIONING_ERROR_MESSAGE, + error: 'billing_error', + }) + } + // Generic handler for other 401/403 authentication errors if ( error instanceof APIError && From 962848e8ded1649bcc7b1ce0191fb27b85fa8f6f Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Thu, 30 Jul 2026 14:06:15 -0300 Subject: [PATCH 2/2] fix(errors): scope plan provisioning to Verboo --- .../api/errors.planProvisioning.test.ts | 86 ++++++++++++++++-- src/services/api/errors.ts | 88 ++++++++++++++++--- 2 files changed, 153 insertions(+), 21 deletions(-) diff --git a/src/services/api/errors.planProvisioning.test.ts b/src/services/api/errors.planProvisioning.test.ts index dcf548fdaf..189a35f98b 100644 --- a/src/services/api/errors.planProvisioning.test.ts +++ b/src/services/api/errors.planProvisioning.test.ts @@ -1,7 +1,22 @@ import { APIError } from '@anthropic-ai/sdk' -import { expect, test } from 'bun:test' +import { afterEach, expect, test } from 'bun:test' -import { getAssistantMessageFromError } from './errors.js' +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, @@ -13,11 +28,16 @@ function getFirstText( return typeof first.text === 'string' ? first.text : '' } -test('401 "Not Enough Credits" (provisionamento pós-upgrade) NÃO manda pro login', () => { +test('typed Verboo provisioning code reports plan activation', () => { const error = APIError.generate( 401, + { + error: { + code: 'plan_provisioning', + message: 'Plan entitlement is still provisioning', + }, + }, undefined, - 'Not Enough Credits', new Headers(), ) @@ -25,13 +45,62 @@ test('401 "Not Enough Credits" (provisionamento pós-upgrade) NÃO manda pro log const text = getFirstText(message) expect(message.isApiErrorMessage).toBe(true) - // mostra que o plano está sendo ativado... - expect(text.toLowerCase()).toContain('plan is being activated') - // ...e NÃO pede login (que não resolveria) + expect(message.error).toBe('billing_error') + expect(text).toBe(PLAN_PROVISIONING_ERROR_MESSAGE) expect(text).not.toContain('/login') }) -test('401 de autenticação real CONTINUA orientando /login (sem regressão)', () => { +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, @@ -43,5 +112,6 @@ test('401 de autenticação real CONTINUA orientando /login (sem regressão)', ( 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 f60fb52ee2..19b0a51516 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -286,6 +286,8 @@ 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' @@ -307,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() @@ -1023,23 +1065,43 @@ export function getAssistantMessageFromError( }) } - // 401 de billing/provisionamento (ex.: logo após um upgrade de plano o - // entitlement ainda não propagou e o router responde "Not Enough Credits"). - // Não é falha de autenticação: pedir /login não resolve e assusta o cliente - // no pior momento (recém pagou). Mostra que o plano está sendo ativado. - // Idealmente o router devolveria um código estável; enquanto isso, casamos - // pela mensagem retornada. + // 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 && - /not enough credits|insufficient credits|provision(ing|ed)/i.test( - error.message, - ) + isVerbooRouterError() ) { - return createAssistantAPIErrorMessage({ - content: PLAN_PROVISIONING_ERROR_MESSAGE, - error: 'billing_error', - }) + 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