Search by

pdro-lucas / filament-ai-writer

Pedro Lucas

Filament AI writing plugin with per-panel permissions and shared provider generation

Package info

github.com/pdro-lucas/filament-ai-writer

pkg:composer/pdro-lucas/filament-ai-writer

Statistics

Installs: 12 967

Dependents: 0

Suggesters: 0

Stars: 9

Open Issues: 0


README

Filament AI Writer: AI text in any form field, with credits, limits and usage you control

Latest version Total downloads PHP version Filament 4 and 5 License Tests

A Filament plugin for AI-assisted writing, with per-panel registration, host-defined permissions and a shared generation service. Its form action integrates OpenAI, Anthropic, Gemini and OpenRouter with scalar and array fields. Supported endpoints do not imply support for every model or optional API feature.

Documentation

Start with installation, panel registration and the writing examples. Coming from 0.7.1? Read the upgrade guide: the action alone needs nothing new. See the integration guide for administration, scoped directories, custom action placement, credits, translations and presentation. Reporting and maintenance are covered in statistics and retention.

Requirements

  • PHP 8.2+
  • Laravel 11.28+ or 12.x
  • Filament 4.15.3+ (Livewire 3) or 5.x (Livewire 4)

Installation

composer require pdro-lucas/filament-ai-writer

The Laravel service provider is auto-discovered. AiWriterAction works on its own: without registering the plugin and without migrations, it calls the configured provider and fills the field. Register the plugin to add permissions and opt-in modules. Install the package tables only when you enable usage tracking:

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

The package does not automatically run migrations. To customize the usage table name, publish the configuration and set tables.usage before running the migration; keep that name stable afterward. No changes to the host's user table are required.

Configuration

Add your API key to .env depending on which provider you want to use:

# Anthropic (requires an explicit AI_WRITER_MODEL)
ANTHROPIC_API_KEY=sk-ant-...

# OpenAI (default)
OPENAI_API_KEY=sk-...

# Google Gemini
GEMINI_API_KEY=...

# OpenRouter
OPENROUTER_API_KEY=sk-or-...

To switch providers or customize the model, add the following variables:

AI_WRITER_PROVIDER=openai   # anthropic | openai | gemini | openrouter
AI_WRITER_MODEL=gpt-4o
AI_WRITER_MAX_TOKENS=2048
AI_WRITER_CONNECT_TIMEOUT=10
AI_WRITER_TIMEOUT=60

If you need to customize the full configuration, publish the config file:

php artisan vendor:publish --tag=filament-ai-writer

This creates config/filament-ai-writer.php in your project where you can set per-provider models, API keys, and token limits. Publishing configuration is optional when using the default table name and no host tariffs; publishing and running the migrations is required only for usage tracking.

Panel plugin

Register a separate instance in each panel's PanelProvider:

use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Gate;
use PdroLucas\FilamentAiWriter\AiWriterPlugin;

$panel->plugin(
    AiWriterPlugin::make()
        ->authorizeUseAi(fn (Authenticatable $user, $panel): bool =>
            Gate::forUser($user)->allows('use-ai-writer'))
        ->authorizeViewStatistics(fn (Authenticatable $user, $panel): bool =>
            Gate::forUser($user)->allows('view-ai-writer-statistics'))
        ->authorizeManage(fn (Authenticatable $user, $panel): bool =>
            Gate::forUser($user)->allows('manage-ai-writer')),
);

Callbacks receive the authenticated actor from that panel's guard and the panel itself. No User class, roles, columns or key type are required. Guests are denied. Authenticated actors may use AI by default; statistics and administration are denied unless their independent callbacks allow them. AiWriterPlugin::get() resolves the plugin on the current panel.

When the plugin is registered, the action enforces the panel's use permission and any host action-level authorization on the server before running generation hooks or contacting the provider. Interactive and silent actions use the same checks. Permissions are checked again on later requests, including submission of a modal opened before permission was revoked. A host ->authorize(true) cannot bypass plugin authorization; host action authorization can further restrict access.

These callbacks can use permissions managed by Filament Shield / Spatie Laravel Permission, or host rules based on roles, user types and plans. See the Shield and business-rule examples for custom permissions, role assignment, Gate callbacks, guards and Super Admin behavior. Neither permission package is a dependency of AI Writer.

