Custom providers
Provider discovery
Prisma resolves providers by convention: the class name must live in the
Aimeos\Prisma\Providers\{Type} namespace (e.g. Text, Image, Audio, Video).
The class name determines the provider name passed to using():
// Resolves to \Aimeos\Prisma\Providers\Text\Myprovider
$provider = Prisma::text()->using( 'myprovider', ['api_key' => '...'] );
// Resolves to \Aimeos\Prisma\Providers\Image\Myprovider
$provider = Prisma::image()->using( 'myprovider', ['api_key' => '...'] );
Check if a provider supports a specific method before calling it:
if( Prisma::supports( 'text', 'myprovider', 'write', $config ) ) {
// provider has a write() method
}
For testing, Prisma::fake() replaces all providers with a fake that returns
pre-built responses (see Testing):
Prisma::fake( [TextResponse::fromText( 'Hello' )] );
Available contracts
Each provider type has a set of contracts (interfaces) you can implement. A provider only needs to implement the ones it supports.
Audio
| Contract | Method | Returns |
|---|---|---|
| Demix | demix( Audio $audio, int $stems, array $options = [] ) |
FileResponse |
| Denoise | denoise( Audio $audio, array $options = [] ) |
FileResponse |
| Describe | describe( Audio $audio, ?string $lang = null, array $options = [] ) |
TextResponse |
| Revoice | revoice( Audio $audio, string $voice, array $options = [] ) |
FileResponse |
| Speak | speak( string $text, ?string $voice = null, array $options = [] ) |
FileResponse |
| Transcribe | transcribe( Audio $audio, ?string $lang = null, array $options = [] ) |
TextResponse |
| Vectorize | vectorize( array $audio, ?int $size = null, array $options = [] ) |
VectorResponse |
Image
| Contract | Method | Returns |
|---|---|---|
| Background | background( Image $image, string $prompt, array $options = [] ) |
FileResponse |
| Describe | describe( Image $image, ?string $lang = null, array $options = [] ) |
TextResponse |
| Detext | detext( Image $image, array $options = [] ) |
FileResponse |
| Erase | erase( Image $image, Image $mask, array $options = [] ) |
FileResponse |
| Imagine | imagine( string $prompt, array $images = [], array $options = [] ) |
FileResponse |
| Inpaint | inpaint( Image $image, Image $mask, string $prompt, array $options = [] ) |
FileResponse |
| Isolate | isolate( Image $image, array $options = [] ) |
FileResponse |
| Recognize | recognize( Image $image, array $options = [] ) |
TextResponse |
| Relocate | relocate( Image $image, Image $bgimage, array $options = [] ) |
FileResponse |
| Repaint | repaint( Image $image, string $prompt, array $options = [] ) |
FileResponse |
| Uncrop | uncrop( Image $image, int $top, int $right, int $bottom, int $left, array $options = [] ) |
FileResponse |
| Upscale | upscale( Image $image, int $factor, array $options = [] ) |
FileResponse |
| Vectorize | vectorize( array $images, ?int $size = null, array $options = [] ) |
VectorResponse |
Text
| Contract | Method | Returns |
|---|---|---|
| Structure | structure( string $prompt, Schema $schema, array $files = [], array $options = [] ) |
TextResponse |
| Translate | translate( array $texts, string $to, ?string $from = null, ?string $context = null, array $options = [] ) |
TextResponse |
| Write | write( string $prompt, array $files = [], array $options = [] ) |
TextResponse |
Video
| Contract | Method | Returns |
|---|---|---|
| Describe | describe( Video $video, ?string $lang = null, array $options = [] ) |
TextResponse |
New provider types
Prisma resolves provider types by namespace convention — no registration or
configuration is needed. Prisma::type('embedding')->using('openai', $config)
works as long as the class Aimeos\Prisma\Providers\Embedding\Openai exists and
is autoloaded via PSR-4.
To add a new provider type, follow these steps:
1. Define capability contracts
Create one interface per capability in src/Contracts/{Type}/:
namespace Aimeos\Prisma\Contracts\Embedding;
use Aimeos\Prisma\Responses\VectorResponse;
interface Embed
{
public function embed( array $texts, ?int $size = null, array $options = [] ) : VectorResponse;
}
2. Choose a response class
Use an existing response class if it fits (TextResponse, FileResponse,
VectorResponse). Only create a new one if the return data shape is genuinely
different from all existing responses.
3. Create the provider
namespace Aimeos\Prisma\Providers\Embedding;
use Aimeos\Prisma\Contracts\Embedding\Embed;
use Aimeos\Prisma\Exceptions\PrismaException;
use Aimeos\Prisma\Providers\Base;
use Aimeos\Prisma\Responses\VectorResponse;
class Openai extends Base implements Embed
{
public function __construct( array $config )
{
if( !isset( $config['api_key'] ) ) {
throw new PrismaException( 'No API key' );
}
$this->header( 'Authorization', 'Bearer ' . $config['api_key'] );
$this->baseUrl( 'https://api.openai.com' );
}
public function embed( array $texts, ?int $size = null, array $options = [] ) : VectorResponse
{
$options = $this->allowed( $options, ['model'] );
$response = $this->client()->post( 'v1/embeddings', ['json' => [
'model' => $options['model'] ?? 'text-embedding-3-small',
'input' => $texts,
'dimensions' => $size,
]] );
$this->validate( $response );
$data = $this->fromJson( $response );
$vectors = array_map( fn( $item ) => $item['embedding'], $data['data'] ?? [] );
return VectorResponse::fromVectors( $vectors )
->withUsage( $data['usage']['total_tokens'] ?? null, $data['usage'] ?? [] );
}
}
If the API is OpenAI-compatible, the provider can extend a shared base class
(e.g. Providers\Openai) instead of Base directly — see Two-level provider
pattern.
4. Use the new type
use Aimeos\Prisma\Prisma;
$provider = Prisma::type( 'embedding' )->using( 'openai', ['api_key' => '...'] );
$response = $provider->embed( ['Hello world', 'Another text'], 256 );
Capability checks, Prisma::supports(), Fake, and MakesPrismaRequests all
work automatically for new types — no extra wiring is needed.
Base skeleton
<?php
// for Audio providers
namespace Aimeos\Prisma\Providers\Audio;
// for Image providers
namespace Aimeos\Prisma\Providers\Image;
// for Text providers
namespace Aimeos\Prisma\Providers\Text;
// for Video providers
namespace Aimeos\Prisma\Providers\Video;
use Aimeos\Prisma\Exceptions\PrismaException;
use Aimeos\Prisma\Providers\Base;
class Myprovider extends Base implements ...
{
public function __construct( array $config )
{
if( !isset( $config['api_key'] ) ) {
throw new PrismaException( sprintf( 'No API key' ) );
}
// if authentication is done via headers
$this->header( '<api key name>', $config['api_key'] );
// base url for all requests (no paths)
$this->baseUrl( '<provider URL>' );
}
Implement one or more contracts for the chosen provider type:
namespace Aimeos\Prisma\Providers\Image;
use Aimeos\Prisma\Contracts\Image\Describe;
use Aimeos\Prisma\Exceptions\PrismaException;
use Aimeos\Prisma\Files\Image;
use Aimeos\Prisma\Providers\Base;
use Aimeos\Prisma\Responses\TextResponse;
class Myprovider extends Base implements Describe
{
public function describe( Image $image, ?string $lang = null, array $options = [] ) : TextResponse
{
// ...
}
}
Configuration
The cfg() method safely extracts string values from the config array. It returns the default value if the key is missing or not a string:
protected function cfg( array $config, string $key, string $default = '' ) : string
Use it in constructors to support custom API URLs (for self-hosted or proxy setups):
public function __construct( array $config )
{
if( !isset( $config['api_key'] ) ) {
throw new PrismaException( 'No API key' );
}
$this->header( 'Authorization', 'Bearer ' . $this->cfg( $config, 'api_key' ) );
$this->baseUrl( $this->cfg( $config, 'url', 'https://api.default.com' ) );
}
System prompt
The systemPrompt() method returns the system prompt set by the user via
withSystemPrompt(). Use it when building request payloads for text providers:
$payload = [
'model' => $this->modelName( 'default-model' ),
'messages' => [['role' => 'user', 'content' => $prompt]],
];
if( $system = $this->systemPrompt() ) {
$payload['system'] = $system;
}
Token limits
The maxTokens() and thinkingBudget() methods return the values set by the
user via withMaxTokens() and withThinkingBudget(). Use them to configure
output length and extended thinking:
$payload['max_tokens'] = $this->maxTokens() ?? 4096;
if( $budget = $this->thinkingBudget() ) {
$payload['thinking'] = ['type' => 'enabled', 'budget_tokens' => $budget];
}
File types
The Aimeos\Prisma\Files namespace provides typed file classes. File is the
base class, while Audio, Image and Video extend it with mime type validation
(ensuring the mime type starts with audio/, image/ or video/ respectively).
Factory methods
All file classes support these static factory methods:
use Aimeos\Prisma\Files\Image;
$file = Image::fromBinary( $binaryData, 'image/png' );
$file = Image::fromBase64( $base64Data, 'image/png' );
$file = Image::fromUrl( 'https://example.com/photo.jpg', 'image/jpeg' );
$file = Image::fromLocalPath( '/path/to/photo.jpg' );
$file = Image::fromStoragePath( 'photos/image.jpg', 'public', 'image/jpeg' );
The mime type parameter is optional but recommended. If omitted, it will be guessed from the content when accessed.
Accessors
$file->binary(); // raw binary content (fetches from URL if needed)
$file->base64(); // base64-encoded content
$file->url(); // original URL (if created from URL)
$file->mimeType(); // mime type string
$file->filename(); // filename (if available)
$file->as( 'name.png' ); // set the filename
Requests
The allowed() and sanitize() methods filter options to only those the API supports. This lets callers pass parameters for multiple providers at once:
// filter key/value pairs in $options and use the ones allowed by the API
$allowed = $this->allowed( $options, ['<key1>', '<key2>', /* ... */] );
// filter values to pass only allowed option values (optional)
$allowed = $this->sanitize( $allowed, ['<key1>' => ['<val1>', '<val2>', '<val3>']])
The modelName() method returns the user’s model choice or the given default:
$model = $this->modelName( 'gemini-2.5-flash' );
The request() method formats parameters and files for form or multipart requests. Build JSON payloads directly:
// Form data request
$data = $this->request( $params );
// Multipart request
$data = ['multipart' => $this->request( $params, ['image_key' => $image->binary()] )];
// JSON request
$data = ['json' => ['image_key' => array_map( fn( $image ) => $image->base64(), $images )] + $params];
Send the request via the Guzzle client, validate, and extract content:
$response = $this->client()->post( 'relative/api/path', $data );
$this->validate( $response );
$content = $response->getBody()->getContents();
Full example:
use Aimeos\Prisma\Files\Image;
use Aimeos\Prisma\Responses\TextResponse;
public function describe( Image $image, ?string $lang = null, array $options = [] ) : TextResponse
{
$model = $this->modelName( 'flash' );
$allowed = $this->allowed( $options, ['version'] );
$params = ['language' => $lang] + $allowed;
$data = ['multipart' => $this->request( $params, ['file' => $image->binary()] )];
$response = $this->client()->post( 'relative/api/path', $data );
$this->validate( $response );
$content = $response->getBody()->getContents();
// return a response
}
Error handling
The validate() method checks for HTTP 200, extracts the error message from
the JSON body (error.message or message), and calls throw() which maps
status codes to typed exceptions:
| HTTP Status | Exception |
|---|---|
| 400, 409, 422 | BadRequestException |
| 401 | UnauthorizedException |
| 402 | PaymentRequiredException |
| 403 | ForbiddenException |
| 404 | NotFoundException |
| 413 | SizeException |
| 429 | RateLimitException |
| 502, 503, 504 | OverloadedException |
| other | PrismaException |
All exceptions extend PrismaException (Aimeos\Prisma\Exceptions namespace).
The fromJson() method decodes a JSON response body into an array, throwing
PrismaException on invalid JSON.
$data = $this->fromJson( $response );
Custom error handling
Override validate() when the API uses a different error format or status code mapping:
use Psr\Http\Message\ResponseInterface;
protected function validate( ResponseInterface $response ) : void
{
if( $response->getStatusCode() === 200 ) {
return;
}
$data = $this->fromJson( $response );
$error = $data['detail'] ?? $response->getReasonPhrase();
// remap status codes if needed (e.g. API returns 422 for auth errors)
$this->throw( match( $response->getStatusCode() ) {
422 => 400,
default => $response->getStatusCode(),
}, is_string( $error ) ? $error : '' );
}
Responses
Three response types are available: FileResponse, TextResponse and
VectorResponse.
File response
A FileResponse contains one or more files as binary, base64 or URL. The mime type is optional but prevents guessing later:
use Aimeos\Prisma\Responses\FileResponse;
$response = FileResponse::fromBinary( '...', 'image/png' );
$response = FileResponse::fromBase64( '...', 'image/png' );
$response = FileResponse::fromUrl( '...', 'image/png' );
Add multiple files with add():
use Aimeos\Prisma\Files\File;
$response->add( File::fromBinary( '...', 'image/png' ) );
For asynchronous APIs, see the Async operations section.
Text response
use Aimeos\Prisma\Responses\TextResponse;
$response = TextResponse::fromText( '...' );
$response->add( '...' ); // add more texts
Also supports fromAsync() — see Async operations.
Vector response
A VectorResponse contains float vectors representing the input:
use Aimeos\Prisma\Responses\VectorResponse;
$response = VectorResponse::fromVectors( [
[0.27629, 0.89271, 0.98265, /* ... */],
/* ... */
] );
Meta data
All responses support withUsage() and withMeta() for usage and meta data:
$response->withUsage( // optional
100, // used tokens, credits, etc. if available or NULL
[] // arbitrary key/value pairs for the rest of the usage information
);
$response->withMeta( // optional
[] // arbitrary meta data as key/value pairs, can be nested
);
TextResponse stores structured data from structure() or transcriptions:
$response->withStructured( [
// for transcriptions
['start' => 0.0, 'end' => 1.0, 'text' => 'This is a test.'],
// ...
] );
Transcription entries must contain start (seconds), end (seconds) and text. Additional key/value pairs are allowed.
FileResponse also supports withDescription():
$response->withDescription( '...' );
Finish reason
TextResponse supports withReason() to indicate why the model stopped
generating. Use the constants defined in the HasReason trait:
| Constant | Meaning |
|---|---|
STOP |
Model finished normally |
LENGTH |
Hit max token limit |
TOOL |
Stopped for tool calls (maxSteps exhausted) |
CONTENT |
Blocked by safety/content filter |
ERROR |
Provider returned an error |
UNKNOWN |
Unrecognized finish reason |
return TextResponse::fromText( $data['text'] ?? '' )
->withReason( match( $data['finish_reason'] ?? '' ) {
'stop' => self::STOP,
'length' => self::LENGTH,
'tool_calls' => self::TOOL,
'content_filter' => self::CONTENT,
default => self::UNKNOWN,
} );
Citations
TextResponse supports withCitations() for source references. Each Citation
has optional title, url, text (cited output) and source (verbatim quote):
use Aimeos\Prisma\Values\Citation;
$citations = [];
foreach( $data['citations'] ?? [] as $cit ) {
$citations[] = new Citation(
title: $cit['title'] ?? null,
url: $cit['url'] ?? null,
text: $cit['cited_text'] ?? null,
source: $cit['source_text'] ?? null,
);
}
return TextResponse::fromText( $data['text'] ?? '' )
->withCitations( $citations );
Tool steps
TextResponse supports withSteps() to record tool call history. Each Step
contains the tool call ID, name, arguments and result:
use Aimeos\Prisma\Tools\Step;
// after executing tools with execTools()
return TextResponse::fromText( $data['text'] ?? '' )
->withSteps( $allSteps );
Callers can inspect tool steps via $response->steps(), where each Step
provides id(), name(), arguments() and result().
Rate limits
The getRateLimit() method extracts rate limit info from standard HTTP headers
(x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after).
Attach it to any response with withRateLimit():
$response = $this->client()->post( 'v1/chat', $data );
$this->validate( $response );
$data = $this->fromJson( $response );
return TextResponse::fromText( $data['text'] ?? '' )
->withRateLimit( $this->getRateLimit( $response ) );
The RateLimit value object provides these accessors:
$rateLimit = $response->rateLimit();
$rateLimit->limit(); // request limit (int or null)
$rateLimit->remaining(); // remaining requests (int or null)
$rateLimit->reset(); // reset timestamp (string or null)
$rateLimit->retryAfter(); // retry after seconds (int or null)
Structured output
Text providers can implement the Structure contract to return data matching
a defined schema.
Schema
The Schema class defines the expected JSON output structure using a fluent API:
use Aimeos\Prisma\Schema\Schema;
$schema = Schema::for( 'person', [
'name' => Schema::string()->description( 'Full name' )->required(),
'age' => Schema::integer()->description( 'Age in years' ),
'tags' => Schema::array()->items( Schema::string() ),
'address' => Schema::object( [
'city' => Schema::string()->required(),
'zip' => Schema::string(),
] ),
] );
You can also create a schema from an existing JSON Schema array:
$schema = Schema::fromArray( 'person', $jsonSchemaArray );
Available type builders:
| Type | Builder | Type-specific methods |
|---|---|---|
| string | Schema::string() |
min(), max(), pattern(), format(), default() |
| integer | Schema::integer() |
min(), max(), default() |
| number | Schema::number() |
min(), max(), default() |
| boolean | Schema::boolean() |
default() |
| array | Schema::array() |
items(), min(), max(), unique(), default() |
| object | Schema::object() |
withoutAdditionalProperties(), default() |
All types share these methods: description(), title(), required(), nullable(), enum().
The enum() method accepts either an array of values or a BackedEnum class name:
Schema::string()->enum( ['draft', 'published', 'archived'] )
Schema::string()->enum( StatusEnum::class )
Schema instance methods:
$schema->toArray(); // full JSON Schema array
$schema->toString(); // JSON string
$schema->filter( ['type', 'description', 'properties', 'required', 'items'] ); // filtered schema
$schema->name(); // schema name
$schema->strict(); // enable strict mode
$schema->isStrict(); // check strict mode
The filter() method keeps only specified keys (recursively). This is useful for APIs that reject unknown JSON Schema fields.
Implementing structure()
For APIs with native structured output support:
use Aimeos\Prisma\Schema\Schema;
use Aimeos\Prisma\Responses\TextResponse;
public function structure( string $prompt, Schema $schema, array $files = [], array $options = [] ) : TextResponse
{
$allowed = $this->allowed( $options, ['temperature'] );
$model = $this->modelName( 'default-model' );
$payload = [
'model' => $model,
'messages' => [['role' => 'user', 'content' => $prompt]],
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => $schema->name(),
'schema' => $schema->toArray(),
'strict' => $schema->isStrict(),
],
],
] + $allowed;
$response = $this->client()->post( 'v1/chat/completions', ['json' => $payload] );
$this->validate( $response );
$data = $this->fromJson( $response );
$text = $data['choices'][0]['message']['content'] ?? '';
$structured = json_decode( $text, true ) ?: [];
return TextResponse::fromText( $text )->withStructured( $structured );
}
For APIs without native support, use prompt engineering as a fallback:
$schemaPrompt = $prompt
. "\n\nRespond with ONLY valid JSON matching this schema:\n"
. $schema->toString();
// ... send $schemaPrompt to the API ...
$text = trim( $data['text'] ?? '' );
$text = preg_replace( '/^```(?:json)?\s*|\s*```$/s', '', $text ) ?? $text;
$structured = json_decode( $text, true ) ?: [];
return TextResponse::fromText( $text )->withStructured( $structured );
Tool support
Text providers that support tool calling need the CallsTools trait in their
mid-level base class.
Available methods
| Method | Purpose |
|---|---|
execTools( array $toolCalls ) |
Execute tool calls, returns array of Step results |
tools() |
Returns user-provided tool adapters |
providerTools() |
Returns built-in provider tool adapters |
toolChoice() |
Returns tool choice setting (self::AUTO, self::REQ, self::NONE) |
maxSteps() |
Returns max tool loop iterations |
concurrency() |
Returns concurrency strategy for parallel tool execution |
Methods to implement
Each provider must implement these three methods to match its API format:
// Format tools array for the API
protected function toolsParam() : array
{
$tools = [];
foreach( $this->tools() as $tool ) {
$tools[] = [
'name' => $tool->name(),
'description' => $tool->description(),
'parameters' => $tool->schema()->toArray(),
];
}
return $tools;
}
// Extract tool calls from API response
protected function parseToolCalls( array $result ) : array
{
$toolCalls = [];
foreach( $result['tool_calls'] ?? [] as $call ) {
$toolCalls[] = [
'id' => $call['id'] ?? null,
'name' => $call['function']['name'] ?? '',
'arguments' => json_decode( $call['function']['arguments'] ?? '{}', true ) ?: [],
];
}
return $toolCalls;
}
// Format tool results as messages for the next API call
protected function toolResults( array $results ) : array
{
$messages = [];
foreach( $results as $step ) {
$messages[] = [
'role' => 'tool',
'tool_call_id' => $step->id(),
'content' => $step->result(),
];
}
return $messages;
}
Tool loop pattern
The tool loop sends the request, checks for tool calls, executes them, appends
results, and repeats until no more tool calls or maxSteps() is reached:
private function generate( array $messages, array $options ) : TextResponse
{
$allSteps = [];
for( $step = 1; $step <= $this->maxSteps(); $step++ )
{
$params = [
'model' => $this->modelName( 'default-model' ),
'messages' => $messages,
] + $options;
if( $tools = $this->toolsParam() ) {
$params['tools'] = $tools;
$params['tool_choice'] = $this->toolChoice();
}
$response = $this->client()->post( 'v1/chat/completions', ['json' => $params] );
$this->validate( $response );
$result = $this->fromJson( $response );
$toolCalls = $this->parseToolCalls( $result );
if( !$toolCalls ) {
break;
}
$toolResults = $this->execTools( $toolCalls );
array_push( $allSteps, ...$toolResults );
$messages[] = $result['choices'][0]['message'] ?? [];
$messages = array_merge( $messages, $this->toolResults( $toolResults ) );
}
$text = $result['choices'][0]['message']['content'] ?? '';
return TextResponse::fromText( $text )
->withSteps( $allSteps )
->withReason( $toolCalls ? self::TOOL : self::STOP );
}
Provider tools
Map built-in provider tools (web search, code execution) using
mapProviderTools() with a static map:
private static array $providerToolMap = [
'web_search' => [
'type' => 'web_search',
'options' => ['allowed_domains', 'search_context_size'],
],
'code_execution' => [
'type' => 'code_interpreter',
],
];
protected function toolsParam() : array
{
$tools = [];
foreach( $this->tools() as $tool ) {
$tools[] = [
'name' => $tool->name(),
'description' => $tool->description(),
'parameters' => $tool->schema()->toArray(),
];
}
return array_merge( $tools, $this->mapProviderTools( self::$providerToolMap ) );
}
Async operations
For APIs that require polling, both FileResponse and TextResponse support fromAsync():
FileResponse::fromAsync( Closure $closure, int $retry = 5 ) : FileResponse
TextResponse::fromAsync( Closure $closure, int $retry = 5 ) : TextResponse
The closure receives the response object and returns true when ready or
false to keep polling. $retry is the sleep interval in seconds.
Typical pattern — a separate method returning the polling closure:
use Aimeos\Prisma\Files\Image;
use Aimeos\Prisma\Responses\FileResponse;
public function imagine( string $prompt, array $images = [], array $options = [] ) : FileResponse
{
$response = $this->client()->post( 'v1/generate', ['json' => ['prompt' => $prompt]] );
$this->validate( $response );
$data = $this->fromJson( $response );
return FileResponse::fromAsync( $this->download( $data['polling_url'] ?? '' ), 2 );
}
protected function download( string $url ) : \Closure
{
$client = $this->client();
return function( FileResponse $fr ) use ( $client, $url ) : bool {
$response = $client->get( $url );
$data = json_decode( $response->getBody()->getContents(), true ) ?: [];
if( ( $data['status'] ?? '' ) !== 'Ready' ) {
return false;
}
$fr->add( Image::fromUrl( $data['url'] ?? '' ) );
return true;
};
}
The same pattern works for TextResponse:
use Aimeos\Prisma\Responses\TextResponse;
return TextResponse::fromAsync( function( TextResponse $tr ) use ( $client, $id ) : bool {
$response = $client->get( "v1/jobs/{$id}" );
$data = json_decode( $response->getBody()->getContents(), true ) ?: [];
if( ( $data['status'] ?? '' ) !== 'COMPLETED' ) {
return false;
}
$tr->add( $data['text'] ?? '' );
return true;
}, 3 );
Use $response->ready() for non-blocking checks. Accessing content (files(),
text()) blocks until the operation completes.
OpenAI-compatible APIs
The OpenaiApi trait provides ready-made methods for OpenAI-compatible APIs,
handling the full request/response cycle including tool loops:
| Method | Purpose |
|---|---|
completions() |
Chat completions with tool loop |
responses() |
OpenAI Responses API with tool loop |
structuredCompletions() |
Completions with JSON schema response format |
structuredResponses() |
Responses API with JSON schema format |
content() |
Build content blocks from prompt and files |
messages() |
Build messages array with optional system prompt |
toolsParam() |
Format tools in OpenAI function calling format |
parseToolCalls() |
Extract tool calls from completions response |
Use both CallsTools and OpenaiApi traits in the mid-level base:
<?php
namespace Aimeos\Prisma\Providers;
use Aimeos\Prisma\Concerns\CallsTools;
use Aimeos\Prisma\Concerns\OpenaiApi;
use Aimeos\Prisma\Exceptions\PrismaException;
class Myprovider extends Base
{
use CallsTools;
use OpenaiApi;
public function __construct( array $config )
{
if( !isset( $config['api_key'] ) ) {
throw new PrismaException( 'No API key' );
}
$this->header( 'Authorization', 'Bearer ' . $this->cfg( $config, 'api_key' ) );
$this->baseUrl( $this->cfg( $config, 'url', 'https://api.myprovider.com' ) );
}
}
The text provider becomes minimal:
<?php
namespace Aimeos\Prisma\Providers\Text;
use Aimeos\Prisma\Contracts\Text\Structure;
use Aimeos\Prisma\Contracts\Text\Write;
use Aimeos\Prisma\Providers\Myprovider as Base;
use Aimeos\Prisma\Responses\TextResponse;
use Aimeos\Prisma\Schema\Schema;
class Myprovider extends Base implements Structure, Write
{
public function structure( string $prompt, Schema $schema, array $files = [], array $options = [] ) : TextResponse
{
$options = $this->allowed( $options, ['temperature', 'top_p'] );
return $this->structuredCompletions(
'v1/chat/completions', 'default-model',
$this->messages( $this->content( $prompt, $files ) ),
$schema, $options
);
}
public function write( string $prompt, array $files = [], array $options = [] ) : TextResponse
{
$options = $this->allowed( $options, ['temperature', 'top_p'] );
return $this->completions(
'v1/chat/completions', 'default-model',
$this->messages( $this->content( $prompt, $files ) ),
$options
);
}
}
Two-level provider pattern
When a provider serves multiple types and needs shared helpers, use a
two-level pattern instead of extending Base directly:
Providers/Base.php (framework base, all providers)
Providers/Myprovider.php (shared: auth, helpers)
Providers/Text/Myprovider.php (implements Write, Structure)
Providers/Audio/Myprovider.php (implements Speak, Transcribe)
Mid-level base with shared authentication and helpers:
<?php
namespace Aimeos\Prisma\Providers;
use Aimeos\Prisma\Exceptions\PrismaException;
class Myprovider extends Base
{
public function __construct( array $config )
{
if( !isset( $config['api_key'] ) ) {
throw new PrismaException( 'No API key' );
}
$this->header( 'Authorization', 'Bearer ' . $this->cfg( $config, 'api_key' ) );
$this->baseUrl( $this->cfg( $config, 'url', 'https://api.myprovider.com' ) );
}
protected function content( string $prompt, array $files ) : array
{
// shared logic for building content blocks from prompt and files
$parts = [['type' => 'text', 'text' => $prompt]];
foreach( $files as $file ) {
$parts[] = ['type' => 'image', 'data' => $file->base64()];
}
return $parts;
}
}
Type-specific subclass:
<?php
namespace Aimeos\Prisma\Providers\Text;
use Aimeos\Prisma\Contracts\Text\Write;
use Aimeos\Prisma\Providers\Myprovider as Base;
use Aimeos\Prisma\Responses\TextResponse;
class Myprovider extends Base implements Write
{
public function write( string $prompt, array $files = [], array $options = [] ) : TextResponse
{
$allowed = $this->allowed( $options, ['temperature', 'top_p'] );
$payload = [
'model' => $this->modelName( 'default-model' ),
'messages' => [['role' => 'user', 'content' => $this->content( $prompt, $files )]],
] + $allowed;
if( $system = $this->systemPrompt() ) {
$payload['system'] = $system;
}
$response = $this->client()->post( 'v1/chat', ['json' => $payload] );
$this->validate( $response );
$data = $this->fromJson( $response );
return TextResponse::fromText( $data['choices'][0]['message']['content'] ?? '' )
->withUsage( $data['usage']['total_tokens'] ?? null, $data['usage'] ?? [] )
->withRateLimit( $this->getRateLimit( $response ) );
}
}
Examples
Audio / Image / Video provider
Audio, Image and Video providers follow the same pattern — only the namespace, contract, file class and method signature differ:
<?php
namespace Aimeos\Prisma\Providers\Image; // or Audio, Video
use Aimeos\Prisma\Contracts\Image\Describe; // or Audio\Describe, Video\Describe
use Aimeos\Prisma\Exceptions\PrismaException;
use Aimeos\Prisma\Files\Image; // or Audio, Video
use Aimeos\Prisma\Providers\Base;
use Aimeos\Prisma\Responses\TextResponse;
class Myprovider extends Base implements Describe
{
public function __construct( array $config )
{
if( !isset( $config['api_key'] ) ) {
throw new PrismaException( 'No API key' );
}
$this->header( 'x-api-key', $config['api_key'] );
$this->baseUrl( 'https://ai.com' );
}
public function describe( Image $image, ?string $lang = null, array $options = [] ) : TextResponse
{
$allowed = $this->allowed( $options, ['version'] );
$model = $this->modelName( 'flash' );
$params = ['language' => $lang, 'model' => $model] + $allowed;
$data = ['multipart' => $this->request( $params, ['file' => $image->binary()] )];
$response = $this->client()->post( 'relative/api/path', $data );
$this->validate( $response );
$data = $this->fromJson( $response );
return TextResponse::fromText( $data['text'] ?? '' )
->withStructured( $data['segments'] ?? [] )
->withUsage( $data['usage']['total'] ?? null, $data['usage'] ?? [] )
->withMeta( $data['meta'] ?? [] );
}
}
Text provider
<?php
namespace Aimeos\Prisma\Providers\Text;
use Aimeos\Prisma\Contracts\Text\Translate;
use Aimeos\Prisma\Exceptions\PrismaException;
use Aimeos\Prisma\Providers\Base;
use Aimeos\Prisma\Responses\TextResponse;
class Myprovider extends Base implements Translate
{
public function __construct( array $config )
{
if( !isset( $config['api_key'] ) ) {
throw new PrismaException( 'No API key' );
}
$this->header( 'Authorization', 'Bearer ' . $config['api_key'] );
$this->baseUrl( 'https://ai.com' );
}
public function translate( array $texts, string $to, ?string $from = null, ?string $context = null, array $options = [] ) : TextResponse
{
$payload = [
'texts' => $texts,
'target_lang' => $to,
'source_lang' => $from,
] + $this->allowed( $options, ['formality'] );
$response = $this->client()->post( '/v1/translate', ['json' => $payload] );
$this->validate( $response );
$data = $this->fromJson( $response );
$translated = array_map( fn( $item ) => $item['text'] ?? '', $data ?? [] );
return TextResponse::fromTexts( $translated )
->withUsage( $data['usage']['total'] ?? 0, $data['usage'] ?? [] )
->withMeta( $data['meta'] ?? [] );
}
}
Testing
MakesPrismaRequests trait
The MakesPrismaRequests trait provides a mocked HTTP layer for PHPUnit:
| Method | Purpose |
|---|---|
prisma( string $type, string $name, array $config ) |
Set up a provider with mocked HTTP |
response( string\|array $body, array $headers, int $status, string $reason ) |
Queue a fake HTTP response, returns Provider |
requests() |
Get all recorded HTTP requests |
provider() |
Access the underlying provider instance |
assertPrismaRequest( callable $callback ) |
Assert a matching request was sent |
use Tests\MakesPrismaRequests;
use PHPUnit\Framework\TestCase;
class MyproviderTest extends TestCase
{
use MakesPrismaRequests;
public function testDescribe() : void
{
$this->prisma( 'image', 'myprovider', ['api_key' => 'test'] );
$result = $this->response( ['text' => 'A photo of a cat'] )
->describe( Image::fromUrl( 'https://example.com/cat.jpg' ) );
$this->assertEquals( 'A photo of a cat', $result->text() );
$this->assertPrismaRequest( function( $request, $options ) {
return str_contains( $request->getUri()->getPath(), 'relative/api/path' );
} );
}
}
Queue multiple responses for tool loops or async polling:
$this->prisma( 'text', 'myprovider', ['api_key' => 'test'] );
$this->response( ['status' => 'pending'] );
$result = $this->response( ['text' => 'Done'] )
->write( 'Hello' );
Fake provider
The Fake provider returns pre-built responses without HTTP, validating
method names against a real provider:
use Aimeos\Prisma\Providers\Fake;
use Aimeos\Prisma\Responses\TextResponse;
$fake = new Fake( [
TextResponse::fromText( 'Hello' ),
TextResponse::fromText( 'World' ),
] );
$fake->use( new \Aimeos\Prisma\Providers\Text\Myprovider( ['api_key' => 'test'] ) );
$result = $fake->write( 'prompt' ); // returns "Hello"
$result = $fake->write( 'prompt' ); // returns "World"