You priced the agent before you shipped it. One request, about two thousand tokens in, a few hundred out. Five steps at most.
Multiply by the rate card. Multiply by traffic. Put the number in the budget.
The first invoice is five times that.
Nothing errored. Every run finished and answered correctly, and the SDK’s own usage report agrees with the bill.
Here’s what the loop actually sends, measured on a real agent, and the two levers that change it. One of them is the caching everyone recommends. It made the run more expensive.
The number the SDK gives you is true and useless
Every tool-calling agent in the Laravel AI SDK runs a loop. The model answers or calls a tool. If it called a tool, the SDK runs it, appends the result to the conversation, and sends the whole conversation back.
Again. Until the model answers without a tool call, or MaxSteps runs out.
Every step re-sends everything before it.
The SDK sums usage across those steps and hands you one total on $response->usage. I checked the source. The total is honest, it is the whole loop.
It is also the number that hides the problem.
Here’s the research agent from the tools post, same query, instrumented per step. Anthropic, Sonnet 4.6, no caching.
| Step | Tools called | Context sent | Output |
|---|---|---|---|
| 1 | search ×2, database (parallel) | 1,386 | 305 |
| 2 | scrape ×2 (parallel) | 7,632 | 284 |
| 3 | none, final answer | 62,522 | 1,601 |
| Total | 71,540 | 2,190 |
The SDK reported 71,540 input tokens. True.
What it does not show is that step 3 was 87% of the run. Two scraped pages landed as tool results in step 2. Step 3 paid for them in full, then wrote the answer.
At Sonnet 4.6 rates that run is 25 cents. Take step 1 as the typical request, multiply by five steps, and the estimate says 4.5 cents. That’s the 5×.
It’s also the same agent I published three months ago at 39,000 tokens.
Scraped pages vary.
The cost of a run is a property of what your tools return, not of your prompt.
MaxSteps bounds the steps, not the bill
The SDK’s cost controls are MaxSteps, MaxTokens, and UseCheapestModel.
They cap the step count, the output length, and the rate.
None of them touch the thing that compounds.
Step 1 sends your instructions and tool schemas. Step 2 sends those again plus step 1’s results. Step 5 sends the prefix plus four steps of results, and the loop has paid for step 1’s results five times over.
So the bill compounds on two things. How many steps run, and how big the payload was when it landed.
Five steps re-send earlier results ten times over. Ten steps re-send them forty-five times over.
Doubling MaxSteps “to let it finish” doesn’t double the worst case. It quadruples it.
Here’s the shape at 8,000 new tokens a step, which is one modest scrape:
php artisan ai:estimate --steps=5 --per-step=8000
+------+--------------+------------+
| Step | Context sent | Cumulative |
+------+--------------+------------+
| 1 | 1,345 | 1,345 |
| 2 | 9,345 | 10,690 |
| 3 | 17,345 | 28,035 |
| 4 | 25,345 | 53,380 |
| 5 | 33,345 | 86,725 |
+------+--------------+------------+
Per-request estimate (5 steps × (prefix + per-step)): 46,725 input tokens $0.1777 per run
What the loop actually sends (N·S + u·N(N−1)/2): 86,725 input tokens $0.2977 per run
Ratio: 1.9× Last step alone: 33,345 tokens, 38% of the run
At 1,000 runs/month: $177.68 estimated, $297.68 actual. Doubling MaxSteps to 10: $1195.35.
The default MaxSteps with no attribute is 5.
If you read MaxSteps(10) as “ten requests’ worth of cost”, your estimate is already wrong.
Where the per-step numbers live
Middleware won’t help you here.
Agent middleware wraps the whole run: one prompt in, one response out. It never sees a step.
The per-step hooks are two events.
StartingStep fires before each request with the full message context about to be sent. StepCompleted fires after, with that step’s usage.
use Laravel\Ai\Events\StartingStep;
use Laravel\Ai\Events\StepCompleted;
class StepUsageRecorder
{
private array $rows = [];
public function starting(StartingStep $event): void
{
$this->rows[$event->invocationId][$event->stepNumber] = [
'context_chars' => strlen(json_encode($event->messages)),
];
}
public function completed(StepCompleted $event): void
{
$usage = $event->response->usage;
$this->rows[$event->invocationId][$event->stepNumber] += [
'tool_calls' => count($event->response->toolCalls),
'context_tokens' => $usage->promptTokens
+ $usage->cacheWriteInputTokens
+ $usage->cacheReadInputTokens,
'completion_tokens' => $usage->completionTokens,
];
}
}
Register both in a service provider with Event::listen, and bind the class as a singleton first. The dispatcher resolves a class listener from the container on every event, so without the binding each event lands on a fresh instance and the rows never accumulate.
stepNumber starts at zero. The tables in this post add one.
The invocationId is the same across every event of one run, so a listener can keep score per run without any other plumbing. The recorder, with a JSON dump per run, is one commit on the companion repo.
Two things in that code you’ll get wrong the first time.
Sum three fields, not one. promptTokens is the uncached remainder. On both Anthropic and OpenAI the provider bills prompt plus cache write plus cache read.
Once caching is on, the old Tokens: N in line I printed in the tools post reports 5 for a run that sent 77,849. Five.
$event->messages is not the whole request. It’s the conversation. The gateway adds the instructions and tool schemas on top, and for this agent they cost 1,345 tokens on Sonnet 4.6 and 620 on GPT-5.6.
Step 1 of a real run gives you that number. You need it for the next part.
Refuse the step before it’s sent
MaxSteps is a cap on the step count. You want a cap on tokens, and the SDK doesn’t have one.
A StartingStep listener is the place to put it. It runs before the request, and it can throw.
use App\Ai\Exceptions\TokenBudgetExceeded;
use Laravel\Ai\Events\StartingStep;
use Laravel\Ai\Events\StepCompleted;
class TokenBudget
{
public int $prefixTokens = 1345;
public float $charsPerToken = 3.0;
private array $spent = [];
public function __construct(public ?int $ceiling = null) {}
public function starting(StartingStep $event): void
{
$encoded = json_encode($event->messages);
$estimate = $this->prefixTokens + (int) ceil(strlen($encoded) / $this->charsPerToken);
$spent = $this->spent[$event->invocationId] ?? 0;
if ($this->ceiling !== null && $spent + $estimate > $this->ceiling) {
throw TokenBudgetExceeded::beforeStep($event->stepNumber + 1, $spent, $estimate, $this->ceiling);
}
}
public function completed(StepCompleted $event): void
{
$usage = $event->response->usage;
$this->spent[$event->invocationId] = ($this->spent[$event->invocationId] ?? 0)
+ $usage->promptTokens + $usage->cacheWriteInputTokens + $usage->cacheReadInputTokens;
}
}
Set the ceiling on the singleton before you call the agent, from a config value or the request. The guard and its exception are one commit on the companion repo.
The exception propagates out of the loop, through the middleware pipeline, and out of ->prompt(). Catch it where you called the agent.
A fresh run against a 20,000 ceiling:
Refused step 3: 10,348 tokens sent so far + ~11,612 estimated for this step
would exceed the 20,000-token ceiling.
Steps 1 and 2 ran and were charged. Step 3, the one carrying the scraped pages, was never sent.
This run’s scrapes came back smaller than the one in the table, so the refused step was worth about 12,000 tokens, not 62,000. Scraped pages vary. The guard doesn’t care.
MaxSteps sat at its default of 5 the whole time.
What happens in that catch is the design decision the SDK leaves to you. Three answers work. Fail the job and report it, which is right for anything billed per run. Retry with the scrape tool told to return less, the fix from the tools post. Or route the retry to a cheaper model, which gets its own post. What doesn’t work is raising the ceiling until it stops firing. That’s MaxSteps again under another name.
The estimate is a heuristic and it says so. On this agent’s payload, scraped markdown serialised as JSON, the provider billed one token per 3.3 characters. Dividing by 3 over-estimates by about 10%, the right side to err on for a guard.
Prose runs nearer 4. Measure your own on step 2 of any real run.
Caching the history costs more on the step that matters
Every cost guide says the same thing: turn on prompt caching. Repeated prefix, 90% off the re-reads. In an agent loop the whole conversation is a repeated prefix, so cache it.
I did.
Anthropic only caches when asked. A top-level cache_control in the request tells it to put a breakpoint on the last block of every request, so the breakpoint advances one step at a time and each step’s write covers everything before it. The SDK merges the agent’s provider options into the request body, so it’s one method:
use Laravel\Ai\Contracts\HasProviderOptions;
use Laravel\Ai\Enums\Lab;
class ResearchAgent implements Agent, HasProviderOptions, HasTools
{
public function providerOptions(Lab|string $provider): array
{
return match ($provider) {
Lab::Anthropic => ['cache_control' => ['type' => 'ephemeral']],
default => [],
};
}
}
The mechanism worked perfectly.
Each step read back the previous step’s write in full.
| Step | Uncached | Cache write | Cache read |
|---|---|---|---|
| 1 | 3 | 1,383 | 0 |
| 2 | 1 | 9,410 | 1,383 |
| 3 | 1 | 54,875 | 10,793 |
The run cost 6% more than with no caching at all.
Here’s why. A cache write costs 1.25× the input rate. A read costs 0.1×. Content that comes back once costs 1.35× cached against 2× uncached, so a write wins the moment it’s read back once. It loses only when it’s never read, and then it loses the full 25%.
Now look at what each step’s new content did:
| Content first sent at | Tokens | Read back | Cached | Uncached | |
|---|---|---|---|---|---|
| step 1, the prefix | 1,383 | 2× | $0.006 | $0.012 | saves |
| step 2, search + database | 9,410 | 1× | $0.038 | $0.057 | saves |
| step 3, two scraped pages | 54,875 | 0× | $0.206 | $0.165 | loses $0.04 |
The biggest payload in a tool loop is the tool result that arrives right before the final answer. The final answer is the last step. So the largest write is never read, and it’s 70% of the tokens.
The advancing breakpoint caches exactly the content that is never read again.
OpenAI does the same thing automatically. GPT-5.6’s implicit mode writes at the end of every step, 1.25× as well. In two runs it came out 4% and 16% over uncached, because reads within a 30-second loop mostly didn’t land.
You can turn it off with one provider option: prompt_cache_options in explicit mode with no breakpoints. The raw usage then shows zero writes and zero reads.
What can’t lose is caching the fixed prefix only.
The SDK has attributes for it:
use Laravel\Ai\Attributes\CacheInstructions;
use Laravel\Ai\Attributes\CacheToolDefinitions;
#[CacheInstructions]
#[CacheToolDefinitions]
class ResearchAgent implements Agent, HasTools
Written once, read on every later step, the history left alone. On this three-step run it saved 1.85%. On a ten-step agent with a fat tool list it’s real money.
And one trap. The attributes cache the instructions block and the tool block, and for this agent those came to 1,042 tokens. That’s short of the 1,345 from earlier because the request carries about 300 fixed tokens that sit outside both blocks: step 1 of that run billed 344 uncached tokens for a message of about 40. Sonnet 4.6’s minimum cacheable prefix is 1,024. Eighteen tokens shorter and the attributes do nothing, silently. Haiku 4.5’s minimum is 4,096, so on Haiku this agent doesn’t cache at all.
Check cacheWriteInputTokens on step 1. If it’s zero, you’re below the floor.
Caching does not fix a 55,000-token scrape. It charges 25% extra for it.
The lever that always works is the one from the tools post: make the tool result smaller before it enters the context. The two structural levers, a cheaper model for the runs that don’t need Sonnet and trimming the conversation history, each get their own post.
Run the math before the launch, not after the invoice
There’s no token counter in the SDK and no budget. So the estimate is a short Artisan command, one commit on the companion repo: prefix, new tokens per step, steps, rates. It prints the series, the per-request number you would have written down, and the number the loop sends.
php artisan ai:estimate --prefix=1345 --per-step=8000 --steps=5 \
--input-rate=3.00 --output-rate=15.00 --runs=1000
Put your own prefix in from step 1 of a real run. For the per-step value use the biggest page your scraper returns, not the average.
Then read the “Last step alone” line. At 8,000 tokens a step it’s 38% of the run. On the measured run it was 87%.
That’s the step your budget guard has to be sized for.
Rates in this post are the published Anthropic and OpenAI cards on 2026-09-12. Sonnet 4.6 at $3 in and $15 out. GPT-5.6 Terra at $2 in and $12 out. They’ll move. The shape won’t.
What you can price now
You started with one request times five steps.
Now you can see what every step of a run sent. You can refuse the step that would blow the ceiling before it leaves the app. You can print the quadrupling before you raise MaxSteps. And you know the caching everyone recommends costs more on the step that matters.
The whole thing is on GitHub as commits on laravel-ai-research-agent, with the seven recorded runs behind every number above. Clone it, add keys, run it against your own agent.
Price the loop, not the request. Cap tokens, not steps. Cache the prefix, not the payload.