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:

  1. Quanto cada tenant custou neste mês, por feature?
  2. Qual é a margem de cada plano depois do custo de LLM?
  3. 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 float em milhares de linhas acumula erro, e decimal funciona, mas inteiro é mais simples de somar no Redis. Um micro-dólar é um milionésimo de dólar.
  • provider_request_id único. O id da mensagem devolvido pela API. Se o job que grava o uso rodar duas vezes, a constraint impede contar em dobro.
  • feature como 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:

  1. Antes da chamada, calcular o custo máximo estimado.
  2. 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.
  3. 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:

  1. Uma feature nova chamando a API fora do cliente central.
  2. Preço de modelo novo ou de cache de 1 hora não cadastrado no config.
  3. Chamadas em batch registradas sem a flag batch, o que dobra o custo na sua tabela.
  4. 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.