Para controlar o custo de LLM por cliente num SaaS Laravel, registre o campo usage que a API devolve em cada chamada e converta em dinheiro com uma tabela de preços versionada, guardando o valor como inteiro. Antes de chamar o modelo, reserve o custo máximo estimado num contador atômico no Redis e acerte a diferença quando a resposta chegar. É o modelo de pré-autorização e captura do cartão aplicado a tokens. Sem isso, um único tenant com um loop ou com uso fora da curva consome o orçamento de todos os outros, e você só descobre quando chega a fatura.
Trabalho com pagamentos, então meu reflexo com qualquer coisa que gasta dinheiro é tratá-la como transação financeira: medir na origem, guardar em centavos (ou menos), reservar antes de gastar e conciliar depois. Abaixo está como aplico isso às features de IA de um SaaS multi-tenant.
Por que o custo total da API não basta em um SaaS multi-tenant?
O painel da Anthropic ou da OpenAI mostra quanto a conta gastou, mas não diz qual cliente gastou. Num SaaS em que o agente de IA responde no WhatsApp, resume documentos ou classifica dados, o custo varia muito entre tenants. Um cliente com muito volume ou com uma integração mal feita, como um webhook que dispara o agente em loop, pode representar uma fatia grande da fatura enquanto paga o mesmo plano que os outros.
Você precisa responder a três perguntas que o painel do provedor não responde:
- Quanto cada tenant custou neste mês, por feature?
- Qual é a margem de cada plano depois do custo de LLM?
- Como impedir que um tenant passe de um teto antes de a conta estourar?
As duas primeiras são de medição. A terceira é de controle, e é a que exige mais cuidado com concorrência.
Onde capturar o uso de tokens de cada chamada ao Claude?
Na resposta da própria API, nunca numa estimativa. A Messages API da Anthropic devolve um objeto usage com quatro campos que importam:
input_tokens: tokens de entrada cobrados a preço cheio (os que vêm depois do último ponto de cache).output_tokens: tokens gerados pelo modelo.cache_creation_input_tokens: tokens gravados no cache do prompt caching, cobrados com acréscimo.cache_read_input_tokens: tokens lidos do cache, cobrados com desconto grande.
O total de entrada é a soma dos três campos de input. Quem soma só input_tokens subestima o consumo quando usa cache, e quem ignora cache_creation_input_tokens erra o custo para baixo. Se você ainda não usa cache, vale ler como o prompt caching e a Batch API cortam o custo do Claude em produção, porque isso muda a conta por tenant.
A regra de arquitetura é simples: todas as chamadas a LLM passam por uma única classe. Se cada feature chama Http::post direto, a medição vira opcional e alguém esquece. Com um ponto único, medir e limitar é inevitável.
Como modelar a tabela de consumo de LLM?
Uma linha por chamada, com o custo calculado no momento da chamada. Guardar só os tokens e calcular o custo na hora do relatório parece mais flexível, mas quebra quando o preço muda: o histórico passa a refletir a tabela nova. Guardo os tokens e também o custo já calculado, como um snapshot.
Schema::create('llm_usages', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained(); $table->string('feature', 64); // ex.: whatsapp_agent, resumo_documento $table->string('model', 64); $table->string('provider_request_id')->nullable()->unique(); $table->unsignedInteger('input_tokens'); $table->unsignedInteger('output_tokens'); $table->unsignedInteger('cache_write_tokens')->default(0); $table->unsignedInteger('cache_read_tokens')->default(0); $table->boolean('batch')->default(false); $table->unsignedBigInteger('cost_micros'); // micro-dólares $table->timestamp('created_at')->useCurrent(); $table->index(['tenant_id', 'created_at']); });
Decisões que valem explicar:
- Micro-dólares em inteiro. Uma chamada barata custa frações de centavo. Somar
floatem milhares de linhas acumula erro, edecimalfunciona, mas inteiro é mais simples de somar no Redis. Um micro-dólar é um milionésimo de dólar. provider_request_idúnico. Oidda mensagem devolvido pela API. Se o job que grava o uso rodar duas vezes, a constraint impede contar em dobro.featurecomo string. É o que permite responder qual funcionalidade custa caro e se ela se paga.- Conversão para real só no relatório. O provedor cobra em dólar; converter na gravação congela um câmbio que não é o da fatura.
Como calcular o custo em dinheiro sem erro de arredondamento?
Deixo os preços num arquivo de config, em micro-dólares por milhão de tokens, com os quatro tipos separados. Os valores abaixo são os da tabela pública da Anthropic no momento em que escrevo; confira a página de preços antes de usar, porque eles mudam.
// config/llm.php return [ // micro-dólares por milhão de tokens 'prices' => [ 'claude-sonnet-4-5' => [ 'input' => 3_000_000, 'output' => 15_000_000, 'cache_write' => 3_750_000, // cache de 5 minutos 'cache_read' => 300_000, ], 'claude-haiku-4-5' => [ 'input' => 1_000_000, 'output' => 5_000_000, 'cache_write' => 1_250_000, 'cache_read' => 100_000, ], ], ];
O preço de escrita de cache acima é o do TTL de 5 minutos. Se você usa o cache de 1 hora, o multiplicador é maior e precisa de uma entrada própria.
O cálculo soma tudo no numerador e divide uma vez só, arredondando para cima. Arredondar para cima é deliberado: prefiro superestimar o custo de um cliente em um micro-dólar a subestimar.
final class LlmCostCalculator { public function cost(string $model, array $usage, bool $batch = false): int { $p = config("llm.prices.{$model}") ?? throw new RuntimeException("Preço não configurado para {$model}"); $numerator = ($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']; $micros = intdiv($numerator + 999_999, 1_000_000); // Batch API cobra metade do preço return $batch ? intdiv($micros + 1, 2) : $micros; } public function estimateMax(string $model, array $payload): int { $p = config("llm.prices.{$model}"); // Heurística conservadora: superestima tokens de entrada pelo tamanho em bytes $bytes = strlen(json_encode([ $payload['system'] ?? null, $payload['messages'], $payload['tools'] ?? null, ])); $inputTokens = intdiv($bytes, 3) + 1; $numerator = $inputTokens * max($p['input'], $p['cache_write']) + $payload['max_tokens'] * $p['output']; return intdiv($numerator + 999_999, 1_000_000); } }
O estimateMax não precisa ser preciso, precisa ser um teto. A saída é limitada por max_tokens, então o pior caso de saída é exato. Para a entrada, uso uma heurística por bytes que erra para cima. Se precisar de mais precisão, a Anthropic tem o endpoint POST /v1/messages/count_tokens, que conta os tokens de entrada sem gerar resposta, ao custo de uma ida extra à rede. Para planejar o orçamento de cada plano antes de escrever código, a calculadora de custo de IA faz essa conta por modelo, com e sem cache e Batch API.
Como impor uma cota mensal por tenant sem race condition?
O erro clássico é ler o gasto do mês no banco, comparar com o limite e então chamar o modelo. Com dez jobs do mesmo tenant rodando ao mesmo tempo no Horizon, os dez leem o mesmo saldo, os dez passam na checagem e o tenant estoura o teto. É a mesma race condition do saldo de conta digital, só que com tokens.
A solução que uso é reservar e liquidar:
- Antes da chamada, calcular o custo máximo estimado.
- Num script Lua no Redis, que roda de forma atômica, verificar se gasto + reserva cabe no limite e, se couber, somar a reserva ao contador.
- Depois da resposta, ajustar o contador pela diferença entre o custo real e o reservado.
final class LlmBudgetGuard { private const RESERVE = <<<'LUA' local spent = tonumber(redis.call('GET', KEYS[1]) or '0') if spent + tonumber(ARGV[1]) > tonumber(ARGV[2]) then return 0 end redis.call('INCRBY', KEYS[1], ARGV[1]) redis.call('EXPIRE', KEYS[1], ARGV[3]) return 1 LUA; public function reserve(Tenant $tenant, int $micros): bool { return (bool) Redis::eval( self::RESERVE, 1, $this->key($tenant), $micros, $tenant->llm_monthly_budget_micros, 60 * 60 * 24 * 40, ); } public function settle(Tenant $tenant, int $reserved, int $actual): void { $delta = $actual - $reserved; if ($delta !== 0) { Redis::incrby($this->key($tenant), $delta); } } private function key(Tenant $tenant): string { return sprintf('llm:budget:%d:%s', $tenant->id, now()->format('Y-m')); } }
O Redis é o controle rápido, não a fonte da verdade. A fonte da verdade é a tabela llm_usages. Se a chave sumir (flush, failover, troca de instância), reconstruo o contador somando o cost_micros do mês no PostgreSQL antes de liberar novas reservas. O custo dessa decisão é ter dois lugares com o mesmo número, e por isso existe a conciliação mais abaixo.
O trade-off da reserva pelo teto é que, perto do limite, o tenant pode ser bloqueado mesmo tendo saldo para a chamada real, porque a reserva é maior que o custo final. Na prática isso só acontece na margem e é preferível a deixar passar.
Cota de dinheiro não substitui limite de taxa. Para conter loops, combino com o RateLimiter do Laravel por tenant e por feature, por exemplo, um número máximo de chamadas do agente por minuto. A cota mensal protege a margem; o rate limit protege contra o bug que gastaria a cota inteira em meia hora.
Como medir o uso dentro do cliente HTTP do Claude?
Juntando as peças, a classe única que todas as features usam fica assim:
final class ClaudeClient { public function __construct( private LlmBudgetGuard $budget, private LlmCostCalculator $calculator, ) {} public function message(Tenant $tenant, string $feature, array $payload): array { $reserved = $this->calculator->estimateMax($payload['model'], $payload); if (! $this->budget->reserve($tenant, $reserved)) { throw new LlmBudgetExceeded($tenant->id, $feature); } try { $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ]) ->timeout(60) ->post('https://api.anthropic.com/v1/messages', $payload + [ 'metadata' => ['user_id' => hash('sha256', 'tenant:'.$tenant->id)], ]) ->throw() ->json(); } catch (RequestException $e) { // A API respondeu com erro: não houve geração, devolvo a reserva $this->budget->settle($tenant, $reserved, 0); throw $e; } // ConnectionException (timeout) não é capturada: a reserva fica presa de propósito $cost = $this->calculator->cost($payload['model'], $response['usage']); LlmUsage::create([ 'tenant_id' => $tenant->id, 'feature' => $feature, 'model' => $response['model'], 'provider_request_id' => $response['id'], '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_micros' => $cost, ]); $this->budget->settle($tenant, $reserved, $cost); return $response; } }
O campo metadata.user_id é aceito pela Messages API e serve para o provedor identificar abuso por usuário final. Mando um hash, nunca o ID cru nem dados pessoais, pelo mesmo motivo que não mando CPF no prompt.
Um detalhe: gravo o model que veio na resposta, não o que pedi. Se você usa um alias, a resposta diz qual versão realmente atendeu, e é essa que importa para auditoria.
E os retries, timeouts e chamadas que falham?
Aqui está o buraco mais comum na medição. Quando a requisição dá timeout do seu lado, o modelo pode ter gerado a resposta inteira e o provedor cobrou, mas você nunca recebeu o usage. Por isso, no código acima, um erro HTTP com resposta (4xx, 5xx) devolve a reserva, mas um timeout mantém a reserva presa. Prefiro contar uma chamada que talvez não tenha sido cobrada a deixar de contar uma que foi.
Se o job tentar de novo, a nova tentativa faz uma nova reserva. Isso é correto: o retry pode realmente custar duas vezes. Detalhei como reduzir essa duplicação de custo em como chamar LLMs no Laravel com filas, retries e timeouts; o ponto aqui é que a medição precisa refletir o que o retry custou, e não o que você gostaria que ele tivesse custado.
No streaming, o usage chega fracionado nos eventos: os tokens de entrada vêm no message_start e a contagem final de saída vem no message_delta. Se a conexão cair no meio do stream, o mesmo raciocínio vale: mantenha a reserva.
O que fazer quando o cliente estoura a cota?
Bloquear não é a única opção, e nem sempre é a melhor para o produto. As estratégias que considero:
| Estratégia | Quando usar | Trade-off |
|---|---|---|
| Bloqueio duro | Plano gratuito, trial | Experiência ruim; o cliente precisa ver o motivo claramente |
| Alerta em 80% e 100% | Qualquer plano pago | Só funciona se alguém lê o alerta |
| Trocar para um modelo menor | Features em que o Haiku dá conta | A qualidade cai sem o cliente saber, a menos que você avise |
| Adiar para a Batch API | Tarefas sem urgência, como resumos e classificação | A resposta pode demorar horas |
| Cobrar excedente | Planos com uso variável | Exige que sua medição seja confiável o bastante para virar fatura |
Num agente de atendimento no WhatsApp, bloquear no meio da conversa é péssimo. Ali prefiro trocar o modelo e mandar uma resposta padrão encaminhando para um humano se até o modelo menor estiver fora do limite. Em tarefas de fundo, adiar para batch quase sempre resolve.
A última linha da tabela é a que mais pesa: se você pretende cobrar excedente, a tabela de uso deixa de ser telemetria e vira base de faturamento. Aí o arredondamento para cima, o ID único da requisição e o snapshot do preço deixam de ser capricho.
Como conferir se a sua medição bate com a fatura da Anthropic?
Do mesmo jeito que se concilia pagamento: um job diário que soma o cost_micros do dia anterior na sua tabela e compara com o consumo informado pelo provedor, seja no Console, seja pela Admin API de uso e custo da Anthropic. Se a diferença passar de uma tolerância, alguma chamada está fora do cliente central ou o preço no config está desatualizado. É o mesmo raciocínio da conciliação de pagamentos com o extrato do provedor, com a vantagem de que aqui não há estorno nem chargeback.
Quando há mais de um produto na mesma organização, uso workspaces separados na Anthropic, cada um com sua chave. A conciliação fica por produto, e um vazamento de chave não mistura os custos.
As divergências que mais aparecem:
- Uma feature nova chamando a API fora do cliente central.
- Preço de modelo novo ou de cache de 1 hora não cadastrado no config.
- Chamadas em batch registradas sem a flag
batch, o que dobra o custo na sua tabela. - Timeouts que você contou e o provedor não cobrou, normalmente uma diferença pequena e a seu favor.
O que eu implementaria primeiro
Se hoje você não mede nada, não comece pela cota. Comece pelo cliente único e pela tabela llm_usages com o custo em micro-dólares, porque uma semana de dados já mostra qual tenant e qual feature pesam e se o plano se paga. Em seguida, coloque o rate limit por tenant, que é barato e corta o pior cenário, o loop. Só então implemente a reserva no Redis e a cota mensal, com a política de estouro decidida junto com quem cuida do produto. Por último, o job de conciliação diária, que é o que dá confiança para um dia transformar esse número em cobrança.