fomvasss/laravel-ai-tasks

AI task orchestrator for Laravel: routing, queue, audit, budget, webhooks

Maintainers

Package info

github.com/fomvasss/laravel-ai-tasks

pkg:composer/fomvasss/laravel-ai-tasks

Statistics

Installs: 18

Dependents: 0

Suggesters: 0

Stars: 10

Open Issues: 0

3.3.0 2026-06-24 20:46 UTC

This package is auto-updated.

Last update: 2026-06-24 20:48:32 UTC


README

License Latest Stable Version Total Downloads

Support

If this package is useful to you, consider supporting its development:

Monobank Ko-Fi USDT TRC20

USDT TRC20: THLgp6DxiAtbNHvgnKV56vk1L38UuUagKf

AI task orchestrator for Laravel. Handles routing, queuing, audit logging, budget tracking, and webhook processing on top of laravel/ai as the transport layer.

Українська документація

Dashboard

Built-in web UI at /ai-tasks — runs list with stats, filters, and per-run detail (request, response, tokens, cost).

Dashboard

Configurable via config/ai-tasks.php:

'dashboard' => [
    'enabled'       => env('AI_DASHBOARD_ENABLED', true),
    'path'          => env('AI_DASHBOARD_PATH', 'ai-tasks'),
    'middleware'    => ['web'],
    'poll_interval' => env('AI_DASHBOARD_POLL', 3),       // seconds; 0 = off
    'theme'         => env('AI_DASHBOARD_THEME', 'system'), // light|dark|system
    'per_page'      => env('AI_DASHBOARD_PER_PAGE', 50),
],

Requirements

Installation

composer require fomvasss/laravel-ai-tasks

Publish configs and run migrations:

# laravel/ai provider config (credentials go here)
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider" --tag=ai-config

# this package config (routing, budgets, queues)
php artisan vendor:publish --tag=ai-tasks-config

php artisan vendor:publish --tag=ai-migrations
php artisan migrate

Add API keys to .env — credentials are read by laravel/ai:

AI_DEFAULT=openai

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=...
DEEPSEEK_API_KEY=sk-...
GROQ_API_KEY=gsk_...

Two config files

File Purpose
config/ai.php laravel/ai — API keys, provider URLs
config/ai-tasks.php this package — models, prices, routing, budgets

Horizon / Queue

Two queues are used by default:

AI_QUEUE=ai
AI_QUEUE_POST=ai-post

Example Horizon config:

'supervisor-ai' => [
    'connection'   => 'redis',
    'queue'        => ['ai'],
    'balance'      => 'auto',
    'minProcesses' => 2,
    'maxProcesses' => 20,
    'tries'        => 3,
    'timeout'      => 300,
],
'supervisor-ai-post' => [
    'connection'   => 'redis',
    'queue'        => ['ai-post'],
    'balance'      => 'simple',
    'minProcesses' => 1,
    'maxProcesses' => 8,
    'tries'        => 3,
    'timeout'      => 60,
],

Creating a Task

php artisan ai:make-task SummarizeTask
php artisan ai:make-task Orders/AnalyzeTask --queued
<?php

declare(strict_types=1);

namespace App\Ai\Tasks;

use Laravel\Ai\Messages\UserMessage;
use Fomvasss\AiTasks\DTO\AiPayload;
use Fomvasss\AiTasks\DTO\AiResponse;
use Fomvasss\AiTasks\Tasks\AiTask;

class SummarizeTask extends AiTask
{
    public function __construct(
        private readonly string $text,
    ) {}

    public function modality(): string
    {
        return 'text';
    }

    public function toPayload(): AiPayload
    {
        return new AiPayload(
            modality: $this->modality(),
            messages: [new UserMessage("Summarize: {$this->text}")],
            systemPrompt: 'You are a concise summarizer. Reply in 3 sentences max.',
            options: ['temperature' => 0.3],
        );
    }

