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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions src/components/FreeTokenAccountingNotice.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <Text color="yellow">Limite de solicitações aguardando contabilização atingido. Novas inferências gratuitas estão pausadas.</Text> : null
}
return <Text color={blocked ? 'yellow' : undefined}>
{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.'}
</Text>
}
25 changes: 25 additions & 0 deletions src/components/FreeTokenActivation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
8 changes: 5 additions & 3 deletions src/components/FreeTokenActivation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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)
Expand Down Expand Up @@ -54,7 +55,8 @@ export function FreeTokenActivationView({ onDone, dependencies = defaultDependen
return <Box flexDirection="column" gap={1} borderStyle="round" paddingX={1}>
<Text bold>Ativação do plano</Text>
{status?.state === 'exhausted' && <Text>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.</Text>}
{status?.accountingPending && <Text>Estamos confirmando o consumo de tokens. Aguarde a confirmação antes de continuar.</Text>}
{status && <FreeTokenAccountingNotice status={status} />}
{(status?.accountingBlocked ?? status?.accountingPending) && <Text>Você pode ativar o plano pago enquanto confirmamos o consumo gratuito. A cobrança só acontece após o seu aceite.</Text>}
{status && <Text>{status.tokensRemaining.toLocaleString('pt-BR')} tokens restantes · {status.tokensUsed.toLocaleString('pt-BR')} consumidos</Text>}
{error && <Text color="yellow">Não foi possível confirmar a operação. Verifique novamente; uma tentativa pode estar em andamento.</Text>}
{quote && !busy && <>
Expand Down
3 changes: 2 additions & 1 deletion src/components/Settings/VerbooUsage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -27,7 +28,7 @@ export function VerbooUsage({
{free ? <>
<Text bold>{status.tokensRemaining.toLocaleString('pt-BR')} tokens grátis restantes</Text>
<Text>{status.tokensUsed.toLocaleString('pt-BR')} consumidos de {status.tokenLimit.toLocaleString('pt-BR')}. Entrada + saída, sem prazo de validade.</Text>
{status.accountingPending && <Text color="yellow">Consumo pendente de confirmação. Novas inferências estão pausadas.</Text>}
<FreeTokenAccountingNotice status={status} />
<Text>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.</Text>
</> : status ? <Text>Consulte seu uso no painel: https://code.verboo.ai/dashboard</Text> : null}
{showCancelHint ? <Text dimColor>
Expand Down
2 changes: 1 addition & 1 deletion src/services/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/services/api/openaiErrorClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/services/api/openaiShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions src/services/api/verbooFreeTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
8 changes: 8 additions & 0 deletions src/services/oauth/cliEntitlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})
8 changes: 4 additions & 4 deletions src/services/oauth/cliEntitlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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':
Expand All @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion src/services/oauth/freeTokenActivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
Expand Down
3 changes: 1 addition & 2 deletions src/services/oauth/verbooStartupAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,14 +358,13 @@ async function ensureCLIEntitlement(accessToken: string): Promise<void> {
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) {
Expand Down
Loading