ez-php / ai
Multi-provider AI client for ez-php — unified driver-based abstraction over OpenAI, Anthropic, Gemini, and Mistral
Requires
- php: ^8.5
- ez-php/contracts: ^2.0
- ez-php/http: ^2.0
- ez-php/http-client: ^2.0
Requires (Dev)
- ez-php/docker: ^2.0
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-16 14:34:17 UTC
README
Multi-provider AI client for ez-php. Supports chat completions, streaming, tool calling, and embeddings across OpenAI, Anthropic, Gemini, Mistral, and Grok.
Installation
composer require ez-php/ai
Requires PHP 8.5 and ez-php/http-client.
Configuration
Register AiServiceProvider in your application and add config/ai.php:
// config/ai.php return [ 'driver' => env('AI_DRIVER', 'openai'), // Seconds a streamed completion may send nothing before it fails (no total limit). 'stream_idle_timeout' => (int) env('AI_STREAM_IDLE_TIMEOUT', 120), 'openai' => [ 'api_key' => env('OPENAI_API_KEY', ''), 'model' => env('OPENAI_MODEL', 'gpt-4o-mini'), 'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com'), ], 'anthropic' => [ 'api_key' => env('ANTHROPIC_API_KEY', ''), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'), 'api_version' => env('ANTHROPIC_API_VERSION', '2023-06-01'), ], 'gemini' => [ 'api_key' => env('GEMINI_API_KEY', ''), 'model' => env('GEMINI_MODEL', 'gemini-2.0-flash'), ], 'mistral' => [ 'api_key' => env('MISTRAL_API_KEY', ''), 'model' => env('MISTRAL_MODEL', 'mistral-small-latest'), 'base_url' => env('MISTRAL_BASE_URL', 'https://api.mistral.ai'), ], 'grok' => [ 'api_key' => env('GROK_API_KEY', ''), 'model' => env('GROK_MODEL', 'grok-3-mini'), 'base_url' => env('GROK_BASE_URL', 'https://api.x.ai'), ], 'log' => [ 'inner_driver' => env('AI_LOG_INNER_DRIVER', 'openai'), ], ];
Driver options
AI_DRIVER value |
Description |
|---|---|
openai |
OpenAI chat completions API |
anthropic |
Anthropic Messages API |
gemini |
Google Gemini generateContent API |
mistral |
Mistral AI (OpenAI-compatible) |
grok |
xAI Grok (OpenAI-compatible) |
log |
Decorates another driver with error_log output |
null |
Returns empty responses; useful in tests |
Environment variables
| Variable | Default | Description |
|---|---|---|
AI_DRIVER |
null |
Active driver |
AI_STREAM_IDLE_TIMEOUT |
120 |
Seconds a stream may send nothing before it fails |
OPENAI_API_KEY |
— | OpenAI API key |
OPENAI_MODEL |
gpt-4o-mini |
Default OpenAI model |
OPENAI_BASE_URL |
https://api.openai.com |
Base URL (Azure / proxy support) |
ANTHROPIC_API_KEY |
— | Anthropic API key |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
Default Anthropic model |
ANTHROPIC_API_VERSION |
2023-06-01 |
anthropic-version header value |
GEMINI_API_KEY |
— | Google AI API key |
GEMINI_MODEL |
gemini-2.0-flash |
Default Gemini model |
MISTRAL_API_KEY |
— | Mistral API key |
MISTRAL_MODEL |
mistral-small-latest |
Default Mistral model |
MISTRAL_BASE_URL |
https://api.mistral.ai |
Mistral base URL |
GROK_API_KEY |
— | xAI (Grok) API key |
GROK_MODEL |
grok-3-mini |
Default Grok model |
GROK_BASE_URL |
https://api.x.ai |
Grok base URL |
AI_LOG_INNER_DRIVER |
openai |
Driver wrapped by the log driver |
Basic usage
Static facade
use EzPhp\Ai\Ai; use EzPhp\Ai\Request\AiRequest; $response = Ai::complete(AiRequest::make('What is the capital of France?')); echo $response->content(); // "Paris"
Direct driver injection
use EzPhp\Ai\AiClientInterface; use EzPhp\Ai\Request\AiRequest; class MyService { public function __construct(private AiClientInterface $ai) {} public function ask(string $question): string { $response = $this->ai->complete(AiRequest::make($question)); return $response->content(); } }
Building requests
AiRequest is immutable. All wither methods return new instances.
use EzPhp\Ai\Request\AiRequest; use EzPhp\Ai\Message\AiMessage; // Single user message $request = AiRequest::make('Hello'); // Explicit message list $request = AiRequest::withMessages( AiMessage::system('You are a helpful assistant.'), AiMessage::user('What is 2 + 2?'), ); // Chain withers $request = AiRequest::make('Explain async/await') ->withModel('gpt-4o') ->withTemperature(0.7) ->withMaxTokens(500) ->withSystemPrompt('You are a concise technical writer.'); // Append a message $request = $request->addMessage(AiMessage::user('Give an example in PHP.'));
Messages
use EzPhp\Ai\Message\AiMessage; use EzPhp\Ai\Message\ContentPart; // Plain text AiMessage::user('Hello'); AiMessage::assistant('Hi there!'); AiMessage::system('You are a helpful assistant.'); // Multimodal (text + image URL) AiMessage::userWithParts([ ContentPart::text('What is in this image?'), ContentPart::imageUrl('https://example.com/image.png'), ]);
Streaming
Drivers that implement StreamingAiClientInterface support streaming responses.
use EzPhp\Ai\Ai; use EzPhp\Ai\Request\AiRequest; use EzPhp\Ai\StreamingAiClientInterface; $client = Ai::getClient(); if ($client instanceof StreamingAiClientInterface) { $stream = $client->stream(AiRequest::make('Tell me a story.')); foreach ($stream as $chunk) { echo $chunk->content(); if ($chunk->isFinal()) { echo PHP_EOL; echo 'Finish reason: ' . $chunk->finishReason()?->value . PHP_EOL; } } } // Or collect the full text at once $text = $stream->collect();
All five production drivers (OpenAI, Anthropic, Gemini, Mistral, Grok) implement StreamingAiClientInterface.
Chunks arrive while the provider is still generating. stream() returns once the provider has answered with headers, so authentication, quota and model errors still throw AiRequestException there.
While iterating, AiStreamException signals a stream that cannot complete — the connection dropped or went silent, the provider sent an error event (providerErrorType() holds e.g. overloaded_error), or the stream ended without the provider's completion signal:
use EzPhp\Ai\AiStreamException; try { $text = $stream->collect(); } catch (AiStreamException $e) { // partial output is not a complete answer }
A stream holds its connection open until it is consumed or dropped — iterate it right away rather than keeping AiStream objects around.
AI_STREAM_IDLE_TIMEOUT (default 120 s) is how long a stream may send nothing before it fails; there is no total limit.
Forwarding a stream to the browser
use EzPhp\Http\StreamedResponse; // Make the call here, before returning, so provider errors still become a normal error response. $stream = $client->stream(AiRequest::make('Tell me a story.')); return StreamedResponse::sse(fn () => $stream->toSseEvents());
toSseEvents() emits event: token with {"content": …} per chunk and a final event: done
with {"finish_reason": …}. Payloads are JSON, so newlines in model output cannot break SSE framing.
If the stream fails mid-way, StreamedResponse::sse() sends a generic event: error frame and reports the exception; closing the browser tab closes the provider connection.
Tool calling
Define tools, attach them to the request, and handle tool calls in a loop.
use EzPhp\Ai\Ai; use EzPhp\Ai\Request\AiRequest; use EzPhp\Ai\Message\AiMessage; use EzPhp\Ai\Response\FinishReason; use EzPhp\Ai\Tool\ToolDefinition; $getWeather = new ToolDefinition( name: 'get_weather', description: 'Returns the current weather for a city.', parameters: [ 'type' => 'object', 'properties' => [ 'city' => ['type' => 'string', 'description' => 'The city name'], ], 'required' => ['city'], ], ); $request = AiRequest::make('What is the weather in Berlin?') ->withTools($getWeather); $response = Ai::complete($request); // Agentic loop while ($response->finishReason() === FinishReason::TOOL_CALL) { $toolMessages = []; foreach ($response->toolCalls() as $call) { $result = match ($call->name()) { 'get_weather' => json_encode(['temp' => '18°C', 'condition' => 'Cloudy']), default => 'Unknown tool', }; $toolMessages[] = AiMessage::tool($result, $call->id()); } $request = $request ->addMessage(AiMessage::assistantWithToolCalls(...$response->toolCalls())) ->addMessage(...$toolMessages); // may need multiple addMessage calls $response = Ai::complete($request); } echo $response->content();
Gemini note: Gemini does not assign separate IDs to tool calls. The function name is used as the call ID. Use the function name as
toolCallIdin tool result messages for Gemini conversations.
Streaming + tool calling: Tool calls are only parsed in
complete(). Thestream()path yields text chunks only.
Embeddings
Use OpenAiEmbeddingDriver or GeminiEmbeddingDriver directly — embeddings are not wired through AiServiceProvider or the Ai facade.
use EzPhp\Ai\Driver\OpenAiConfig; use EzPhp\Ai\Driver\OpenAiEmbeddingDriver; use EzPhp\HttpClient\CurlTransport; use EzPhp\HttpClient\HttpClient; $driver = new OpenAiEmbeddingDriver( new HttpClient(new CurlTransport()), new OpenAiConfig(apiKey: $_ENV['OPENAI_API_KEY']), ); // Returns float[] $vector = $driver->embed('The quick brown fox'); // Override model $vector = $driver->embed('Hello world', 'text-embedding-3-large'); // Batch: one request for multiple inputs — returns float[][], in input order $vectors = $driver->embedBatch(['The quick brown fox', 'jumps over the lazy dog']);
use EzPhp\Ai\Driver\GeminiConfig; use EzPhp\Ai\Driver\GeminiEmbeddingDriver; use EzPhp\HttpClient\CurlTransport; use EzPhp\HttpClient\HttpClient; $driver = new GeminiEmbeddingDriver( new HttpClient(new CurlTransport()), new GeminiConfig(apiKey: $_ENV['GEMINI_API_KEY']), ); // Default model: text-embedding-004 $vector = $driver->embed('The quick brown fox');
| Driver | Default model | Endpoint |
|---|---|---|
OpenAiEmbeddingDriver |
text-embedding-3-small |
POST /v1/embeddings |
GeminiEmbeddingDriver |
text-embedding-004 |
POST /v1beta/models/{model}:embedContent |
Response object
$response = Ai::complete($request); $response->content(); // string — generated text $response->finishReason(); // FinishReason enum: STOP, LENGTH, TOOL_CALL, CONTENT_FILTER, ERROR $response->usage(); // TokenUsage $response->toolCalls(); // list<ToolCall> — non-empty when finishReason === TOOL_CALL $response->hasToolCalls(); // bool $response->rawBody(); // string — raw JSON from the provider $usage = $response->usage(); $usage->inputTokens(); // int $usage->outputTokens(); // int $usage->totalTokens(); // int
Logging decorator
Wrap any driver to log every request and response via error_log:
// config/ai.php return [ 'driver' => 'log', 'log' => ['inner_driver' => 'openai'], 'openai' => ['api_key' => env('OPENAI_API_KEY')], ];
Or construct LogDriver manually with a custom logger closure:
use EzPhp\Ai\Driver\LogDriver; $driver = new LogDriver( $innerDriver, function (string $level, string $message, array $context): void { $this->logger->log($level, $message, $context); }, );
Retry decorator
Wrap any client to retry transient failures (HTTP 429 rate limits and 5xx server
errors) with exponential backoff. Non-retryable 4xx errors (other than 429) are
re-thrown immediately. On a 429, a retry_after field in the response body
overrides the computed backoff delay when present.
use EzPhp\Ai\RetryAiClient; $client = new RetryAiClient( inner: $innerClient, maxAttempts: 3, // total attempts including the first; minimum 1 baseDelayMs: 500, // exponential backoff base ); $response = $client->complete($request);
complete() is the only method — RetryAiClient implements AiClientInterface
directly, so it composes with any other decorator (e.g. LogDriver) or the Ai
facade the same way an undecorated driver does.
AI variant pool
AiVariantPool is a database-backed pool of AI-generated text variants for a
given cache key — useful for content that should vary between requests (e.g.
flavor text, item descriptions) without calling the AI provider on every read.
Variants are stored in a flat table (cache_key, content, created_at); the
table must exist before use (see the class docblock for the recommended schema).
use EzPhp\Ai\AiVariantPool; $pool = new AiVariantPool($client, $db, table: 'ai_variants'); // Returns a random stored variant; generates and persists one on a cache miss $description = $pool->getVariant('sword_description', $request); // Pre-generate more variants ahead of time (5 by default) $pool->generateBatch('sword_description', $request, count: 10); // Check how many variants exist for a key $count = $pool->countVariants('sword_description');
OpenAI-compatible proxies and Azure
OpenAiDriver and MistralDriver accept a base_url config key, making them compatible with Azure OpenAI and any OpenAI-compatible proxy:
// config/ai.php — Azure OpenAI 'openai' => [ 'api_key' => env('AZURE_OPENAI_API_KEY'), 'model' => 'gpt-4o', 'base_url' => env('AZURE_OPENAI_ENDPOINT'), // e.g. https://my-resource.openai.azure.com ],
Testing
In unit tests, inject NullDriver or use FakeTransport from ez-php/http-client:
use EzPhp\Ai\Driver\NullDriver; use EzPhp\Ai\Request\AiRequest; $driver = NullDriver::withContent('Paris'); $response = $driver->complete(AiRequest::make('What is the capital of France?')); assertEquals('Paris', $response->content());
use EzPhp\Ai\Driver\OpenAiDriver; use EzPhp\Ai\Driver\OpenAiConfig; use EzPhp\Ai\Request\AiRequest; use EzPhp\HttpClient\FakeTransport; use EzPhp\HttpClient\HttpClient; $fake = new FakeTransport(); $fake->queue(200, '{"choices":[{"message":{"role":"assistant","content":"Paris"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}'); $driver = new OpenAiDriver( new HttpClient($fake), new OpenAiConfig('test-key'), ); $response = $driver->complete(AiRequest::make('Capital of France?')); assertEquals('Paris', $response->content());
Use Ai::resetClient() in tearDown() when tests touch the static facade to prevent state leaking between test cases.
Quality suite
# Inside Docker docker compose exec app composer full # Individual steps docker compose exec app composer analyse # PHPStan level 9 docker compose exec app composer cs # php-cs-fixer docker compose exec app composer test # PHPUnit
Start the development shell:
./start.sh