    public function postprocess(AiResponse $response): AiResponse|array
    {
        // save to DB, dispatch further jobs, etc.
        return $response;
    }
}

Running Tasks

use Fomvasss\AiTasks\Facades\AI;

// Sync
$response = AI::send(new SummarizeTask($text));
echo $response->content;

// Async (queue)
$runId = AI::queue(new SummarizeTask($text));

// Streaming
$response = AI::stream(new SummarizeTask($text), function (string $chunk) {
    echo $chunk;
});
// $response->content — full accumulated text
// $response->usage  — tokens + cost (same as AI::send)

// Override driver at runtime
$response = AI::send(new SummarizeTask($text), drivers: 'anthropic');

Streaming

AI::stream() delivers response text chunk by chunk via a callback, useful for real-time UI (SSE, WebSockets).

$response = AI::stream(
    new SummarizeTask($text),
    function (string $chunk) {
        echo $chunk;          // or: event('stream', $chunk)
    },
    drivers: ['openai'],      // optional driver override
);

// After the stream ends:
$response->content;           // full accumulated text
$response->usage;             // tokens + cost

Provider support

All providers supported by laravel/ai work with streaming automatically — OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama, and any OpenAI-compatible endpoint.

Long responses

AI::send() has a default 60-second timeout per request. For tasks that generate large outputs (long articles, stories, detailed reports), use AI::stream() — it has no timeout by default:

$response = AI::stream(new WriteStoryTask(), function (string $chunk) {
    // process chunks, or ignore them
}, drivers: ['deepseek']);

$response->content; // full accumulated text

Tools & MCP

Override tools() on any task to pass tools to the underlying AnonymousAgent. Tools are forwarded automatically on send(), stream(), and queue().

Three approaches are supported:

  • Local tools — PHP classes implementing Laravel\Ai\Contracts\Tool
  • Native MCP (recommended) — install laravel/mcp and use Client::web() or Client::local() for HTTP and stdio servers; laravel/ai wraps the tool primitives automatically, no supergateway proxy needed
  • HttpMcpClient — zero-dependency fallback for Streamable HTTP servers when laravel/mcp is not installed

Full documentation: docs/mcp.md

Job Timeout

Override jobTimeout() on any task to control how long the queue job is allowed to run before Horizon kills it:

class HeavyAnalysisTask extends AiTask
{
    // default is 300 seconds; raise for long multi-step tool chains
    public function jobTimeout(): int { return 600; }
}

The value is passed to ProcessAiPayload at dispatch time. Make sure the Horizon supervisor timeout is at least as large as your highest jobTimeout().

Driver Routing

Tasks are routed to drivers via config/ai-tasks.php:

'routing' => [
    'summarize'       => ['openai', 'anthropic'], // fallback chain
    'orders_analyze'  => ['gemini'],
],

Or on the task instance:

AI::send((new SummarizeTask($text))->viaDrivers('gemini'));

Multi-tenant Budget Tracking

// config/ai-tasks.php
'budgets' => [
    'tenant-abc' => ['monthly_usd' => 50.0],
    'default'    => ['monthly_usd' => 100.0],
],

The TenantResolver picks up tenant ID from X-Tenant-Id header, authenticated user, or config default. Override it by binding your own resolver in a service provider:

$this->app->scoped(\Fomvasss\AiTasks\Support\TenantResolver::class, fn() => new MyTenantResolver());

Cost Tracking

Set pricing per driver in config/ai-tasks.php (per 1M tokens):

'anthropic' => [
    'model' => 'claude-sonnet-4-6',
    'price' => [
        'in'          => 3.00,
        'out'         => 15.00,
        'cache_write' => 3.75,  // prompt caching write
        'cache_read'  => 0.30,  // prompt caching read
    ],
],

Cost is calculated after each response and stored in ai_runs.cost. If price is not set, cost is null but token counts are always saved.

Query spend per tenant:

AiRun::where('tenant_id', $tenantId)
    ->where('status', 'ok')
    ->sum('cost'); // fast SQL, indexed column