Optional modules

A registered plugin with no modules behaves like the standalone action: no pages, routes or render hooks, and no package table is read. Enable modules per panel:

AiWriterPlugin::make()
    ->usageTracking()  // or ->usageTracking(fn (Panel $panel): bool => ...)
    ->controls();      // requires usageTracking()

usageTracking() records each generation in the metadata-only usage ledger, deduplicates operations, gives each one an execution deadline that ai-writer:recover-operations can close, and registers the AI Writer usage statistics page (still gated by authorizeViewStatistics()). It does not read or reserve credits, blocks or abuse settings.

controls() adds admission under one scope lock: AI availability, actor blocks, credits and abuse protection, plus the AI Writer controls page and the credits indicator (with its popover). The My AI credits page is opt-in with creditsPage(). It requires usageTracking(); enabling it alone throws a configuration error when the panel registers. Both modules use the same four package migrations (000001–000004): they build on each other and are published together.

Each closure receives the panel and is evaluated in two stages: once when the panel registers, which decides which pages, routes and render hooks exist; and again on every request, so turning a module off also denies already mounted pages, hides the credits indicator and changes how new generations run. Turning controls() off never deletes stored settings, balances or audit entries; turning it back on resumes them. Operations admitted while controls were on keep their own accounting: delivery consumes, and failure or recovery releases, the reservation they made, even if controls are off by then. Generations admitted while controls are off are not charged and do not count toward abuse limits later. With the modules off, ai-writer:prune-usage and ai-writer:recover-operations succeed without changes when the tables are not installed.

Controls, credits and integration

The plugin ships three native panel pages (My AI credits is off until you call creditsPage()):

Surface Required permission Purpose
AI Writer controls (ai-writer-controls) authorizeManage() Settings for availability, quotas and abuse protections; Users with a reusable Manage AI modal; paginated Activity audit
My AI credits (my-ai-credits, opt-in) authorizeUseAi() The authenticated actor's availability, balance and protection status
AI Writer usage (ai-writer-usage) authorizeViewStatistics() Read-only metadata statistics for the current panel

AI starts enabled, with quotas and all abuse protections off. No business limits are supplied. Management changes write an audit entry atomically. A reason of at most 500 characters is required for actions against one person (block/unblock, individual limit, extra credits, period reset) and for renewing everyone; it is optional for global settings, including pausing AI, which is a single click without a dialog. Blocking a person and renewing everyone ask for confirmation in a dialog. Use requireReason(true) to require a reason everywhere or requireReason(false) nowhere (a Closure may take $panel and the audited ?string $action, which is null when read through getRequireReason() without an action); the services refuse a missing required reason on the server, whatever the interface does. Disabling AI or blocking an actor prevents new admissions; already admitted generations retain their delivery rights and execution deadlines. Quotas off does not override availability, blocks or permissions, and enabling quotas never retroactively charges a free generation.

Configure an explicit, scoped actor directory to manage users before their first generation. The plugin never infers a host User model, tenant column, display name or email. Return ActorIdentity with a human label in both search and resolve callbacks. Without a directory the Users tab is hidden and its section cannot be opened; Settings shows managers a link to the setup guide. The resolver remains required when embedding Manage AI in your own Resource with the built-in Users tab off.

Choose what appears on each panel instance:

use PdroLucas\FilamentAiWriter\AiWriterPlugin;

AiWriterPlugin::make()
    ->usersTab(false)           // Keep Settings/Activity; manage users in your Resource.
    ->creditsIndicator(false);  // Hide the credits indicator; enforcement is unchanged.

Pages and navigation

Each page has a class setter that takes true (built-in), a concrete subclass, false (no page and no route) or a Closure returning either; they are resolved when the panel registers the plugin. Disabling a page never changes permissions or the service behind it. A subclass replaces the original page, so the route is registered once.

