Observability for Laravel AI SDK interactions — run lifecycle, token counts, cost, and provider metadata, recorded automatically with two helpers.
$run = ai_run('summarize-document'); // provider & model inferred from config/ai.php
$response = Ai::text('Summarize: ' . $document->content);
$usage = ai_usage($run, $response);
// AiRun → status: completed, finished_at: now
// AiUsage → prompt_tokens: 180, completion_tokens: 67, total_tokens: 247- PHP 8.5+
- Laravel 11+
laravel/ai^0.8
composer require laraveljutsu/usaige
php artisan migratePackage auto-discovery registers the service provider and the Usaige facade. The ai_run() and ai_usage() helpers are available everywhere immediately.
provider and model are optional — USAIGE reads them from config/ai.php automatically:
$run = ai_run('generate-reply');
$response = Ai::text('Write a reply to: ' . $ticket->body);
$usage = ai_usage($run, $response);USAIGE reads the same config/ai.php the SDK uses:
// config/ai.php
'default' => 'openai',
'providers' => [
'openai' => [
'driver' => 'openai',
'models' => [
'text' => ['default' => 'gpt-4o-mini'],
],
// ...
],
],These three calls are equivalent when the config above is in place:
$run = ai_run('summarize-document');
$run = ai_run('summarize-document', provider: 'openai');
$run = ai_run('summarize-document', provider: 'openai', model: 'gpt-4o-mini');Explicit values always win. If no AI SDK config is present, provider and model are stored as null.
provider accepts Laravel\Ai\Enums\Lab directly — no string conversion needed:
use Laravel\Ai\Enums\Lab;
$run = ai_run('summarize-document', provider: Lab::OpenAI);
$run = ai_run('summarize-document', provider: Lab::Anthropic, model: 'claude-sonnet-4-6');$run = ai_run('summarize-document', provider: 'anthropic', model: 'claude-sonnet-4-6');Resolved from auth()->id() by default. Override it per-call or globally:
// Per-call override (queue jobs, impersonation, …)
$run = ai_run('translate', userId: $targetUser->id);// Global override — call once in AppServiceProvider::boot()
use Laraveljutsu\Usaige\Usaige;
Usaige::resolveUsersUsing(fn () => auth('api')->id());Attach any app-specific identifiers as JSON:
$run = ai_run('classify-ticket', metadata: [
'team_id' => $team->id,
'ticket_id' => $ticket->id,
]);
$run->metadata['team_id']; // → 42$run = ai_run('translate');
try {
$response = Ai::text('Translate to FR: ' . $text);
$usage = ai_usage($run, $response);
} catch (Throwable $e) {
$run->fail($e->getMessage());
throw $e;
}For streaming accumulators or unsupported response shapes:
$usage = ai_usage(
$run,
promptTokens: 1_240,
completionTokens: 380,
costUsd: 0.001620,
);use Laraveljutsu\Usaige\Facades\Usaige;
$run = Usaige::createRun('ocr-scan', model: 'gpt-4o');
$usage = Usaige::recordUsage($run, $response);use Laraveljutsu\Usaige\Models\AiRun;
// All runs for the current user
AiRun::where('user_id', auth()->id())->with('usage')->latest()->get();
// Total tokens spent on a feature
AiRun::where('feature_key', 'summarize-document')
->join('ai_usages', 'ai_runs.id', '=', 'ai_usages.ai_run_id')
->sum('ai_usages.total_tokens');
// Failed runs in the last 24 hours
AiRun::where('status', 'failed')->where('started_at', '>=', now()->subDay())->get();
// Wall-clock duration
$run->durationMs(); // → 891php artisan vendor:publish --tag=usaige-config// config/usaige.php
return [
'path' => 'usaige', // dashboard URL
'middleware' => [], // e.g. ['auth', 'can:admin']
'table_names' => [
'ai_runs' => 'ai_runs',
'ai_usages' => 'ai_usages',
],
];Note: Don't put closures in the published config — Laravel can't cache them. Use
Usaige::resolveUsersUsing()andUsaige::auth()inAppServiceProvider::boot()instead.
USAIGE ships a web dashboard at /usaige listing all runs with status, tokens, cost, and duration.
Via config (middleware-based):
// config/usaige.php
'middleware' => ['auth'], // require login
'middleware' => ['auth', 'can:admin'], // Gate / Policy
'middleware' => ['auth:admin'], // custom guardVia callback (code-based):
Usaige::auth(fn ($request) => $request->user()?->isAdmin() ?? false);Both can be combined — the callback runs after any configured middleware.
Creates an AiRun with status = running and started_at = now().
| Parameter | Type | Default | Description |
|---|---|---|---|
$feature |
string |
— | Stable key identifying the AI feature (e.g. 'summarize-document'). |
$provider |
Lab|string|null |
null |
Provider name or Lab enum. Inferred from config/ai.php when null. |
$model |
?string |
null |
Model identifier. Inferred from config/ai.php when null. |
$userId |
?int |
null |
Overrides the user resolver. |
$metadata |
array |
[] |
Arbitrary key-value pairs stored as JSON. |
$inputHash |
?string |
null |
Pre-computed hash of the prompt (for deduplication). |
Calls $run->complete(), extracts tokens from the response, and returns a new AiUsage.
| Parameter | Type | Default | Description |
|---|---|---|---|
$run |
AiRun |
— | The run returned by ai_run(). |
$response |
mixed |
null |
AI SDK response — tokens extracted automatically. |
$promptTokens |
?int |
null |
Override the extracted prompt token count. |
$completionTokens |
?int |
null |
Override the extracted completion token count. |
$costUsd |
?float |
null |
Cost in USD. Not computed automatically. |
| Method | Description |
|---|---|
complete(): void |
Sets status = completed, finished_at = now(). Called internally by ai_usage(). |
fail(string $message): void |
Sets status = failed, stores the error, finished_at = now(). |
durationMs(): ?int |
Wall-clock duration in milliseconds. null if not finished. |
usage(): HasOne |
Relationship to the associated AiUsage. |
UsageExtractor detects the response shape automatically:
| SDK / Shape | Prompt field | Completion field |
|---|---|---|
| Laravel AI SDK | usage->inputTokens |
usage->outputTokens |
| OpenAI PHP SDK | usage->prompt_tokens |
usage->completion_tokens |
| Plain array | ['usage']['prompt_tokens'] |
['usage']['completion_tokens'] |
| Unknown / null | 0 — override manually |
0 — override manually |
| Column | Type | Notes |
|---|---|---|
id |
bigint | Primary key |
user_id |
bigint, nullable | No FK — works with any auth setup |
feature_key |
string | Developer-defined feature identifier |
status |
string | running · completed · failed |
provider |
string, nullable | e.g. openai, anthropic |
model |
string, nullable | e.g. gpt-4o-mini |
input_hash |
string, nullable | For deduplication |
started_at |
timestamp, nullable | Set by ai_run() |
finished_at |
timestamp, nullable | Set by complete() or fail() |
error_message |
text, nullable | Set by fail() |
metadata |
json, nullable | App-specific context |
created_at / updated_at |
timestamp |
| Column | Type | Notes |
|---|---|---|
id |
bigint | Primary key |
ai_run_id |
bigint | FK → ai_runs, cascade delete |
prompt_tokens |
uint | Defaults to 0 |
completion_tokens |
uint | Defaults to 0 |
total_tokens |
uint, nullable | Sum of prompt + completion |
cost_usd |
decimal(10, 6) | 6 decimal places for sub-cent precision |
created_at / updated_at |
timestamp |
./vendor/bin/pestIn your application tests, use RefreshDatabase and call the helpers as normal:
it('tracks the summarize run', function () {
$run = ai_run('summarize', model: 'gpt-4o-mini');
$usage = ai_usage($run, promptTokens: 100, completionTokens: 40);
expect($run->fresh()->status->value)->toBe('completed')
->and($usage->total_tokens)->toBe(140);
});MIT — Ludovic Guenet
