Usage and credits
Every model call the package makes goes through a UsageRecorder, which is told who owns it, who was asking, what it was for, which model served it, and how many tokens went each way.
interface UsageRecorder { public function record( Model $owner, ?Model $actor, string $operation, string $model, int $inputTokens, int $outputTokens, ?Model $subject = null, ): void; public function hasCredits(Model $owner): bool; }
Out of the box it is NullUsageRecorder, which records nothing and blocks nothing. A package should not start refusing calls because you have not wired your billing yet.
Wiring larameter
'usage_recorder' => 'larameter',
Or from the service provider:
Laragents::meterWith(LarameterRecorder::class);
That shorthand throws a readable error if larameter is not installed, rather than failing somewhere further in with a missing class.
The adapter lives in this package and not in larameter, so the dependency runs one way: larameter meters forms and emails and invoices and has no business knowing an AI package exists.
The model name is the operation
$meter->meter( meterable: $owner, operation: $model, // 'gpt-4.1-mini' unit: 'token', quantityIn: $inputTokens, quantityOut: $outputTokens, metadata: ['operation' => $operation], // 'chat', 'chat_compression', ... );
Pricing by model name makes a rate table read the way the providers publish theirs, which is the whole reason to do it that way. What the call was for is kept alongside in metadata, so a bill can be read by feature and not only by model.
The credit net
if ($owner && ! $this->usage->hasCredits($owner)) { throw new PlanCreditsExhausted(...); }
This is a net, not the front door. Your application should stop a user before it builds a whole prompt, because at that point you know the threshold that matters (an agent run should demand more headroom than one chat message) and you know what to tell them. This catches the caller who forgot, which in the application this came from was a feature that dispatched model calls from an @mention and never checked anything.
No owner means no check. A session with no tenant is platform cost, so it is neither checked nor billed. Worth knowing if your application lets a superadministrator chat without an active tenant: those conversations are free and invisible in the accounting.
hasCredits() is asked once per iteration, and the shipped adapter memoises it, so a turn that runs six tools does not run six balance queries. It deliberately does not notice spending mid-turn: the alternative is a loop that stops halfway through a tool sequence, which leaves the user with a half-finished answer and no explanation.
Audio and images are metered too
Neither is billed in tokens, so the package converts:
'openai' => [ 'transcription_tokens_per_minute' => 1000, ],
A minute of audio is metered as if it were that many tokens, and your rate table prices it under the model name like anything else. A convention rather than a truth, and the point of it is that speech and text come out of one allowance instead of two.
Speech and images work the same way, metered on what the provider bills (characters read out, images returned), handed to the recorder as a quantity under the model name.
Writing your own
class StripeMeteredRecorder implements UsageRecorder { public function record(Model $owner, ?Model $actor, string $operation, string $model, int $in, int $out, ?Model $subject = null): void { // ... } public function hasCredits(Model $owner): bool { return true; } }
Laragents::meterWith(StripeMeteredRecorder::class);
It is bound scoped, not singleton, and the distinction matters on a queue: an implementation that memoises the balance would otherwise keep that memo alive between jobs, and a worker would still believe an account has credits an hour after it ran out.