AiWriterPlugin::make()
    ->statisticsPage(App\Filament\Pages\TeamUsage::class) // or false
    ->controlsPage()                                        // true by default
    ->creditsPage()                                         // off by default
    ->navigationGroup('Administration')                     // default for all three pages
    ->cluster(App\Filament\Clusters\Ai::class)
    ->controlsNavigationLabel('AI settings')                // per page, wins over the default
    ->statisticsNavigationIcon('heroicon-o-chart-bar')
    ->creditsNavigationSort(30)
    ->creditsSlug('my-credits')
    ->statisticsNavigation(false);                          // hide from the menu, keep the route

Per page (statistics…, controls…, credits…): NavigationGroup, NavigationLabel, NavigationIcon, NavigationSort, Slug, Cluster and Navigation (menu visibility), each with a get…() getter (has…() for visibility). Plugin-wide defaults, used when a page has no value: navigationGroup(), navigationIcon(), navigationSort(), cluster() and navigation(). Values may be Closures evaluated with $panel on each use. A value set on the plugin wins over a subclass's static properties. Without any setting the pages share a translatable AI Writer navigation group, except pages in a cluster, which get no built-in group. Pass false to navigationGroup() or a page's …NavigationGroup() for explicitly no group. A cluster given to cluster() or …Cluster() must be registered or discovered on the panel by the host (for example discoverClusters()).

Credits indicator

AiWriterPlugin::make()
    ->creditsIndicator()                                       // on/off
    ->creditsIndicatorRenderHook(PanelsRenderHook::SIDEBAR_NAV_START) // any PanelsRenderHook; default USER_MENU_BEFORE
    ->creditsIndicatorIcon('heroicon-o-bolt')
    ->creditsIndicatorColor('warning')
    ->creditsIndicatorLabel(fn (array $balance): string => $balance['available'] . ' left');

The indicator opens a keyboard-accessible popover with the signed-in user's own balance, renewal date and usage; it takes no target. It appears only in the configured hook, only with controls() on and quotas enabled.

Plugin API conventions: every on/off or text setter accepts a value or a Closure (bool|Closure, string|Closure) and is evaluated when used, not when the plugin is configured. Closures can type-hint or name $panel and, for authorization and scope(), $actor (also available as $user). Each setter has a matching getter (usersTab() / hasUsersTab(), scope() / getScope(), authorizeManage() / canManage()). Setters are grouped by feature prefix: usageTracking(), controls(), statisticsPage(), controlsPage(), creditsPage(), the per-page navigation setters (statistics…, controls…, credits…), creditsIndicator() and creditsIndicator*(), requireReason(), usersTab(), authorize*() and scope(). Module and page switches also decide which pages and routes exist, so they are read when the panel registers the plugin as well as on later requests.

Controls sections

The Controls page has three sections (Settings, Users, Activity), bound to the URL: /ai-writer-controls?section=activity opens Activity, survives a reload and works with the browser's back button. An unknown or unavailable value (for example users without an actor directory) falls back to the first available section, and every section is re-authorized on the server. A subclass passed to controlsPage() can change the list and order by overriding sections(). The Users list reads credits from stored balances without creating any: a user whose credit period has elapsed shows as unused until their next generation or detail view rolls the period, and it flags failure-protection pauses but not the short-lived frequency and simultaneous limits (the detail panel shows all of them). A reason of at most 500 characters is required for actions against one person and when renewing everyone, and optional elsewhere; requireReason() changes that for every action or just some.

Messages, translations and formats

People who generate text see a short message they can act on. Quota, block and frequency denials stay specific, with the renewal date when there is one. Technical failures show a generic message with a reference code, and the log keeps the detail under the same reference; only authorizeManage() users see it in the notification. All text lives in the package's en and pt_BR translations (php artisan vendor:publish --tag=filament-ai-writer-translations; your published keys win). Dates follow your Filament format and timezone, even per user, and costs use the configurable currency of the config file. Details in the integration guide.

Native look and metric widgets

Every screen uses only Filament components and tokens, so your theme (colors, radius, font, dark mode) applies; the package ships no CSS. UsageOverviewStats and UsageCountChart are ordinary widgets: register them on your own dashboard with $panel->widgets([...]) and they respect authorizeViewStatistics(). Icons can be replaced with FilamentIcon aliases. See Customize presentation for the aliases and the few public CSS hooks.

