Para controlar o custo de LLM por cliente no Laravel, você precisa de três peças: registrar o campo usage de cada resposta da API com o custo calculado em inteiros, reservar uma estimativa de orçamento no Redis antes da chamada e acertar a diferença depois, como se fosse uma pré-autorização de cartão. Com isso, um cliente que esgota a cota do plano é barrado na hora, e não no fechamento do mês, quando a fatura da Anthropic já chegou.
Esse problema aparece em todo SaaS multi-tenant que coloca um agente de IA na mão do usuário. A média de uso costuma ser tranquila. O que dói é a cauda: um tenant com um fluxo em loop, um script mal escrito batendo no seu endpoint ou um usuário que descobriu que pode colar PDFs inteiros no chat. Sem medição por cliente, você só descobre olhando o painel do provedor, e lá não aparece quem gastou.
O que você vai ter funcionando no final
- Uma tabela
llm_usagescom tokens e custo de cada chamada, por tenant e por funcionalidade. - Um cálculo de custo em nano-dólares (inteiro), sem erro de arredondamento de
float. - Uma cota mensal por plano, controlada no Redis com reserva antes da chamada e acerto depois.
- Um limite de requisições por minuto por tenant.
- Um alerta quando o tenant passa de 80% da cota.
- Um comando que reconcilia o contador do Redis com o banco e gera o relatório do mês.
Pré-requisitos: PHP 8.2+, Laravel 11 ou 12, PostgreSQL, Redis configurado (REDIS_CLIENT=phpredis ou predis), uma chave da API da Anthropic e uma tabela tenants com a coluna plan. Vou chamar a API do Claude direto com o Http:: do Laravel, sem SDK, para o código não depender de pacote nenhum.
1. Como configurar preços e planos sem usar float?
O primeiro erro que vejo é guardar custo como float em dólar. Somar milhares de 0.000003 dá lixo de arredondamento, e quem vem de fintech sabe que dinheiro é inteiro. A saída é escolher uma unidade pequena o bastante para todos os preços virarem inteiros.
Os preços da Anthropic são publicados em USD por milhão de tokens (MTok). Se você multiplicar esse valor por 1.000, obtém nano-dólares por token (1 nano-dólar = 10⁻⁹ USD). Um preço de US$ 3/MTok vira 3.000 nano-dólares por token; US$ 0,30/MTok vira 300. Tudo inteiro.
No .env:
ANTHROPIC_API_KEY=sk-ant-... LLM_ALERT_THRESHOLD=0.8
Crie config/llm.php:
<?php return [ 'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'version' => '2023-06-01', 'timeout' => 60, ], // nano-dólares por token = preço em USD por MTok × 1000 // Confira os valores atuais na página de preços da Anthropic antes de usar. 'prices' => [ 'claude-sonnet-4-5' => ['input' => 3000, 'output' => 15000, 'cache_write' => 3750, 'cache_read' => 300], 'claude-haiku-4-5' => ['input' => 1000, 'output' => 5000, 'cache_write' => 1250, 'cache_read' => 100], ], 'plans' => [ 'basico' => ['monthly_usd' => 5, 'per_minute' => 10], 'pro' => ['monthly_usd' => 30, 'per_minute' => 40], ], 'alert_threshold' => (float) env('LLM_ALERT_THRESHOLD', 0.8), ];
US$ 30 em nano-dólares são 30.000.000.000, que cabe folgado em bigint e em int de 64 bits do PHP. Se quiser simular quanto cada plano aguenta antes de definir a cota, a calculadora de custo de IA faz a conta por modelo, com e sem prompt caching, em dólar e em real.
2. Como registrar o uso de tokens de cada chamada?
php artisan make:model LlmUsage -m
Na migration:
Schema::create('llm_usages', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->string('feature', 50); $table->string('model', 60); $table->string('message_id', 100)->unique(); $table->unsignedInteger('input_tokens'); $table->unsignedInteger('output_tokens'); $table->unsignedInteger('cache_write_tokens')->default(0); $table->unsignedInteger('cache_read_tokens')->default(0); $table->unsignedBigInteger('cost_nano_usd'); $table->timestamp('created_at')->useCurrent(); $table->index(['tenant_id', 'created_at']); });
O message_id é o id que a API devolve (msg_...). O índice único impede que o mesmo resultado seja gravado duas vezes se o job for reprocessado depois de já ter salvo. A coluna feature é o que transforma o relatório em algo útil: saber que o tenant gastou US$ 20 é meio caminho, saber que US$ 18 vieram do resumo automático de conversas é o que te deixa agir.
No model:
class LlmUsage extends Model { public const UPDATED_AT = null; protected $guarded = []; }
3. Como calcular o custo a partir do campo usage?
A resposta da Messages API traz um objeto usage com até quatro contadores: input_tokens, output_tokens, cache_creation_input_tokens e cache_read_input_tokens. Um detalhe que pega muita gente: input_tokens não inclui os tokens lidos ou gravados no cache. Se você usa prompt caching (expliquei o mecanismo no post sobre prompt caching e Batch API do Claude no Laravel), precisa somar os três com preços diferentes.
<?php namespace App\Llm; use InvalidArgumentException; final class LlmCost { public static function nanoUsd(string $model, array $usage): int { $p = config("llm.prices.{$model}") ?? throw new InvalidArgumentException("Sem preço configurado para {$model}"); return ($usage['input_tokens'] ?? 0) * $p['input'] + ($usage['output_tokens'] ?? 0) * $p['output'] + ($usage['cache_creation_input_tokens'] ?? 0) * $p['cache_write'] + ($usage['cache_read_input_tokens'] ?? 0) * $p['cache_read']; } public static function worstCase(string $model, int $inputTokens, int $maxTokens): int { $p = config("llm.prices.{$model}"); return $inputTokens * $p['input'] + $maxTokens * $p['output']; } }
O worstCase é a estimativa pessimista: todo o input cobrado a preço cheio e o modelo gerando até o max_tokens. É esse valor que vamos reservar.
4. Como bloquear o cliente antes de estourar a cota mensal?
Aqui entra a ideia que tirei direto de pagamentos: reserva e captura. Antes de chamar o Claude, você reserva no Redis o custo do pior caso. Se a reserva passar do limite, recusa. Quando a resposta chega, troca a reserva pelo custo real. É exatamente o que a maquininha faz com pré-autorização.
Por que não só checar o gasto atual? Porque com dez requisições simultâneas do mesmo tenant, todas leem "ainda tem saldo" ao mesmo tempo e passam juntas. O INCRBY do Redis é atômico e devolve o novo total, então quem estourar sabe na hora.
<?php namespace App\Llm; use App\Models\Tenant; use Illuminate\Support\Facades\Redis; final class Reservation { public function __construct( public readonly string $key, public readonly int $amount, ) {} } final class LlmBudget { public function key(Tenant $tenant): string { return "llm:budget:{$tenant->id}:" . now('America/Sao_Paulo')->format('Y-m'); } public function limit(Tenant $tenant): int { return (int) config("llm.plans.{$tenant->plan}.monthly_usd") * 1_000_000_000; } public function reserve(Tenant $tenant, int $nano): Reservation { $key = $this->key($tenant); $total = (int) Redis::incrby($key, $nano); Redis::expire($key, 60 * 60 * 24 * 45); if ($total > $this->limit($tenant)) { Redis::decrby($key, $nano); throw new LlmQuotaExceeded($tenant); } return new Reservation($key, $nano); } public function settle(Reservation $r, int $actual): void { $diff = $actual - $r->amount; if ($diff !== 0) { Redis::incrby($r->key, $diff); } } public function release(Reservation $r): void { Redis::decrby($r->key, $r->amount); } public function spent(Tenant $tenant): int { return (int) Redis::get($this->key($tenant)); } }
Repare que a Reservation guarda a chave do mês. Se a chamada começar às 23:59:58 do dia 31 e terminar no dia 1, o acerto cai no mês certo, e não sobra reserva pendurada no mês anterior.
A exceção é simples:
namespace App\Llm; use App\Models\Tenant; use RuntimeException; class LlmQuotaExceeded extends RuntimeException { public function __construct(public readonly Tenant $tenant) { parent::__construct("Cota mensal de IA esgotada para o tenant {$tenant->id}"); } }
5. Como estimar os tokens de entrada antes da chamada?
Você tem duas opções:
| Opção | Precisão | Custo extra | Quando usar |
|---|---|---|---|
Heurística (mb_strlen / 3) |
Aproximada | Nenhum | Prompts curtos, chat comum |
Endpoint /v1/messages/count_tokens |
Exata | Uma requisição a mais (latência) | PDFs, imagens, contextos grandes |
O endpoint de contagem recebe o mesmo corpo da Messages API (sem max_tokens) e devolve {"input_tokens": N}. Para a maioria dos casos de chat, a heurística basta, porque a reserva é corrigida no acerto de qualquer jeito. Uso a contagem exata só quando o input é grande o suficiente para a estimativa errada fazer diferença na cota.
6. Como juntar tudo num cliente do Claude com limite por minuto?
<?php namespace App\Llm; use App\Events\LlmBudgetThresholdReached; use App\Models\LlmUsage; use App\Models\Tenant; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\RateLimiter; final class ClaudeClient { public function __construct(private LlmBudget $budget) {} public function message(Tenant $tenant, string $feature, array $payload): array { $this->throttle($tenant); $model = $payload['model']; $estimate = LlmCost::worstCase($model, $this->estimateInput($payload), $payload['max_tokens']); $reservation = $this->budget->reserve($tenant, $estimate); try { $response = $this->http()->post('/v1/messages', $payload)->throw()->json(); } catch (RequestException $e) { // Erro HTTP (4xx/5xx): a requisição não gerou cobrança de tokens. $this->budget->release($reservation); throw $e; } // ConnectionException (timeout) não é capturada de propósito: // não sabemos se foi cobrado, então a reserva fica. $cost = LlmCost::nanoUsd($model, $response['usage']); $this->budget->settle($reservation, $cost); LlmUsage::firstOrCreate( ['message_id' => $response['id']], [ 'tenant_id' => $tenant->id, 'feature' => $feature, 'model' => $model, 'input_tokens' => $response['usage']['input_tokens'], 'output_tokens' => $response['usage']['output_tokens'], 'cache_write_tokens' => $response['usage']['cache_creation_input_tokens'] ?? 0, 'cache_read_tokens' => $response['usage']['cache_read_input_tokens'] ?? 0, 'cost_nano_usd' => $cost, ], ); $this->maybeAlert($tenant); return $response; } private function throttle(Tenant $tenant): void { $key = "llm:rpm:{$tenant->id}"; $max = (int) config("llm.plans.{$tenant->plan}.per_minute"); if (RateLimiter::tooManyAttempts($key, $max)) { throw new LlmRateLimited(RateLimiter::availableIn($key)); } RateLimiter::hit($key, 60); } private function estimateInput(array $payload): int { $text = json_encode([$payload['system'] ?? '', $payload['messages'], $payload['tools'] ?? []]); return (int) ceil(mb_strlen($text) / 3); } private function maybeAlert(Tenant $tenant): void { $spent = $this->budget->spent($tenant); $limit = $this->budget->limit($tenant); if ($spent < $limit * config('llm.alert_threshold')) { return; } $flag = 'llm:alert:' . $this->budget->key($tenant); if (Cache::add($flag, true, now()->addDays(45))) { LlmBudgetThresholdReached::dispatch($tenant, $spent, $limit); } } private function http(): PendingRequest { return Http::baseUrl('https://api.anthropic.com') ->withHeaders([ 'x-api-key' => config('llm.anthropic.key'), 'anthropic-version' => config('llm.anthropic.version'), ]) ->acceptJson() ->timeout(config('llm.anthropic.timeout')); } }
Crie LlmRateLimited igual à outra exceção, recebendo $retryAfter, e o evento com php artisan make:event LlmBudgetThresholdReached. O Cache::add só grava se a chave não existir, então o alerta dispara uma vez por mês por tenant, mesmo com várias requisições cruzando os 80% ao mesmo tempo.
O JSON enviado é o padrão da Messages API:
{ "model": "claude-haiku-4-5", "max_tokens": 512, "system": "Você é o assistente de agendamento.", "messages": [{"role": "user", "content": "Tem horário amanhã às 15h?"}] }
Se essa chamada roda dentro de um job com retries, combine com o que mostrei em como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo: cada retry é uma nova reserva, o que é correto, porque cada tentativa que chega no provedor é cobrada.
7. Como devolver um erro amigável quando a cota acaba?
Em bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (\App\Llm\LlmQuotaExceeded $e) { return response()->json([ 'message' => 'Seu plano atingiu o limite de uso da IA neste mês.', ], 429); }); $exceptions->render(function (\App\Llm\LlmRateLimited $e) { return response()->json(['message' => 'Muitas requisições, tente em instantes.'], 429) ->header('Retry-After', $e->retryAfter); }); })
No atendimento por WhatsApp, em vez de um 429 seco, faça o agente responder com uma mensagem fixa e encaminhar para humano. Usuário final não entende cota, entende "vou te passar para a equipe".
8. Como reconciliar o Redis com o banco e gerar o relatório do mês?
O Redis é rápido, mas a fonte da verdade é a tabela llm_usages. Se o Redis reiniciar sem persistência, o contador zera e o tenant ganha a cota de novo. Um comando agendado resolve:
php artisan make:command LlmReconcile
class LlmReconcile extends Command { protected $signature = 'llm:reconcile'; public function handle(LlmBudget $budget): void { $start = now('America/Sao_Paulo')->startOfMonth()->utc(); $rows = LlmUsage::query() ->where('created_at', '>=', $start) ->selectRaw('tenant_id, feature, sum(cost_nano_usd) as cost') ->groupBy('tenant_id', 'feature') ->get(); foreach (Tenant::whereIn('id', $rows->pluck('tenant_id')->unique())->get() as $tenant) { $db = (int) $rows->where('tenant_id', $tenant->id)->sum('cost'); $redis = $budget->spent($tenant); if ($redis < $db) { Redis::set($budget->key($tenant), $db); $this->warn("Tenant {$tenant->id}: Redis {$redis} < banco {$db}, corrigido"); } } $this->table(['tenant', 'feature', 'USD'], $rows->map(fn ($r) => [ $r->tenant_id, $r->feature, number_format($r->cost / 1e9, 4), ])); } }
Ele só corrige para cima. Se o Redis estiver maior que o banco, a diferença normalmente são reservas em andamento ou timeouts retidos de propósito, e baixar o contador liberaria cota indevida. Agende em routes/console.php com Schedule::command('llm:reconcile')->hourly();.
9. Como testar o controle de custo?
it('registra o custo e acerta a reserva', function () { Http::fake(['api.anthropic.com/v1/messages' => Http::response([ 'id' => 'msg_test_1', 'content' => [['type' => 'text', 'text' => 'ok']], 'usage' => ['input_tokens' => 1000, 'output_tokens' => 200], ])]); $tenant = Tenant::factory()->create(['plan' => 'basico']); app(ClaudeClient::class)->message($tenant, 'chat', [ 'model' => 'claude-haiku-4-5', 'max_tokens' => 512, 'messages' => [['role' => 'user', 'content' => 'oi']], ]); $expected = 1000 * 1000 + 200 * 5000; expect(LlmUsage::first()->cost_nano_usd)->toBe($expected) ->and(app(LlmBudget::class)->spent($tenant))->toBe($expected); });
Esse teste precisa de um Redis real (ou um banco Redis separado para testes). Para o caso de cota estourada, faça Redis::set da chave com o limite e espere LlmQuotaExceeded, garantindo com Http::assertNothingSent() que nada saiu para a API.
Erros comuns
- Custo de cache ignorado. Somar só
input_tokenseoutput_tokenssubestima a conta de quem usa prompt caching, porque os tokens de cache vêm em campos separados. - Usar o
modelda resposta para buscar o preço. A API pode devolver o identificador com data (versão fixada), que não bate com a chave da config. Use o modelo que você enviou, ou normalize o nome. - Reserva vazando. Se o código lança exceção entre
reserveesettlesem liberar, o contador sobe para sempre. Libere em erro HTTP; em timeout, segure de propósito e deixe a reconciliação ajustar. - Mês em UTC.
now()->format('Y-m')em UTC vira o mês às 21h do horário de Brasília. Se a cota é "do mês" para o cliente brasileiro, use o fuso dele na chave e na reconciliação. - Streaming sem usage. Com SSE, o uso vem distribuído:
message_starttraz o input emessage_deltatraz ooutput_tokensacumulado. Se você só grava ao fim da resposta e o cliente fecha a conexão no meio, perde o registro. Grave no últimomessage_deltaque receber; o post sobre streaming de respostas do Claude com Server-Sent Events mostra onde esses eventos chegam. max_tokensgigante. Como a reserva usa o pior caso, ummax_tokensde 8.000 numa resposta que costuma ter 300 tokens bloqueia cota à toa e recusa tenants que ainda tinham saldo. Ajuste omax_tokenspor feature.
Conclusão prática
Comece pela tabela llm_usages com a coluna feature, mesmo que ainda não tenha cota nenhuma: em uma semana de dados você descobre qual funcionalidade custa caro e qual plano está no prejuízo. Depois ligue a reserva no Redis para os planos de entrada, que é onde o abuso aparece primeiro, e só então pense em cobrar excedente. Tratar token como dinheiro, em inteiro, com reserva, acerto e conciliação, é o que separa uma feature de IA que escala de uma que come a margem em silêncio.
Perguntas frequentes
Dá para usar o mesmo esquema com a API da OpenAI?
Sim. A resposta da OpenAI também traz um objeto usage, com nomes de campos diferentes. Basta adaptar o LlmCost::nanoUsd ao formato e cadastrar os preços dos modelos na config.
Por que não usar só o RateLimiter do Laravel para a cota mensal?
O RateLimiter conta tentativas, não valores. Uma chamada com 50 tokens e outra com 50 mil pesariam igual. Para cota em dinheiro você precisa somar custo, e o INCRBY faz isso de forma atômica.
Preciso gravar o uso no banco de forma síncrona?
Não necessariamente. Se a latência importa, despache um job com os dados do usage, já que o message_id único protege contra gravação duplicada. O controle de cota continua síncrono no Redis.
Como cobrar o cliente pelo excedente?
Com o custo por tenant já registrado em inteiro, o excedente é soma do mês − franquia do plano. Converta para real com a cotação do dia de fechamento e lance como item da fatura, guardando a cotação usada para auditoria.
A heurística de 3 caracteres por token é confiável em português?
É uma aproximação razoável para reservar orçamento, não para faturar. A cobrança e o registro usam sempre o usage real devolvido pela API, então o erro da estimativa só afeta a reserva temporária.