Skip to content

Custom Providers

Spectra ships with built-in support for several AI providers. You can extend it to support any additional provider by creating a provider class and one or more handler classes, then registering the provider in the configuration. Once registered, Spectra automatically detects and tracks requests to the new provider using the same pipeline as built-in providers.

How Provider Detection Works

When an outgoing HTTP request is intercepted, Spectra's ProviderRegistry attempts to match it against all configured providers through a three-step process:

  1. Host matching — The request URL's hostname is compared against each provider's getHosts() list. Host patterns support {placeholder} syntax for variable segments, such as {resource}.openai.azure.com.
  2. Endpoint matching — The URL path is checked against each handler's endpoints() list to confirm the request targets a trackable API endpoint.

Once matched, the provider's handler extracts normalized metrics from the response.

TIP

Spectra only observes usage-related endpoints — requests that consume tokens, generate media, or produce other billable output. Non-usage endpoints such as listing models (/v1/models), health checks, or configuration lookups are not tracked, even if they target a recognized provider host.

Architecture Overview

Each provider follows a two-level architecture:

  • Provider class — Extends AbstractProvider and defines the provider's hosts, display name, and handler list. The provider is a coordinator that delegates all metric extraction to its handlers.
  • Handler classes — Each handler implements the Handler interface and owns the extraction logic for a specific endpoint type (chat completions, embeddings, images, etc.).
MyProvider (extends AbstractProvider)
├── ChatHandler     → /v1/chat/completions
├── EmbeddingHandler → /v1/embeddings
└── ImageHandler    → /v1/images/generations

For providers with a single endpoint type, you need only one handler. For providers with multiple API shapes, create a handler for each endpoint type.

Creating a Provider

The following example creates support for a Mistral AI provider with a single chat completions endpoint.

Step 1: Create the Handler

Handlers implement the Handler interface and contain all extraction logic for a specific endpoint type. The handler must define the model type, the endpoints it matches, and methods to extract metrics, the model name, and the response content.

The MatchesEndpoints trait provides the matchesEndpoint() method required by the Handler interface. It performs an exact match against the handler's endpoints() array, which is sufficient for most providers. If your endpoints contain dynamic segments (for example, /v1/models/{model}:generateContent), you can override matchesEndpoint() with a custom regex implementation instead.

php
<?php

namespace App\Spectra\Handlers;

use Spectra\Concerns\MatchesEndpoints;
use Spectra\Contracts\Handler;
use Spectra\Data\Metrics;
use Spectra\Data\TokenMetrics;
use Spectra\Enums\ModelType;

class MistralChatHandler implements Handler
{
    use MatchesEndpoints;

    public function modelType(): ModelType
    {
        return ModelType::Text;
    }

    public function endpoints(): array
    {
        return ['/v1/chat/completions'];
    }

    /**
     * Extract token usage metrics from the provider's response.
     * The returned Metrics DTO determines how cost is calculated.
     */
    public function extractMetrics(
        array $requestData,
        array $responseData
    ): Metrics {
        $usage = $responseData['usage'] ?? [];

        return new Metrics(
            tokens: new TokenMetrics(
                promptTokens: $usage['prompt_tokens'] ?? 0,
                completionTokens: $usage['completion_tokens'] ?? 0,
            ),
        );
    }

    /**
     * Extract the model identifier from the response.
     * This is used for pricing lookup and dashboard display.
     */
    public function extractModel(array $response): ?string
    {
        return $response['model'] ?? null;
    }

    /**
     * Extract the generated text content from the response.
     * This is stored in the database for inspection in the dashboard.
     */
    public function extractResponse(array $response): ?string
    {
        return $response['choices'][0]['message']['content'] ?? null;
    }
}

Step 2: Create the Provider Class

The provider class ties everything together — it defines the provider's name, display name, recognized hosts, and the list of handlers to use:

php
<?php

namespace App\Spectra;

use App\Spectra\Handlers\MistralChatHandler;
use Spectra\Providers\Provider;

class MistralProvider extends Provider
{
    public function getProvider(): string
    {
        return 'mistral';
    }

    public function getDisplayName(): string
    {
        return 'Mistral AI';
    }

    public function getHosts(): array
    {
        return ['api.mistral.ai'];
    }

    public function handlers(): array
    {
        return [
            app(MistralChatHandler::class),
        ];
    }

    public function requestFingerprint(): array
    {
        return ['model'];
    }
}

Step 3: Register the Provider

