Skip to main content
Laravel, thinking fast.
Chapter 4 · What a Token Costs You

Key the Ceiling to the Thing That Pays

Julian Beaujardin

The key deserves as much thought as the limit:

'ai-editable:'.$site->id.':'.now()->format('Y-m-d')

The obvious choice is the credential the request arrived with. It is wrong. That editing credential is short-lived by design and rotates several times an hour — and a budget keyed to it resets every rotation. The limit would be perfectly enforced and completely ineffective, which is the worst combination, because it looks like it is working.

Key it to the durable thing that maps to the money: the site, the workspace, the account. Then the ceiling holds across sessions, credentials and devices, however many editing sessions are opened.

The date in the key is what makes it expire naturally. No scheduled reset, no cleanup job, no chance of a reset task failing quietly and leaving a tenant permanently blocked.

Say No Properly

A refusal is a response, and it deserves the same care as a success.

Use 429, not 403. This is not a permission failure. The caller may try again, and the status code should say so.

Send Retry-After. Computed to when the budget actually resets — end of day, here — not a guessed constant. A client that respects it stops hammering you, and one that does not is at least being told the truth.

Say which limit was hit. "Daily AI edit limit reached for this site" tells a merchant what happened and that it is not permanent. "Too many requests" tells them nothing and generates a support ticket.

The same rule as everywhere else in this book: the failure path is part of the feature.

Read the Usage Block

You cannot manage what you never look at, and the usage numbers come back on every response — free, already paid for, and almost universally discarded.

Accumulate them across a turn, since a turn is many calls:

private function accumulateUsage(array $response): void
{
    $usage = $response['usage'] ?? null;

    if (! is_array($usage)) {
        return;
    }

    foreach (['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'] as $key) {
        $this->usage[$key] = ($this->usage[$key] ?? 0) + (int) ($usage[$key] ?? 0);
    }
}

Then log the total once, with the iteration count beside it:

Log::info('ai_widget.turn', [
    'iterations' => $iterations,
    'input_tokens' => $this->usage['input_tokens'] ?? 0,
    'output_tokens' => $this->usage['output_tokens'] ?? 0,
    'cache_read_input_tokens' => $this->usage['cache_read_input_tokens'] ?? 0,
    'cache_creation_input_tokens' => $this->usage['cache_creation_input_tokens'] ?? 0,
]);

Each number answers a question you will eventually be asked:

  • Input climbing across a turn — the history is growing, as it must; the question is whether the growth is linear or quadratic.
  • Output near the cap — either your cap is too low or your prompt is asking for too much.
  • Iterations high — the model is reaching for tools repeatedly, which is a prompt or tool-description problem, not a budget one.
  • Cache reads at zero — the next section.

Before this existed, spend was both uncapped and invisible. Uncapped is the dangerous one; invisible is why nobody noticed.