These options do not revoke permissions. Authorization callbacks enforce access on the server, including later Livewire requests. AI availability, quotas, abuse protections, navigation visibility and retention are separate controls.

The integration guide provides:

Upgrading an existing installation

Writer actions keep working without the plugin and without migrations. Register the plugin in each panel that needs permissions, add usageTracking() for usage history and controls() for credits and administration. Service-provider discovery does not register a panel plugin. Remove temporary authorize() or beforeGenerate() callbacks that only duplicated plugin checks; retain application-specific restrictions. Actions outside a current Filament panel are unsupported.

The upgrade guide lists what each layer needs, how authorization closures are resolved (by name or type), that authorize*() without an argument grants everyone, and that databases from the PR #21 build need migrate:fresh. With usageTracking() or controls() enabled, publish new migrations and run them before generating; missing storage or a failing scope resolver refuses generation rather than bypassing stored policy. For installations that already run migrations 000003 (credits) and 000004 (abuse), follow the cutover details: pause admissions, drain old operations and stop old writers before migrating.

Usage

Illustrative AI Writer interface showing prompt-to-copy generation, provider selection, per-user credits, usage tracking and tone and length presets

Import the action and attach it to a Filament field using hintAction, or suffixAction on a field that supports affixes. For other placements, see the native form examples.

use PdroLucas\FilamentAiWriter\Actions\AiWriterAction;

The action works in two modes: interactive and silent. Interactive mode opens a modal where the user describes what they want. Silent mode reads other fields from the current form as context and generates immediately, with no modal.

Interactive mode

The user clicks the sparkles button, a modal opens with a free-text area, and the AI generates content based on the user input and the configured prompt. The result is injected into the target field.

use Filament\Forms\Components\MarkdownEditor;