Add an entry to the providers array in config/spectra.php:

php
'providers' => [
    // Built-in providers...
    'openai'     => Spectra\Providers\OpenAI\OpenAI::class,
    'anthropic'  => Spectra\Providers\Anthropic\Anthropic::class,
    // ...

    // Your custom provider
    'mistral' => App\Spectra\MistralProvider::class,
],

Once registered, Spectra will automatically detect and track any HTTP request to api.mistral.ai that targets the /v1/chat/completions endpoint.

Multi-Endpoint Providers

When a provider has multiple API shapes — for example, separate endpoints for text, images, and audio — create a handler for each endpoint type and return them all from the provider's handlers() method. The AbstractProvider base class routes each request to the correct handler based on endpoint matching.

php
class MyProvider extends AbstractProvider
{
    public function handlers(): array
    {
        return [
            app(ImageHandler::class),     // /v1/images/generations
            app(EmbeddingHandler::class), // /v1/embeddings
            app(ChatHandler::class),      // /v1/chat/completions (catch-all)
        ];
    }
}

NOTE

When each handler targets distinct endpoints, order does not matter — Spectra checks all handlers and selects the one whose endpoint matches. When multiple handlers share the same endpoint (for example, OpenAI's /v1/responses serves both text and image generation), implement the MatchesResponseShape interface on the specialized handlers so Spectra can disambiguate based on the response body.

Optional Handler Interfaces

Handlers can implement additional interfaces for richer metric extraction and behavior:

HasFinishReason

AI providers include a reason for why text generation stopped in each response. OpenAI uses finish_reason (stop, length, tool_calls), while Anthropic uses stop_reason (end_turn, max_tokens, stop_sequence). Implement HasFinishReason to normalize this value so it appears in the dashboard and is available for filtering:

php
use Spectra\Contracts\HasFinishReason;

class MyChatHandler implements Handler, HasFinishReason
{
    public function extractFinishReason(array $response): ?string
    {
        // OpenAI-compatible format
        return $response['choices'][0]['finish_reason'] ?? null;

        // Anthropic format would be:
        // return $response['stop_reason'] ?? null;
    }
}

MatchesResponseShape

Some providers use the same endpoint for different types of output. For example, OpenAI's /v1/responses endpoint can return both text completions and generated images. When multiple handlers match the same endpoint, Spectra calls matchesResponse() on each handler to determine which one should process the response. The handler that recognizes the response structure wins:

php
use Spectra\Contracts\MatchesResponseShape;

// This handler claims /v1/responses only when the output contains images
class MyImageHandler implements Handler, MatchesResponseShape
{
    public function endpoints(): array
    {
        return ['/v1/responses'];
    }

    public function matchesResponse(array $data): bool
    {
        // Check if the response contains image generation output
        foreach ($data['output'] ?? [] as $item) {
            if (($item['type'] ?? null) === 'image_generation_call') {
                return true;
            }
        }

        return false;
    }
}

// This handler claims /v1/responses for regular text completions
class MyTextHandler implements Handler, MatchesResponseShape
{
    public function endpoints(): array
    {
        return ['/v1/chat/completions', '/v1/responses'];
    }

    public function matchesResponse(array $data): bool
    {
        return ($data['object'] ?? null) === 'response'
            || ($data['object'] ?? null) === 'chat.completion';
    }
}

HasMedia

AI providers often return generated images and videos as temporary URLs that expire after a set period. Implement HasMedia to download and persist these files to a Laravel filesystem disk before the URLs expire. The following example shows how OpenAI's image handler stores generated images:

php
use Illuminate\Support\Facades\Http;
use Spectra\Contracts\HasMedia;
use Spectra\Services\MediaPersister;

class MyImageHandler implements Handler, HasMedia
{
    public function storeMedia(
        string $requestId,
        array $responseData
    ): array {
        $persister = app(MediaPersister::class);
        $stored = [];

        foreach ($responseData['data'] ?? [] as $i => $item) {
            if (isset($item['b64_json'])) {
                // Image returned as base64-encoded data
                $content = base64_decode($item['b64_json']);
                $stored[] = $persister->store($requestId, $i, $content, 'image', 'png', 'b64_json');
            } elseif (isset($item['url'])) {
                // Image returned as a temporary URL — download before it expires
                $content = Http::withoutAITracking()->get($item['url'])->body();
                $stored[] = $persister->store($requestId, $i, $content, 'image', 'png', $item['url']);
            }
        }

        return $stored;
    }
}

ExtractsPricingTierFromRequest / ExtractsPricingTierFromResponse

Implemented on the provider class (not the handler) when the provider offers multiple pricing tiers. These interfaces allow Spectra to detect the active tier from request or response metadata. Implement one or both depending on where the tier information is available:

php
use Spectra\Contracts\ExtractsPricingTierFromRequest;
use Spectra\Contracts\ExtractsPricingTierFromResponse;

class MyProvider extends AbstractProvider implements ExtractsPricingTierFromRequest, ExtractsPricingTierFromResponse
{
    public function extractPricingTierFromRequest(array $requestData): ?string
    {
        return $requestData['tier'] ?? null;
    }

    public function extractPricingTierFromResponse(array $responseData): ?string
    {
        return $responseData['system_fingerprint'] ?? null;
    }
}

The default pricing tier is resolved from config automatically (spectra.costs.provider_settings.{provider}.default_tier), so providers do not need to define it themselves. To configure a default tier for your provider, add it to config/spectra.php:

php
'provider_settings' => [
    'myprovider' => [
        'default_tier' => env('SPECTRA_MYPROVIDER_PRICING_TIER', 'standard'),
    ],
],

Metrics DTOs

Handlers return a Metrics container with typed data transfer objects for each metric category. Use the appropriate DTO based on your handler's model type:

php
use Spectra\Data\Metrics;
use Spectra\Data\TokenMetrics;
use Spectra\Data\ImageMetrics;
use Spectra\Data\AudioMetrics;
use Spectra\Data\VideoMetrics;

// Text / LLM response
new Metrics(tokens: new TokenMetrics(
    promptTokens: 150,
    completionTokens: 50,
    cachedTokens: 100,       // Optional: prompt-cached tokens
));

// Image generation
new Metrics(image: new ImageMetrics(count: 4));

// Audio (TTS or STT)
new Metrics(audio: new AudioMetrics(
    durationSeconds: 30.5,
    inputCharacters: 500,    // For TTS
));

// Video generation
new Metrics(video: new VideoMetrics(
    count: 1,
    durationSeconds: 15.0,
));

The handler's modelType() return value determines which pricing formula is applied and how the request is rendered in the dashboard:

ModelTypeUsed ForPrimary Metric
TextChat completions, text generationTokens
EmbeddingVector embeddingsTokens (input only)
ImageImage generationImage count
VideoVideo generationVideo count / duration
TtsText-to-speechDuration / characters
SttSpeech-to-textDuration

Adding Pricing

For accurate cost tracking, add your custom provider's models to the pricing catalog. You can do this through the dashboard's Pricing Management screen, or by creating a JSON pricing file and importing it:

json
{
  "providers": [
    {
      "internal_name": "mistral",
      "display_name": "Mistral AI",
      "models": [
        {
          "internal_name": "mistral-large-latest",
          "display_name": "Mistral Large",
          "type": "text",
          "pricing_unit": "tokens",
          "pricing": [
            {
              "tier": "standard",
              "input_price": 200,
              "output_price": 600
            }
          ]
        }
      ]
    }
  ]
}
shell
php artisan spectra:pricing --path=my-pricing.json --force

TIP

All prices in the pricing catalog are stored in cents. For token-based models, prices are in cents per million tokens (for example, 200 means $2.00 per million tokens). For unit-based models (images, audio, video), prices are in cents per unit.

Testing Custom Providers

Write fixture-based tests for each handler to verify correct metric extraction. Test coverage should include model name extraction, token or metric extraction, response content extraction, endpoint matching, and edge cases such as missing fields or empty responses:

php
use App\Spectra\Handlers\MistralChatHandler;

it('extracts metrics from Mistral chat response', function () {
    $handler = new MistralChatHandler();

    $request = ['model' => 'mistral-large-latest', 'messages' => [...]];
    $response = [
        'model' => 'mistral-large-latest',
        'choices' => [
            ['message' => ['content' => 'Hello!'], 'finish_reason' => 'stop'],
        ],
        'usage' => [
            'prompt_tokens' => 10,
            'completion_tokens' => 5,
        ],
    ];

    $metrics = $handler->extractMetrics($request, $response);

    expect($metrics->tokens->promptTokens)->toBe(10);
    expect($metrics->tokens->completionTokens)->toBe(5);
    expect($handler->extractModel($response))->toBe('mistral-large-latest');
    expect($handler->extractResponse($response))->toBe('Hello!');
});

Released under the MIT License.