Prompt Caching (Anthropic)

return new AiPayload(
    modality: 'text',
    messages: [new UserMessage($prompt)],
    systemPrompt: $longSystemPrompt,
    options: ['cache' => true], // caches systemPrompt on Anthropic
);

Queued Tasks

Implement ShouldQueueAi and define serializeForQueue():

use Fomvasss\AiTasks\Contracts\ShouldQueueAi;

class AnalyzeTask extends AiTask implements ShouldQueueAi
{
    public function __construct(private readonly int $productId) {}

    public function serializeForQueue(): array
    {
        return [$this->productId];
    }

    public function viaQueues(): array
    {
        return ['request' => 'ai', 'post' => 'ai-post'];
    }
}

Note: serializeForQueue() must return only scalar values (strings, ints, arrays of scalars). The array is JSON-encoded and stored in Redis; on the worker side it is passed back into the constructor via new static(...$args). Do not pass Eloquent models — they will be JSON-serialized into a plain array and the constructor will receive an array instead of a model instance. Pass IDs instead and load the model inside toPayload().

serializeForQueue() also drives idempotency: if it returns [] (the default), no deduplication is applied for AI::queue(). Any task that accepts constructor parameters influencing the prompt must implement serializeForQueue()AI::queue() will throw a LogicException at dispatch time if it detects constructor parameters but serializeForQueue() returns [].

Delayed dispatch

Pass a delay to AI::queue() to defer execution:

AI::queue(new SummarizeTask($text), delay: 300);                 // 5 minutes (seconds)
AI::queue(new SummarizeTask($text), delay: now()->addHours(2));  // Carbon
AI::queue(new SummarizeTask($text), delay: new \DateInterval('PT10M'));

Pre-execution guard — shouldRun()

Override shouldRun() on any task to perform a last-moment check inside the queue job, before the API call is made. If it returns false, the run is marked skipped and no tokens are consumed:

class AnalyzeProductTask extends AiTask
{
    public function __construct(private readonly int $productId) {}

    public function shouldRun(): bool
    {
        // re-check at job execution time — the model state may have changed
        return Product::find($this->productId)?->needs_analysis ?? false;
    }
}

Useful when a queued task may become irrelevant by the time a worker picks it up (e.g. record deleted, status changed, result already computed).

Idempotency

Every run is protected against duplicates via a unique idempotency_key stored in ai_runs. The key is a hash of [tenantId, taskName, modality, serializeForQueue()].

Deduplication is active only when serializeForQueue() returns a non-empty array. If it returns [] (the default), idempotencyKey() returns null and no deduplication is applied — multiple runs with the same task can coexist. This means: for any task with variable inputs, implementing serializeForQueue() is required both for queue reconstruction and for correct idempotency behavior.

Collision behavior (when a non-null key already exists in ai_runs):

  • AI::queue() — returns the existing run_id; no duplicate job is dispatched.
  • AI::send() — always makes a fresh API call; idempotency_key is not stored for sync runs.

Override idempotencyKey() when you need custom deduplication logic:

class ChatTask extends AiTask
{
    public function __construct(
        private readonly string $question,
        private readonly string $messageId, // unique per message from the chat system
        private readonly array  $history = [],
    ) {}

    public function serializeForQueue(): array
    {
        return [$this->question, $this->messageId, $this->history];
    }
    // idempotencyKey() default is sufficient — messageId makes each turn unique
}

For chat/assistant integrations where the same question can be asked multiple times: as long as the conversation history (or a messageId) is part of serializeForQueue(), each turn produces a different key and idempotency works correctly — it only blocks genuine technical duplicates (double-send, queue retry).

Laravel Octane

No configuration needed. The package handles Octane automatically:

  • TenantResolver is bound as scoped — new instance per request/job
  • AiManager driver cache is flushed on every RequestReceived and TaskReceived Octane event

If you provide a custom TenantResolver that holds per-request state, the scoped binding ensures it is reset correctly between requests.

