From 4ac11b0ea772bf47231a481a1b33a4b3fe20c3d7 Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Sat, 26 Sep 2026 11:14:34 -0300 Subject: [PATCH] feat: show usage-window percentages and stop exhausted retries --- package.json | 2 +- src/components/Settings/VerbooUsage.tsx | 41 +++++++++++++++-- src/services/api/errors.ts | 5 +++ src/services/api/openaiErrorClassification.ts | 13 ++++++ src/services/api/readRetry.ts | 45 +++++++++++++++++++ src/services/api/usageWindowErrors.test.ts | 32 +++++++++++++ src/services/api/usageWindowErrors.ts | 12 +++++ src/services/api/verbooUsageWindows.ts | 27 +++++++++++ src/services/api/withRetry.test.ts | 14 ++++++ src/services/api/withRetry.ts | 3 ++ 10 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 src/services/api/readRetry.ts create mode 100644 src/services/api/usageWindowErrors.test.ts create mode 100644 src/services/api/usageWindowErrors.ts create mode 100644 src/services/api/verbooUsageWindows.ts diff --git a/package.json b/package.json index e132319397..8cfe37e7c2 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/Settings/VerbooUsage.tsx b/src/components/Settings/VerbooUsage.tsx index bd8d591a09..f33c5eb939 100644 --- a/src/components/Settings/VerbooUsage.tsx +++ b/src/components/Settings/VerbooUsage.tsx @@ -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, @@ -12,19 +14,50 @@ export function VerbooUsage({ }): React.ReactNode { const [status, setStatus] = React.useState(null) const [failed, setFailed] = React.useState(false) + const [windows, setWindows] = React.useState(null) + const [owner, setOwner] = React.useState(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 | 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 Carregando consumo… + return ( {!status && !failed && Carregando consumo…} {failed && Não foi possível consultar o saldo. Tente novamente.} + {windows?.map(group => + {group.groupName} + {!group.limited && Sem limite de janela.} + {group.accountingPending && Uso pausado até a confirmação das solicitações anteriores.} + {group.blocked && Limite de uso atingido. Aguarde a renovação.} + {group.windows.map(window => + {(window.durationSeconds / 3600).toLocaleString('pt-BR', { maximumFractionDigits: 2 })}h: {window.usedPercent.toLocaleString('pt-BR')}% utilizado + {window.resetsAt ? `Renova em ${new Date(window.resetsAt).toLocaleString('pt-BR')}` : 'A janela começa no próximo uso.'} + )} + )} {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. diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index c3671225b5..d303d0a7a9 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -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': diff --git a/src/services/api/openaiErrorClassification.ts b/src/services/api/openaiErrorClassification.ts index 2e393b81d7..87f7c86222 100644 --- a/src/services/api/openaiErrorClassification.ts +++ b/src/services/api/openaiErrorClassification.ts @@ -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' @@ -39,6 +42,9 @@ const OPENAI_COMPATIBILITY_FAILURE_CATEGORIES: ReadonlySet( + request: (signal: AbortSignal) => Promise, + classify: (error: unknown) => { retry: boolean; delayMs?: number }, + signal?: AbortSignal, +): Promise { + 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((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 +} diff --git a/src/services/api/usageWindowErrors.test.ts b/src/services/api/usageWindowErrors.test.ts new file mode 100644 index 0000000000..426a824f6c --- /dev/null +++ b/src/services/api/usageWindowErrors.test.ts @@ -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) +}) diff --git a/src/services/api/usageWindowErrors.ts b/src/services/api/usageWindowErrors.ts new file mode 100644 index 0000000000..43932ff370 --- /dev/null +++ b/src/services/api/usageWindowErrors.ts @@ -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) +} diff --git a/src/services/api/verbooUsageWindows.ts b/src/services/api/verbooUsageWindows.ts new file mode 100644 index 0000000000..fe2a9cd4f3 --- /dev/null +++ b/src/services/api/verbooUsageWindows.ts @@ -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 +export async function fetchUsageWindows(signal?: AbortSignal): Promise { + 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) +} diff --git a/src/services/api/withRetry.test.ts b/src/services/api/withRetry.test.ts index b24c9d1756..9b3fab2662 100644 --- a/src/services/api/withRetry.test.ts +++ b/src/services/api/withRetry.test.ts @@ -29,6 +29,7 @@ const envKeys = [ 'OPENAI_MODEL', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', + 'CLAUDE_CODE_UNATTENDED_RETRY', ] as const beforeEach(async () => { @@ -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, @@ -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 () => { diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index f615945a88..bfbcbb492f 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -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() @@ -266,6 +267,7 @@ export async function* withRetry( 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' }, @@ -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