MarkdownEditor::make('content')
    ->hintAction(
        AiWriterAction::make()
            ->targetField('content')
            ->prompt('
                You are a content writing expert.
                Write rich Markdown with headers, lists, and well-structured paragraphs.
                Return ONLY the Markdown content, no explanations.
            ')
    ),
use Filament\Forms\Components\Textarea;

Textarea::make('excerpt')
    ->hintAction(
        AiWriterAction::make('ai_excerpt')
            ->targetField('excerpt')
            ->prompt('
                Write a concise and engaging summary in 2-3 sentences.
                Tone: professional but accessible.
                Return ONLY the summary.
            ')
    ),

Silent mode

The user clicks the button and the AI generates immediately, reading the specified context fields from the current form. No modal is shown. A success notification is displayed when generation completes.

If any of the context fields are empty when the button is clicked, the action shows a warning notification listing the missing fields and does not generate.

use Filament\Forms\Components\TextInput;

TextInput::make('slug')
    ->hintAction(
        AiWriterAction::make('ai_slug')
            ->targetField('slug')
            ->contextFields(['title'])
            ->prompt('
                Generate a URL-friendly slug based on the provided title.
                Return ONLY the slug, lowercase, hyphenated, no special characters.
            ')
            ->silent()
    ),

Silent mode with a select field

Use ->valueMap() when the target field is a select or radio. Pass the same id => name map used in ->options(). The AI receives the available options as context and returns the key of the chosen option.

use Filament\Forms\Components\Select;

$categories = Category::all()->pluck('name', 'id');

Select::make('category_id')
    ->label('Category')
    ->options($categories)
    ->hintAction(
        AiWriterAction::make('ai_category')
            ->targetField('category_id')
            ->contextFields(['title', 'body'])
            ->valueMap($categories->toArray())
            ->prompt('
                Based on the title and body, choose the most appropriate category.
                Return ONLY the numeric ID of the chosen category, nothing else.
            ')
            ->silent()
    ),

Silent mode with tags or multi-select

Use ->allowedValues() when the target field is a tags input or multi-select. The AI receives the list of allowed values and must return a JSON array containing only values from that list. Values not present in the list are filtered out before injection.

use Filament\Forms\Components\TagsInput;

TagsInput::make('tags')
    ->hintAction(
        AiWriterAction::make('ai_tags')
            ->targetField('tags')
            ->contextFields(['title', 'body'])
            ->allowedValues(['laravel', 'filament', 'php', 'api', 'frontend', 'backend'])
            ->normalizeArrayCase()
            ->prompt('
                Suggest relevant tags based on the title and body.
                Return ONLY a raw JSON array of strings from the allowed list, no markdown, no explanation.
                Example: ["laravel","filament","php"]
            ')
            ->silent()
    ),
// Dynamically from the database
$tags = Tag::all()->pluck('name')->toArray();

TagsInput::make('tags')
    ->hintAction(
        AiWriterAction::make('ai_tags')
            ->targetField('tags')
            ->contextFields(['title', 'body'])
            ->allowedValues($tags)
            ->normalizeArrayCase()
            ->prompt('
                Suggest relevant tags based on the title and body.
                Return ONLY a raw JSON array of strings from the allowed list, no markdown, no explanation.
                Example: ["laravel","filament","php"]
            ')
            ->silent()
    ),

When no ->allowedValues() is provided but the target field still expects an array, use ->expectArray():

TagsInput::make('tags')
    ->hintAction(
        AiWriterAction::make('ai_tags')
            ->targetField('tags')
            ->contextFields(['title', 'body'])
            ->expectArray()
            ->normalizeArrayCase()
            ->prompt('
                Suggest up to five relevant tags based on the title and body.
                Return ONLY a raw JSON array of strings, no markdown, no explanation.
                Example: ["laravel","filament","php"]
            ')
            ->silent()
    ),

API reference

AiWriterAction::make(?string $name = null): static

Creates a new action instance. Defaults to 'ai_writer'. If you attach more than one action to the same form, give each a unique name.

->targetField(string $field): static

The name of the form field where the generated text will be injected.

->prompt(string $prompt): static

The system prompt sent to the AI provider. Use this to define tone, format, length constraints, and domain-specific instructions. The more specific the prompt, the better the output.

->contextFields(array $fields): static

An array of field names to read from the current form and pass to the AI as context. Used in silent mode.

->silent(bool $condition = true): static

Enables silent mode. The action generates immediately on click with no modal, using contextFields as input.

->valueMap(array $map): static

An id => name map for select or radio fields. The AI receives the options as context and returns a single key. Use this instead of ->allowedValues() when the field expects a scalar value.

->allowedValues(array $values): static

A flat array of allowed values for tags inputs or multi-selects. The AI must return a JSON array containing only values from this list. Values not present in the list are filtered out before injection.

->expectArray(bool $condition = true): static

Tells the action to parse the AI response as an array even when no ->allowedValues() is defined. Useful for free-form tags where any value is acceptable.

->normalizeArrayCase(bool $condition = true): static

Converts all values in the generated array to lowercase before injection. Useful for tags to ensure consistent casing regardless of what the AI returns.

->beforeGenerate(callable $callback): static

Register a callback that runs before AI generation on this action instance. Return false to cancel generation. Useful for credit checks, permissions, or rate limiting.

use Filament\Forms\Components\TextInput;

TextInput::make('meta_description')
    ->hintAction(
        AiWriterAction::make()
            ->targetField('meta_description')
            ->beforeGenerate(function (): bool {
                if (auth()->user()->credits <= 0) {
                    return false;
                }
                return true;
            })
            ->prompt('Write a meta description.')
    ),

AiWriterAction::globalBeforeGenerate(callable $callback): void

Register a global callback that runs before every AI generation across all action instances. Typically called in AppServiceProvider::boot(). The instance-level ->beforeGenerate() runs after this global hook.

Use the plugin permission callbacks for authorization; no manual authorization hook is required. Hooks can still veto generation for application-specific business rules before admission.

AiTextGenerated event

Dispatched after every successful AI generation. The payload contains:

Key Description
user The authenticated user (or null)
field The target field name
provider The AI provider name (e.g. openai)
model The model used for generation
result The raw text returned by the provider

Listen to this event to debit credits, log usage, or send notifications:

// app/Providers/EventServiceProvider.php
use PdroLucas\FilamentAiWriter\Events\AiTextGenerated;

protected $listen = [
    AiTextGenerated::class => [DebitUserCredits::class],
];
// app/Listeners/DebitUserCredits.php
class DebitUserCredits
{
    public function handle(AiTextGenerated $event): void
    {
        $user = $event->payload['user'];

        if ($user === null) {
            return;
        }

        $user->decrement('ai_credits');
    }
}

Supported providers

Provider Default model Environment variable
Anthropic none (set AI_WRITER_MODEL) ANTHROPIC_API_KEY
OpenAI gpt-4o OPENAI_API_KEY
Gemini gemini-3.1-flash-lite GEMINI_API_KEY
OpenRouter none (set AI_WRITER_MODEL) OPENROUTER_API_KEY

OpenRouter

OpenRouter gives access to many models through one OpenAI-compatible API. It has no default model, so set one explicitly:

AI_WRITER_PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-...
AI_WRITER_MODEL=openai/gpt-4o-mini

The provider sends your app.url and app.name as the HTTP-Referer and X-Title headers for OpenRouter attribution. All four providers use AI_WRITER_CONNECT_TIMEOUT and AI_WRITER_TIMEOUT.

Provider results and beta interface upgrade

Custom providers now implement AiProvider::generate(string $systemPrompt, string $userInput): GenerateResult, replacing the old string return. Rebind the same AiProvider contract in the host container; custom provider identifiers remain supported.

use PdroLucas\FilamentAiWriter\Data\GenerateResult;

return new GenerateResult(
    provider: 'my-provider',
    requestedModel: 'requested-model',
    model: 'returned-model',
    text: 'Generated text',
);

The immutable result holds visible text in memory, requested and returned models separately, inclusive input/output token totals, optional cache/read/write/reasoning details, response/request IDs, finish reason and HTTP duration in milliseconds. Unknown or inconsistent measurements remain null; cache and reasoning are already included in their totals and are not added again. A returned model that is absent stays unknown.

outcome is text, empty, refused or incomplete. Only usable text is delivered. Empty, refused, malformed and truncated responses preserve the original field; invalid selections and empty parsed arrays also preserve it. No success event is emitted for those outcomes. Internal thought blocks are excluded. Available usage is recorded even when the result cannot be delivered.

User-facing failure messages: people who generate text see either a specific, actionable denial (AI disabled, blocked, credits exhausted with the renewal date when there is one, request frequency, concurrency, failure pause, unusable output) or, for technical failures (storage, migrations, configuration, provider, transaction), a short generic message with a reference code such as AIW-7K2M9QXD. The technical detail is written to the application log (AI Writer generation failed.) with the same reference, the failure code, safe IDs and the class and file:line where the cause was thrown (storage and controls causes also carry their stack; provider causes never log their message). Only users allowed by authorizeManage() also see the detail in the notification; with no plugin registered nobody does.

Providers throw GenerationFailure for configuration, transport, protocol or upstream failures. It carries a local safe failure code, status, safe IDs, duration, outcome-unknown flag and an optional metadata-only result, with no chained raw exception. Custom providers must follow this contract and must not attach prompts, credentials, raw errors or response bodies. AiTextGenerated remains a delivery event with text in memory; it is not a durable usage recorder. Its model is now the returned model, possibly null, rather than a guessed configuration value.

The plugin exposes generation()->run($systemPrompt, $userInput, $deliver, $restore, $field, $operationId = null, $actionName = null) for server-side integrations. It checks current-panel admission and owns execution/persistence. $deliver receives GenerateResult; $restore must restore the captured original scalar/array value if delivery or final storage fails. AiWriterAction supplies both callbacks and field conversion automatically. Keep delivery callbacks limited to reversible local field mutation; external side effects cannot be rolled back by this service. Call generation outside any database transaction on the usage connection: admission must be committed before HTTP, and an existing transaction is rejected before contacting the provider.

Configuration and retry changes: Anthropic and OpenRouter require an explicit nonblank model. OpenAI and Gemini use their documented package defaults when AI_WRITER_MODEL is absent or blank. Invalid nonblank models/provider names, keys or nonpositive timeout/token settings fail before HTTP. Anthropic uses x-api-key and Gemini uses x-goog-api-key, never a key in the URL. Each built-in adapter makes at most one POST and does not follow redirects or retry automatically, including timeouts, quota/authentication failures and 5xx responses. The former OpenRouter retry settings were removed. A new user submission is a new operation and may incur another provider charge; an ambiguous timeout is not proof of zero usage or cost.

OpenRouter's usage.cost is normalized directly from its JSON numeric lexeme to a canonical USD string with twelve fractional digits and half-up rounding. It is not converted through a binary float or replaced by upstream/BYOK cost details. A reported zero is known zero; missing/invalid cost is unavailable. Costs represent OpenRouter account charges, not necessarily the complete external BYOK bill. For other responses, cost remains unavailable unless the host configures a complete matching tariff as described below. No price, balance or tokenizer requests are added.

Endpoint references: OpenAI Chat, Anthropic Messages, Gemini generateContent, OpenRouter usage.

Usage ledger and host tariffs

Each admitted operation inserts a committed pending attempt before contacting a provider. The current panel, its guard's actor class and opaque string identifier, target field, action name and UTC start time are captured on the server. The first response freezes allowlisted metadata as received; successful local delivery finishes as delivered. Rejection, timeout, invalid output or delivery failure finishes as failed when storage is available. Provider HTTP duration and local processing overhead are recorded separately in milliseconds. No historical aggregate or remote balance/price/tokenizer lookup runs during generation.

The ledger stores only identity/context and normalized measurements: no prompts, generated text, internal thoughts, credentials, raw response/error bodies or chained exceptions. A failure may have known tokens or cost even though no text was delivered. Missing measurements remain null; a known zero stays zero. Unknown-outcome timeouts are never treated as free. If storage fails before admission, HTTP is blocked. If storage fails after HTTP, the form is preserved/restored and no delivery success is emitted; an unresolved pending or received row may remain. It does not prove zero usage, and the plugin does not automatically retry or infer remote billing after a process crash.

The unique operation UUID plus attempt number prevents repeated local processing from making a second HTTP call for that operation. Finalization is idempotent and bound to the captured identity/context. A host integration may retain and resupply its server-owned operation UUID; a duplicate is safely rejected, not replayed, because generated text is never stored. Ordinary new action submissions create new operation UUIDs and may incur another charge. This does not deduplicate independent clicks or claim remote idempotency. Only attempt 1 is currently used.

Costs and tariff snapshots use canonical USD TEXT with twelve fractional digits, avoiding SQLite numeric-affinity rounding. To estimate a cost when none was reported, configure explicit string rates per million inclusive input/output tokens, keyed by the exact returned provider/model:

// config/filament-ai-writer.php — example host tariff, not a current price quote
'pricing' => [
    'openai' => [
        'my-returned-model' => [
            'input_usd_per_million' => '2',
            'output_usd_per_million' => '10',
        ],
    ],
],

Both token totals, both valid string rates and the returned model must be known. Literal model identifiers containing / or . are supported. Requested aliases do not substitute for returned models. Provider-reported cost, including zero, takes precedence. Estimation multiplies inclusive totals once, rounds half-up only after summing both components, and records estimated plus the applied rate snapshot. Cache/reasoning details are not billed again. Missing, numeric/float, negative, overly precise or unmatched rates leave cost unavailable. Rates represent a host estimate of inclusive usage, not a promise to reproduce cache tier, reasoning tier or external BYOK billing. Historical estimates do not change when the configuration changes.

No cleanup or deletion runs automatically. Use the plugin statistics service for authorized reporting; reading UsageAttempt directly remains a host server-side responsibility, not an authorized statistics API.

Current-panel statistics

The plugin registers AI Writer usage at /your-panel/ai-writer-usage. Login alone does not grant access: configure authorizeViewStatistics() with the host's permission/policy callback. It is independent of AI use and administration permission, and uses the current panel's guard. Permission is checked for page access, every subsequent Livewire request, and each statistics query/aggregate/filter-option read. A mounted page rejects a changed panel context or revoked permission.

The panel is fixed context, never a selectable cross-panel filter. An authorized viewer can inspect all actors' metadata within the current panel, not just their own calls. No cross-panel expansion/global view is exposed. Actor filters identify both actor class and opaque identifier, without joining a host User table or assuming names/roles/ID types.

History is read-only and paginated (10/25/50 rows; values are also bounded server-side). One screen shows Requests, Tokens consumed, Costs and Average duration (the UsageOverviewStats widget) each with its change against the previous period, two top-five lists (UsageTopUsers and UsageTopFields, with an Explore link that narrows the page) and one requests histogram (UsageCountChart) above a compact request table. Period, user and field are page filters in the header and drive everything on the screen; the table adds a result filter, and each request opens in a slide-over with previous/next navigation. Both widgets can also be placed on your dashboard, where they show the last seven days. The shared selection defaults to the last seven calendar days, with Today/7 days/30 days/custom dates. Success means delivered, failure means failed, and in progress includes pending and received. Period boundaries use the application's timezone and convert to UTC; the end date includes the entire local calendar day. Actor options are panel-scoped and bounded. Provider/model switching and comparisons are not part of the screen.

Rows open a right detail drawer with historical actual model/provider, exact recorded consumption/cost source, safe IDs and sanitized outcomes. Every drawer lookup rechecks current permission, guard and panel scope. Metrics, histogram and table share the same selection. Unknown measurements remain unavailable, known zero stays measured, estimated costs are labeled and incomplete known-cost subtotals show partial coverage; exact canonical values remain available in details. Token and duration summaries use available measurements and identify coverage.

Aggregation reads chunks of 500 records and sums decimal/integer strings in PHP, without SQL monetary conversion, float rounding or per-attempt subtotal overflow. History uses panel/date/actor/provider/model indexes; report runtime still grows with matching retained history. No statistics aggregate runs during AI generation, and no provider price/balance/tokenizer call is added. A custom server-side integration may use AiWriterPlugin::get()->statistics()->query() and summary($filteredQuery); summary freshly checks permission and constrains its outer query to the current panel even if given a broader query. These builders are server-side objects, not client-controlled panel selectors.

Filament references: custom pages, tables.

Opt-in usage retention

Nothing is deleted or scheduled by default. retention.days defaults to null; invoking cleanup without configuration or an explicit --days safely reports disabled. To opt in, set AI_WRITER_RETENTION_DAYS=90 (or retention.days in the published config), inspect the dry run, then execute explicitly:

php artisan ai-writer:prune-usage --days=90 --dry-run
php artisan ai-writer:prune-usage --days=90

Days must be a positive integer. retention.batch_size defaults to 500 and must be 1–1000. No package scheduler is registered. A host may schedule this command deliberately after reviewing its own retention needs and CLI/operator access. The maintenance command spans the configured usage table across panels; it does not grant cross-panel statistics access to a panel viewer.

Only terminal delivered/failed attempts with a completion timestamp are eligible. Both operation start and completion must be strictly before the effective cutoff: the earlier of now minus the configured days and the start of the current app-local calendar month, converted to UTC. This protects unresolved pending/received rows and any row started or completed during the current app-calendar month, even with a very short retention setting. Credit balances, reservations and permanent operation identities are stored separately and are never deleted by this command. Unresolved rows are never presumed free or completed by cleanup.

Deletion selects bounded ID batches, freezes an upper ID for the run, and rechecks eligibility in each atomic DELETE. New/backdated inserts wait for a later explicit run. There is no whole-history transaction. Dry-run counts are observations at that read, not a concurrent snapshot guarantee. Successful output reports actual deleted rows and processed batches; on an error, the command returns failure and reports the already deleted count without raw database details. Earlier completed batches remain deleted. Repeating the command does not double-count prior deletion.

Deleted rows disappear from subsequent history queries, known subtotals and measurement coverage. This metadata cleanup cannot recover stored text (none is kept), prove remote billing, retry inference or alter provider accounts. Keep external backups/audit records according to the host's policy before opting in.

Extending with a custom provider

Implement the AiProvider contract and rebind it in your AppServiceProvider:

use PdroLucas\FilamentAiWriter\Contracts\AiProvider;

public function register(): void
{
    $this->app->bind(AiProvider::class, fn () => new MyCustomProvider());
}

Contributing

Contributions are welcome! Feel free to open an issue or submit a pull request.

License

This project is open-sourced under the MIT license.