From 70af7136453e3b38d260a4ad06e116fc7a87c3bb Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Sat, 12 Sep 2026 01:20:19 -0300 Subject: [PATCH] fix(trials): show accounting tolerance and offer paid recovery --- README.md | 8 ++++++ package.json | 2 +- src/components/FreeTokenAccountingNotice.tsx | 12 +++++++++ src/components/FreeTokenActivation.test.tsx | 25 +++++++++++++++++++ src/components/FreeTokenActivation.tsx | 8 +++--- src/components/Settings/VerbooUsage.tsx | 3 ++- src/services/api/errors.ts | 2 +- src/services/api/openaiErrorClassification.ts | 2 +- src/services/api/openaiShim.ts | 4 +-- src/services/api/verbooFreeTokens.ts | 3 +++ src/services/oauth/cliEntitlement.test.ts | 8 ++++++ src/services/oauth/cliEntitlement.ts | 8 +++--- src/services/oauth/freeTokenActivation.ts | 2 +- src/services/oauth/verbooStartupAuth.ts | 3 +-- 14 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/components/FreeTokenAccountingNotice.tsx diff --git a/README.md b/README.md index 4a5139fdb6..cdec09958b 100644 --- a/README.md +++ b/README.md @@ -280,3 +280,11 @@ Verboo Code originated from the Claude Code codebase and has since been substant ## License See [LICENSE](LICENSE). + +## Free-token accounting + +`/usage` shows the number of requests awaiting usage confirmation and the +server-configured limit (10 by default). Live requests reserve a slot until their +usage is settled. Free inference pauses at the limit; paid activation remains +available with explicit confirmation, including during an ongoing conversation. +Older backend responses are supported. Non-interactive sessions never accept a payment. diff --git a/package.json b/package.json index fd5ffdf581..5a6ca82da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@verboo/code", - "version": "0.15.23", + "version": "0.15.24", "description": "Verboo Code — coding agent for the Verboo platform", "type": "module", "bin": { diff --git a/src/components/FreeTokenAccountingNotice.tsx b/src/components/FreeTokenAccountingNotice.tsx new file mode 100644 index 0000000000..cee843be7e --- /dev/null +++ b/src/components/FreeTokenAccountingNotice.tsx @@ -0,0 +1,12 @@ +import { Text } from '../ink.js' +import type { FreeTokenStatus } from '../services/api/verbooFreeTokens.js' + +export function FreeTokenAccountingNotice({ status }: { status: FreeTokenStatus }) { + const blocked = status.accountingBlocked ?? status.accountingPending + if (status.accountingOpenRequests === undefined || status.accountingRequestLimit === undefined) { + return blocked ? Limite de solicitações aguardando contabilização atingido. Novas inferências gratuitas estão pausadas. : null + } + return + {status.accountingOpenRequests}/{status.accountingRequestLimit} solicitações aguardando contabilização · {blocked ? 'Uso gratuito pausado até a confirmação do consumo.' : status.state === 'active' ? 'Uso liberado.' : 'Sem bloqueio de contabilização.'} + +} diff --git a/src/components/FreeTokenActivation.test.tsx b/src/components/FreeTokenActivation.test.tsx index 84b42a98fe..d2253d6729 100644 --- a/src/components/FreeTokenActivation.test.tsx +++ b/src/components/FreeTokenActivation.test.tsx @@ -106,3 +106,28 @@ test('continues without a payment prompt when the block has already cleared', as expect(menu.dependencies.fetchFreeTokenQuote).not.toHaveBeenCalled() expect(menu.dependencies.activateFreeTokens).not.toHaveBeenCalled() }) + +test('accounting limit shows the count and offers explicit paid activation', async () => { + const menu = await activationMenu(undefined, { + ...exhausted, state: 'active', tokensRemaining: 100, accountingPending: true, + accountingBlocked: true, accountingOpenRequests: 10, accountingRequestLimit: 10, + }) + await waitFor(() => menu.output().includes('Ativar plano e pagar')) + expect(menu.output()).toContain('10/10') + expect(menu.output()).toContain('Uso gratuito pausado') + expect(menu.dependencies.activateFreeTokens).not.toHaveBeenCalled() + await menu.press('\r') + await waitFor(() => menu.onDone.mock.calls.length > 0) + expect(menu.dependencies.activateFreeTokens).toHaveBeenCalledTimes(1) +}) + +test('tolerated open requests continue without a payment prompt', async () => { + const menu = await activationMenu(undefined, { + ...exhausted, state: 'active', tokensRemaining: 100, accountingPending: false, + accountingBlocked: false, accountingOpenRequests: 9, accountingRequestLimit: 10, + }) + await waitFor(() => menu.onDone.mock.calls.length > 0) + expect(menu.onDone).toHaveBeenCalledWith(true) + expect(menu.dependencies.fetchFreeTokenQuote).not.toHaveBeenCalled() + expect(menu.dependencies.activateFreeTokens).not.toHaveBeenCalled() +}) diff --git a/src/components/FreeTokenActivation.tsx b/src/components/FreeTokenActivation.tsx index fd2f7a5115..48fdc461ca 100644 --- a/src/components/FreeTokenActivation.tsx +++ b/src/components/FreeTokenActivation.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { Box, Text, render } from '../ink.js' import { Select } from './CustomSelect/select.js' import { openBrowser } from '../utils/browser.js' +import { FreeTokenAccountingNotice } from './FreeTokenAccountingNotice.js' import { activateFreeTokens, fetchFreeTokenQuote, fetchFreeTokenStatus, type FreeTokenQuote, type FreeTokenStatus } from '../services/api/verbooFreeTokens.js' const defaultDependencies = { activateFreeTokens, fetchFreeTokenQuote, fetchFreeTokenStatus, openBrowser } @@ -23,8 +24,8 @@ export function FreeTokenActivationView({ onDone, dependencies = defaultDependen const current = await dependencies.fetchFreeTokenStatus(signal) if (signal?.aborted) return setStatus(current) - if (current.state === 'converted' || (current.state === 'active' && current.tokensRemaining > 0 && !current.accountingPending)) { done.current(true); return } - if ((current.state === 'active' || current.state === 'exhausted') && !current.accountingPending) { + if (current.state === 'converted' || (current.state === 'active' && current.tokensRemaining > 0 && !(current.accountingBlocked ?? current.accountingPending))) { done.current(true); return } + if (current.state === 'active' || current.state === 'exhausted') { const next = await dependencies.fetchFreeTokenQuote(signal) if (!signal?.aborted) setQuote(next) } else setQuote(null) @@ -54,7 +55,8 @@ export function FreeTokenActivationView({ onDone, dependencies = defaultDependen return Ativação do plano {status?.state === 'exhausted' && Seus tokens grátis acabaram e a inferência foi pausada. Para continuar a conversa, ative o plano com o cartão já cadastrado. A cobrança só acontece após o seu aceite.} - {status?.accountingPending && Estamos confirmando o consumo de tokens. Aguarde a confirmação antes de continuar.} + {status && } + {(status?.accountingBlocked ?? status?.accountingPending) && Você pode ativar o plano pago enquanto confirmamos o consumo gratuito. A cobrança só acontece após o seu aceite.} {status && {status.tokensRemaining.toLocaleString('pt-BR')} tokens restantes · {status.tokensUsed.toLocaleString('pt-BR')} consumidos} {error && Não foi possível confirmar a operação. Verifique novamente; uma tentativa pode estar em andamento.} {quote && !busy && <> diff --git a/src/components/Settings/VerbooUsage.tsx b/src/components/Settings/VerbooUsage.tsx index 698a897944..bd8d591a09 100644 --- a/src/components/Settings/VerbooUsage.tsx +++ b/src/components/Settings/VerbooUsage.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { FreeTokenAccountingNotice } from '../FreeTokenAccountingNotice.js' import { fetchFreeTokenStatus, type FreeTokenStatus } from '../../services/api/verbooFreeTokens.js' import { Box, Text } from '../../ink.js' @@ -27,7 +28,7 @@ export function VerbooUsage({ {free ? <> {status.tokensRemaining.toLocaleString('pt-BR')} tokens grátis restantes {status.tokensUsed.toLocaleString('pt-BR')} consumidos de {status.tokenLimit.toLocaleString('pt-BR')}. Entrada + saída, sem prazo de validade. - {status.accountingPending && Consumo pendente de confirmação. Novas inferências estão pausadas.} + Quando os tokens acabarem, a CLI pausará a inferência e mostrará as opções de ativação com o valor da cobrança no cartão cadastrado. : status ? Consulte seu uso no painel: https://code.verboo.ai/dashboard : null} {showCancelHint ? diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index f56c520fee..c3671225b5 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -129,7 +129,7 @@ function mapOpenAICompatibilityFailureToAssistantMessage(options: { case 'free_tokens_required': return createAssistantAPIErrorMessage({ content: new FreeTokensRequiredError().message, error: 'invalid_request' }) case 'free_tokens_accounting_pending': - return createAssistantAPIErrorMessage({ content: 'Estamos confirmando o consumo dos tokens grátis. Novas solicitações estão pausadas. Tente novamente em instantes.', error: 'invalid_request' }) + return createAssistantAPIErrorMessage({ content: 'O limite de solicitações aguardando contabilização foi atingido. O uso gratuito está pausado até a confirmação do consumo; você pode ativar um plano pago.', error: 'invalid_request' }) case 'terms_required': return createAssistantAPIErrorMessage({ content: getIsNonInteractiveSession() diff --git a/src/services/api/openaiErrorClassification.ts b/src/services/api/openaiErrorClassification.ts index b54c0a043a..2e393b81d7 100644 --- a/src/services/api/openaiErrorClassification.ts +++ b/src/services/api/openaiErrorClassification.ts @@ -312,7 +312,7 @@ export function classifyOpenAIHttpFailure(options: { } if (options.status === 402 && body.includes('free_tokens_')) return { source: 'http', category: 'free_tokens_required', retryable: false, status: 402, message: body, hint: 'Confirme a ativação nas opções apresentadas pela CLI ou no painel da sua conta.' } - if (options.status === 503 && body.includes('free_tokens_accounting_pending')) return { source: 'http', category: 'free_tokens_accounting_pending', retryable: false, status: 503, message: body, hint: 'Aguarde a confirmação do consumo antes de uma nova solicitação.' } + if (options.status === 503 && body.includes('free_tokens_accounting_pending')) return { source: 'http', category: 'free_tokens_accounting_pending', retryable: false, status: 503, message: body, hint: 'O limite de contabilização foi atingido. Aguarde a confirmação do consumo ou ative um plano pago.' } if (options.status === 429) { return { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 7c355fb4b1..fdfd983efa 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -3617,9 +3617,9 @@ class OpenAIShimMessages { } captureRouterRateLimit(response.headers, requestUrl) - if (!didRetryFreeTokenActivation && response.status === 402 && isVerbooRouterUrl(request.baseUrl)) { + if (!didRetryFreeTokenActivation && (response.status === 402 || response.status === 503) && isVerbooRouterUrl(request.baseUrl)) { const body = await response.clone().json().catch(() => null) as { error?: { code?: string } } | null - if (body?.error?.code === 'free_tokens_exhausted' || body?.error?.code === 'free_tokens_activation_pending') { + if (body?.error?.code === 'free_tokens_exhausted' || body?.error?.code === 'free_tokens_activation_pending' || body?.error?.code === 'free_tokens_accounting_pending') { didRetryFreeTokenActivation = true if (options?.signal?.aborted) throw options.signal.reason if (!await requestFreeTokenActivation({ requestStartedAt })) throw new FreeTokensRequiredError() diff --git a/src/services/api/verbooFreeTokens.ts b/src/services/api/verbooFreeTokens.ts index 046a4d96a5..af04c56e82 100644 --- a/src/services/api/verbooFreeTokens.ts +++ b/src/services/api/verbooFreeTokens.ts @@ -11,6 +11,9 @@ export const freeTokenStatusSchema = z.object({ tokenLimit: z.number().int().nonnegative(), tokensUsed: z.number().int().nonnegative(), tokensRemaining: z.number().int().nonnegative(), groupId: z.string().uuid().optional(), subscriptionId: z.string().uuid().optional(), billingInterval: z.enum(['month', 'year']).optional(), accountingPending: z.boolean(), + accountingBlocked: z.boolean().optional(), + accountingOpenRequests: z.number().int().nonnegative().optional(), + accountingRequestLimit: z.number().int().min(1).max(100).optional(), activationUrl: z.string().url(), checkoutUrl: z.string().url().optional(), attemptId: z.string().uuid().optional(), }) export const freeTokenQuoteSchema = z.object({ diff --git a/src/services/oauth/cliEntitlement.test.ts b/src/services/oauth/cliEntitlement.test.ts index b8beff3305..ff1beb6619 100644 --- a/src/services/oauth/cliEntitlement.test.ts +++ b/src/services/oauth/cliEntitlement.test.ts @@ -70,3 +70,11 @@ test('free token access has no day expiry and blocks at exhaustion or pending us expect(buildCLIEntitlementFromSubscriptions([{ ...sub, freeTokens: { ...sub.freeTokens, state: 'activating' } }], now)).toMatchObject({ allowed: false, reason: 'free_tokens_activation_pending' }) expect(buildCLIEntitlementFromSubscriptions([{ ...sub, status: 'canceled', freeTokens: { ...sub.freeTokens, state: 'exhausted', tokensRemaining: 0 } }, subscription('active')], now)).toMatchObject({ allowed: true, reason: 'active' }) }) + +test('uses explicit accounting block while accepting older status responses', () => { + const free = { eligible: true, state: 'active' as const, tokenLimit: 10_000_000, tokensUsed: 1, tokensRemaining: 9_999_999, accountingPending: false, activationUrl: 'https://code.verboo.ai/plans' } + const sub = { ...subscription('trialing', undefined, 'free_tokens'), freeTokens: free } + expect(buildCLIEntitlementFromSubscriptions([sub], now).allowed).toBe(true) + expect(buildCLIEntitlementFromSubscriptions([{ ...sub, freeTokens: { ...free, accountingOpenRequests: 9, accountingRequestLimit: 10, accountingBlocked: false } }], now).allowed).toBe(true) + expect(buildCLIEntitlementFromSubscriptions([{ ...sub, freeTokens: { ...free, accountingOpenRequests: 10, accountingRequestLimit: 10, accountingBlocked: true } }], now)).toMatchObject({ allowed: false, reason: 'free_tokens_accounting_pending' }) +}) diff --git a/src/services/oauth/cliEntitlement.ts b/src/services/oauth/cliEntitlement.ts index 429683f4a2..1dd2ffe6e0 100644 --- a/src/services/oauth/cliEntitlement.ts +++ b/src/services/oauth/cliEntitlement.ts @@ -60,7 +60,7 @@ export function buildCLIEntitlementFromSubscriptions( now = Date.now(), ): CLIEntitlement { const active = subscriptions.filter(subscription => - hasCurrentSubscriptionAccess(subscription, now) && (!subscription.freeTokens || subscription.freeTokens.state === 'converted' || (subscription.freeTokens.state === 'active' && !subscription.freeTokens.accountingPending && subscription.freeTokens.tokensRemaining > 0)), + hasCurrentSubscriptionAccess(subscription, now) && (!subscription.freeTokens || subscription.freeTokens.state === 'converted' || (subscription.freeTokens.state === 'active' && !(subscription.freeTokens.accountingBlocked ?? subscription.freeTokens.accountingPending) && subscription.freeTokens.tokensRemaining > 0)), ) const result: CLIEntitlement = { allowed: active.length > 0, @@ -84,7 +84,7 @@ export function buildCLIEntitlementFromSubscriptions( const free = subscriptions.find(subscription => subscription.freeTokens && ['active', 'exhausted', 'activating', 'checkout_required'].includes(subscription.freeTokens.state))?.freeTokens if (free) { - result.reason = free.accountingPending ? 'free_tokens_accounting_pending' : ['activating', 'checkout_required'].includes(free.state) ? 'free_tokens_activation_pending' : 'free_tokens_exhausted' + result.reason = (free.accountingBlocked ?? free.accountingPending) ? 'free_tokens_accounting_pending' : ['activating', 'checkout_required'].includes(free.state) ? 'free_tokens_activation_pending' : 'free_tokens_exhausted' result.recheckAfterSeconds = 3 } else if (subscriptions.some(subscription => subscription.status === 'past_due')) { result.reason = 'past_due' @@ -147,7 +147,7 @@ export function getCLIEntitlementDeniedMessage( case 'free_tokens_activation_pending': return new FreeTokensRequiredError().message case 'free_tokens_accounting_pending': - return 'Estamos confirmando o consumo de tokens grátis. Novas solicitações estão pausadas; tente novamente em instantes.' + return 'O limite de solicitações aguardando contabilização foi atingido. Novas inferências gratuitas estão pausadas até a confirmação do consumo; a ativação paga continua disponível.' case 'past_due': return 'Sua assinatura Verboo Code está com pagamento pendente. Regularize-a para continuar usando a CLI.' case 'expired': @@ -172,7 +172,7 @@ export async function assertCLIEntitlement(options?: { `Não foi possível validar sua assinatura Verboo Code: ${errorMessage(error)}. Novas solicitações foram bloqueadas; tente novamente em instantes.`, ) } - if (!entitlement.allowed && (entitlement.reason === 'free_tokens_exhausted' || entitlement.reason === 'free_tokens_activation_pending')) { + if (!entitlement.allowed && (entitlement.reason === 'free_tokens_exhausted' || entitlement.reason === 'free_tokens_activation_pending' || entitlement.reason === 'free_tokens_accounting_pending')) { if (await requestFreeTokenActivation({ requestStartedAt })) { clearCLIEntitlementCache() entitlement = await fetchCLIEntitlement({ force: true }) diff --git a/src/services/oauth/freeTokenActivation.ts b/src/services/oauth/freeTokenActivation.ts index cc6320295f..25caa768fb 100644 --- a/src/services/oauth/freeTokenActivation.ts +++ b/src/services/oauth/freeTokenActivation.ts @@ -10,7 +10,7 @@ export class FreeTokensRequiredError extends Error { const nextStep = interactive ? 'Tente novamente para ver as opções de ativação' : 'Abra a CLI em modo interativo para confirmar a ativação' - super(`A inferência está pausada porque seus tokens grátis acabaram ou a ativação ainda não foi confirmada. ${nextStep} ou acesse ${VERBOO_FRONT_BASE_URL}/free-tokens.`) + super(`A inferência está pausada porque seus tokens grátis acabaram, o limite de contabilização foi atingido ou a ativação ainda não foi confirmada. ${nextStep} ou acesse ${VERBOO_FRONT_BASE_URL}/free-tokens.`) this.name = 'FreeTokensRequiredError' } } diff --git a/src/services/oauth/verbooStartupAuth.ts b/src/services/oauth/verbooStartupAuth.ts index 95882976c5..52590c05dd 100644 --- a/src/services/oauth/verbooStartupAuth.ts +++ b/src/services/oauth/verbooStartupAuth.ts @@ -358,14 +358,13 @@ async function ensureCLIEntitlement(accessToken: string): Promise { let entitlement = await fetchCLIEntitlement({ force: true }) if (entitlement.allowed) return - if (entitlement.reason === 'free_tokens_exhausted' || entitlement.reason === 'free_tokens_activation_pending') { + if (entitlement.reason === 'free_tokens_exhausted' || entitlement.reason === 'free_tokens_activation_pending' || entitlement.reason === 'free_tokens_accounting_pending') { if (await requestFreeTokenActivation({ startup: true, requestStartedAt })) { entitlement = await fetchCLIEntitlement({ force: true }) if (entitlement.allowed) return } throw new FreeTokensRequiredError() } - if (entitlement.reason === 'free_tokens_accounting_pending') throw new Error(getCLIEntitlementDeniedMessage(entitlement.reason)) if (entitlement.reason === 'past_due') { const resolved = await showPastDueNotice(accessToken) if (resolved) {