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) {