Testing

Use AI::fake() in tests to avoid real API calls. The fake records all calls and provides assertion helpers.

use Fomvasss\AiTasks\Facades\AI;

// Default: all tasks return "fake ai response"
$fake = AI::fake();

// Fixed response for all tasks
$fake = AI::fake('Short summary.');

// Per-task responses (matched by task name)
$fake = AI::fake([
    'summarize' => 'This is a summary.',
    'translate'  => 'Це переклад.',
    '*'          => 'Default fallback.',   // catch-all
]);

Assertions

$fake->assertSent(SummarizeTask::class);

$fake->assertSent(SummarizeTask::class, function (AiTask $task, string $method) {
    return $task->name() === 'summarize' && $method === 'send';
});

$fake->assertNotSent(TranslateTask::class);

$fake->assertQueued(SummarizeTask::class);

$fake->assertSentCount(3);   // total calls (send + stream + queue)

$fake->assertNothingSent();

AI::stream() with fake still calls the $onChunk callback once with the full response, so streaming logic can be tested too.

Events

Event When
AiTaskQueued Task dispatched to queue
AiTaskStarted API call begins
AiTaskCompleted Postprocess done, response ready
AiTaskFailed All drivers failed
AiRunFinished Low-level: single driver call succeeded
AiRunFailed Low-level: single driver call failed
Event::listen(AiTaskCompleted::class, function (AiTaskCompleted $event) {
    // $event->task, $event->response, $event->run
});

Modalities

Supports five modalities: text · image · embed · audio · transcription.

Set modality() and toPayload() accordingly. For image generation, embeddings, TTS, and transcription see:

docs/modalities.md

Artisan Commands

Command Description
ai:make-task Name Generate a task class
ai:models [driver] List available models from provider API
ai:request "prompt" Ad-hoc sync or queued request
ai:runs List recent ai_runs
ai:budget {tenant} Show monthly spend vs limit
ai:retry List failed runs for retry

ai:models

# all configured drivers
php artisan ai:models

# specific driver
php artisan ai:models gemini

# filter by substring
php artisan ai:models openai --filter=gpt-4

# show token limits, release date, capabilities
php artisan ai:models anthropic --detail

Currently configured model is highlighted with . Providers with a URL in ai.providers.*.url (Groq, Mistral, DeepSeek, xAI, Ollama, OpenRouter…) are queried via the OpenAI-compatible /v1/models endpoint automatically.

Supported Providers

Any provider supported by laravel/ai works automatically — just add a section to config/ai.php (credentials) and config/ai-tasks.php (model, price). No code changes needed.

The following providers are pre-configured in config/ai-tasks.php (just add the .env key):

Provider Driver key Pre-configured
OpenAI openai
Anthropic anthropic
Google Gemini gemini
DeepSeek deepseek
Groq groq
Mistral mistral
xAI (Grok) xai
Ollama (local) ollama
VoyageAI voyageai add manually
AWS Bedrock bedrock add manually
OpenRouter openrouter add manually
Perplexity perplexity add manually
ElevenLabs eleven ✅ (audio/tts)
any laravel/ai provider add manually

How credentials work

laravel/ai reads API keys from config/ai.php (published via vendor:publish --provider="Laravel\Ai\AiServiceProvider"). The api_key is not stored in config/ai-tasks.php — that file only contains model names, pricing, and routing config.

To check what .env key each provider expects, see:

vendor/laravel/ai/config/ai.php

Adding a new provider (e.g. Mistral):

# 1. Add to config/ai.php (laravel/ai config)
'mistral' => [
    'key' => env('MISTRAL_API_KEY'),
    'url' => 'https://api.mistral.ai/v1',
],

# 2. Add to config/ai-tasks.php (this package)
'mistral' => [
    'model' => 'mistral-large-latest',
    'price' => ['in' => 2.00, 'out' => 6.00],
],

Changelog

See CHANGELOG.

License

MIT — see LICENSE.