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
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.32",
"version": "0.15.33",
"description": "Verboo Code — coding agent for the Verboo platform",
"type": "module",
"bin": {
Expand Down
41 changes: 37 additions & 4 deletions src/components/Settings/VerbooUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import * as React from 'react'
import { FreeTokenAccountingNotice } from '../FreeTokenAccountingNotice.js'

import { fetchFreeTokenStatus, type FreeTokenStatus } from '../../services/api/verbooFreeTokens.js'
import { fetchUsageWindows, type UsageWindowsStatus } from '../../services/api/verbooUsageWindows.js'
import { Box, Text } from '../../ink.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import { getOauthAccountInfo } from '../../utils/auth.js'

export function VerbooUsage({
showCancelHint = true,
Expand All @@ -12,19 +14,50 @@ export function VerbooUsage({
}): React.ReactNode {
const [status, setStatus] = React.useState<FreeTokenStatus | null>(null)
const [failed, setFailed] = React.useState(false)
const [windows, setWindows] = React.useState<UsageWindowsStatus | null>(null)
const [owner, setOwner] = React.useState<string | undefined>(undefined)
React.useEffect(() => {
const controller = new AbortController()
const load = () => { void fetchFreeTokenStatus(controller.signal).then(value => { if (!controller.signal.aborted) { setStatus(value); setFailed(false) } }).catch(() => { if (!controller.signal.aborted) setFailed(true) }) }
load()
const timer = setInterval(load, 10_000)
return () => { controller.abort(); clearInterval(timer) }
let loadedOwner = getOauthAccountInfo()?.accountUuid
let timer: ReturnType<typeof setTimeout> | undefined
const load = async () => {
const requestOwner = getOauthAccountInfo()?.accountUuid
const [free, usage] = await Promise.allSettled([fetchFreeTokenStatus(controller.signal), fetchUsageWindows(controller.signal)])
if (controller.signal.aborted) return
if (requestOwner !== getOauthAccountInfo()?.accountUuid) {
setStatus(null); setWindows(null); setFailed(false)
timer = setTimeout(load, 10_000)
return
}
if (loadedOwner !== requestOwner) { setStatus(null); setWindows(null) }
loadedOwner = requestOwner
setOwner(requestOwner)
if (free.status === 'fulfilled') setStatus(free.value)
if (usage.status === 'fulfilled') setWindows(usage.value)
setFailed(free.status === 'rejected' || usage.status === 'rejected')
timer = setTimeout(load, 10_000)
}
void load()
return () => { controller.abort(); if (timer) clearTimeout(timer) }
}, [])
const free = status && ['active', 'exhausted', 'activating', 'checkout_required'].includes(status.state)

if (owner !== getOauthAccountInfo()?.accountUuid) return <Text>Carregando consumo…</Text>

return (
<Box flexDirection="column" gap={1}>
{!status && !failed && <Text>Carregando consumo…</Text>}
{failed && <Text color="yellow">Não foi possível consultar o saldo. Tente novamente.</Text>}
{windows?.map(group => <Box key={group.groupId} flexDirection="column">
<Text bold>{group.groupName}</Text>
{!group.limited && <Text>Sem limite de janela.</Text>}
{group.accountingPending && <Text color="yellow">Uso pausado até a confirmação das solicitações anteriores.</Text>}
{group.blocked && <Text color="yellow">Limite de uso atingido. Aguarde a renovação.</Text>}
{group.windows.map(window => <Box key={window.id} flexDirection="column">
<Text>{(window.durationSeconds / 3600).toLocaleString('pt-BR', { maximumFractionDigits: 2 })}h: {window.usedPercent.toLocaleString('pt-BR')}% utilizado</Text>
<Text dimColor>{window.resetsAt ? `Renova em ${new Date(window.resetsAt).toLocaleString('pt-BR')}` : 'A janela começa no próximo uso.'}</Text>
</Box>)}
</Box>)}
{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>
Expand Down
5 changes: 5 additions & 0 deletions src/services/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ function mapOpenAICompatibilityFailureToAssistantMessage(options: {

case 'free_tokens_required':
return createAssistantAPIErrorMessage({ content: new FreeTokensRequiredError().message, error: 'invalid_request' })
case 'usage_window_exhausted':
return createAssistantAPIErrorMessage({ content: 'O limite de uso deste plano foi atingido. Consulte /usage para ver a porcentagem consumida e o horário de renovação.', error: 'rate_limit' })
case 'usage_accounting_pending':
case 'usage_accounting_unavailable':
return createAssistantAPIErrorMessage({ content: 'O uso está pausado até a confirmação das solicitações anteriores. Consulte /usage e tente novamente mais tarde.', error: 'invalid_request' })
case 'free_tokens_accounting_pending':
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':
Expand Down
13 changes: 13 additions & 0 deletions src/services/api/openaiErrorClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export type OpenAICompatibilityFailureCategory =
| 'network_error'
| 'auth_invalid'
| 'free_tokens_required'
| 'usage_window_exhausted'
| 'usage_accounting_pending'
| 'usage_accounting_unavailable'
| 'free_tokens_accounting_pending'
| 'terms_required'
| 'rate_limited'
Expand Down Expand Up @@ -39,6 +42,9 @@ const OPENAI_COMPATIBILITY_FAILURE_CATEGORIES: ReadonlySet<OpenAICompatibilityFa
'network_error',
'auth_invalid',
'free_tokens_required',
'usage_window_exhausted',
'usage_accounting_pending',
'usage_accounting_unavailable',
'free_tokens_accounting_pending',
'terms_required',
'rate_limited',
Expand Down Expand Up @@ -272,6 +278,13 @@ export function classifyOpenAIHttpFailure(options: {
url?: string
}): OpenAICompatibilityFailure {
const body = options.body ?? ''
if (options.status === 429 || options.status === 503) {
try {
const parsed = JSON.parse(body) as { error?: { code?: string } }
const code = parsed.error?.code
if (code === 'usage_window_exhausted' || code === 'usage_accounting_pending' || code === 'usage_accounting_unavailable') return { source: 'http', category: code, retryable: false, status: options.status, code, message: body, hint: 'Consulte /usage para acompanhar seu uso e a renovação.' }
} catch { /* Other providers may return non-JSON errors. */ }
}
const hostname = options.url ? getHostname(options.url) : null
const isLocalHost = isLocalhostLikeHostname(hostname)

Expand Down
45 changes: 45 additions & 0 deletions src/services/api/readRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/** Bounded GET-only retries. The caller classifies HTTP/network failures. */
export async function readWithRetry<T>(
request: (signal: AbortSignal) => Promise<T>,
classify: (error: unknown) => { retry: boolean; delayMs?: number },
signal?: AbortSignal,
): Promise<T> {
const deadline = Date.now() + 35_000
for (let attempt = 0; ; attempt++) {
signal?.throwIfAborted()
const controller = new AbortController()
const abort = () => controller.abort(signal?.reason)
signal?.addEventListener('abort', abort, { once: true })
const timeout = setTimeout(() => controller.abort(new DOMException('A consulta excedeu o prazo.', 'TimeoutError')), Math.min(10_000, Math.max(1, deadline - Date.now())))
let failure: unknown
try {
const result = await request(controller.signal)
controller.signal.throwIfAborted()
return result
} catch (error) {
signal?.throwIfAborted()
failure = controller.signal.aborted ? controller.signal.reason : error
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
}
const decision = classify(failure)
const delay = Math.max(0, decision.delayMs ?? (attempt + 1) * 300)
if (attempt >= 2 || !decision.retry || Date.now() + delay >= deadline) throw failure
await new Promise<void>((resolve, reject) => {
const onAbort = () => { clearTimeout(timer); reject(signal?.reason) }
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve() }, delay)
if (signal?.aborted) onAbort()
else signal?.addEventListener('abort', onAbort, { once: true })
})
}
}

export const retryableReadStatus = new Set([408, 429, 500, 502, 503, 504])
export function retryAfterMilliseconds(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined || value === '') return undefined
const seconds = Number(value)
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
const date = Date.parse(String(value))
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined
}
32 changes: 32 additions & 0 deletions src/services/api/usageWindowErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from 'bun:test'
import { isUsageWindowStop } from './usageWindowErrors.js'
import { classifyOpenAIHttpFailure, buildOpenAICompatibilityErrorMessage } from './openaiErrorClassification.js'
import { readWithRetry, retryAfterMilliseconds } from './readRetry.js'

test('window exhaustion and unknown accounting stop automatic inference retries', () => {
for (const code of ['usage_window_exhausted', 'usage_accounting_pending', 'usage_accounting_unavailable']) {
const failure = classifyOpenAIHttpFailure({ status: code === 'usage_window_exhausted' ? 429 : 503, body: JSON.stringify({ error: { code, message: 'Uso pausado.' } }) })
expect(failure.retryable).toBe(false)
expect(isUsageWindowStop({ error: { code } })).toBe(true)
expect(isUsageWindowStop(new Error(buildOpenAICompatibilityErrorMessage('Uso pausado.', failure)))).toBe(true)
}
expect(isUsageWindowStop(new Error('[openai_category=usage_window_exhausted_extra]'))).toBe(false)
expect(classifyOpenAIHttpFailure({ status: 429, body: '{"error":{"code":"rate_limit"}}' }).retryable).toBe(true)
})

test('usage reads retry transient failures and never retry validation or cancellation', async () => {
let calls = 0
expect(await readWithRetry(async () => { if (++calls < 3) throw new TypeError('offline'); return 42 }, () => ({ retry: true, delayMs: 0 }))).toBe(42)
expect(calls).toBe(3)
calls = 0
await expect(readWithRetry(async () => { calls++; throw new Error('validation') }, () => ({ retry: false }))).rejects.toThrow('validation')
expect(calls).toBe(1)
const controller = new AbortController()
controller.abort()
await expect(readWithRetry(async () => { calls++; return 0 }, () => ({ retry: true }), controller.signal)).rejects.toThrow()
expect(calls).toBe(1)
await expect(readWithRetry(async () => { calls++; throw new Error('rate limited') }, () => ({ retry: true, delayMs: 60_000 }))).rejects.toThrow('rate limited')
expect(calls).toBe(2)
expect(retryAfterMilliseconds('2')).toBe(2000)
expect(retryAfterMilliseconds(new Date(Date.now() + 5000).toUTCString())).toBeGreaterThan(3000)
})
12 changes: 12 additions & 0 deletions src/services/api/usageWindowErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const codes = new Set(['usage_window_exhausted', 'usage_accounting_pending', 'usage_accounting_unavailable'])

/** These outcomes require user action or a window reset, even in persistent mode. */
export function isUsageWindowStop(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const value = error as { code?: unknown; error?: { code?: unknown }; message?: unknown }
if (typeof value.code === 'string' && codes.has(value.code)) return true
if (typeof value.error?.code === 'string' && codes.has(value.error.code)) return true
if (typeof value.message !== 'string') return false
const category = value.message.match(/\[openai_category=([a-z_]+)(?:,host=[^\]]+)?\]/)?.[1]
return category !== undefined && codes.has(category)
}
27 changes: 27 additions & 0 deletions src/services/api/verbooUsageWindows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import axios from 'axios'
import { z } from 'zod'
import { VERBOO_API_BASE_URL } from '../../constants/oauth.js'
import { getClaudeAIOAuthTokensAsync, getOauthAccountInfo } from '../../utils/auth.js'
import { withOAuth401Retry } from '../../utils/http.js'
import { parseApiEnvelope } from './verbooApiError.js'
import { readWithRetry, retryableReadStatus, retryAfterMilliseconds } from './readRetry.js'

export const usageWindowsSchema = z.array(z.object({
groupId: z.string().uuid(), groupName: z.string(), limited: z.boolean(), blocked: z.boolean(), accountingPending: z.boolean(), availableAt: z.string().nullable(),
windows: z.array(z.object({ id: z.string().uuid(), durationSeconds: z.number().int().positive(), usedPercent: z.number().min(0).max(100), resetsAt: z.string().nullable() })),
}))
export type UsageWindowsStatus = z.infer<typeof usageWindowsSchema>
export async function fetchUsageWindows(signal?: AbortSignal): Promise<UsageWindowsStatus> {
const owner = getOauthAccountInfo()?.accountUuid
return readWithRetry(attemptSignal => withOAuth401Retry(async () => {
if (getOauthAccountInfo()?.accountUuid !== owner) throw new Error('A conta mudou durante a consulta.')
const tokens = await getClaudeAIOAuthTokensAsync()
if (!tokens?.accessToken) throw new Error('Execute /login para consultar o uso.')
const response = await axios.get(`${VERBOO_API_BASE_URL}/api/me/usage-windows`, { headers: { Authorization: `Bearer ${tokens.accessToken}` }, timeout: 10_000, signal: attemptSignal })
if (getOauthAccountInfo()?.accountUuid !== owner) throw new Error('A conta mudou durante a consulta.')
return parseApiEnvelope(usageWindowsSchema, response.data, 'janelas de uso')
}), error => {
if (axios.isAxiosError(error)) return { retry: error.code !== 'ERR_CANCELED' && (error.response ? retryableReadStatus.has(error.response.status) : true), delayMs: retryAfterMilliseconds(error.response?.headers?.['retry-after']) }
return { retry: error instanceof Error && error.name === 'TimeoutError' }
}, signal)
}
14 changes: 14 additions & 0 deletions src/services/api/withRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const envKeys = [
'OPENAI_MODEL',
'OPENAI_BASE_URL',
'OPENAI_API_BASE',
'CLAUDE_CODE_UNATTENDED_RETRY',
] as const

beforeEach(async () => {
Expand Down Expand Up @@ -62,6 +63,9 @@ async function importFreshWithRetryModule(
| 'foundry' = 'firstParty',
) {
mock.restore()
// Open builds omit feature-gated internal source files. Keep raw Bun tests
// deterministic while exercising the persistent retry branch explicitly.
mock.module('bun:bundle', () => ({ feature: (name: string) => name === 'UNATTENDED_RETRY' }))
mock.module('src/utils/model/providers.js', () => ({
...actualProviders,
getAPIProvider: () => provider,
Expand All @@ -70,6 +74,16 @@ async function importFreshWithRetryModule(
return import(`./withRetry.js?ts=${Date.now()}-${Math.random()}`)
}

test('quota windows stop after one attempt even with a retry budget', async () => {
process.env.CLAUDE_CODE_UNATTENDED_RETRY = '1'
const { withRetry, CannotRetryError } = await importFreshWithRetryModule('openai')
let calls = 0
const error = APIError.generate(429, { error: { code: 'usage_window_exhausted', message: '[openai_category=usage_window_exhausted] Uso pausado.' } }, 'Uso pausado.', new Headers())
const run = withRetry(async () => ({} as import('@anthropic-ai/sdk').default), async () => { calls++; throw error }, { model: 'verboo', thinkingConfig: { type: 'disabled' }, maxRetries: 10 })
await expect(run.next()).rejects.toBeInstanceOf(CannotRetryError)
expect(calls).toBe(1)
})

// --- parseOpenAIDuration ---
describe('parseOpenAIDuration', () => {
test('parses seconds: "1s" → 1000', async () => {
Expand Down
3 changes: 3 additions & 0 deletions src/services/api/withRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from '../rateLimitMocking.js'
import { REPEATED_529_ERROR_MESSAGE } from './errors.js'
import { extractConnectionErrorDetails } from './errorUtils.js'
import { isUsageWindowStop } from './usageWindowErrors.js'

const abortError = () => new APIUserAbortError()

Expand Down Expand Up @@ -266,6 +267,7 @@ export async function* withRetry<T>(
return await operation(client, attempt, retryContext)
} catch (error) {
lastError = error
if (isUsageWindowStop(error)) throw new CannotRetryError(error, retryContext)
logForDebugging(
`API error (attempt ${attempt}/${maxRetries + 1}): ${error instanceof APIError ? `${error.status} ${error.message}` : errorMessage(error)}`,
{ level: 'error' },
Expand Down Expand Up @@ -718,6 +720,7 @@ function handleGcpCredentialError(error: unknown): boolean {
}

function shouldRetry(error: APIError): boolean {
if (isUsageWindowStop(error)) return false
// Never retry mock errors - they're from /mock-limits command for testing
if (isMockRateLimitError(error)) {
return false
Expand Down
Loading