symfony/ai-ollama-platform
Ollama platform bridge for Symfony AI, connecting your applications to locally hosted Ollama models for chat, embeddings, and NDJSON streaming through the Symfony AI component ecosystem.
Install the Package:
composer require symfony/ai-ollama-platform
Note: Requires PHP 8.1+ and Symfony’s HTTP Client (install via symfony/http-client if missing).
Basic Chat Example:
use Symfony\Component\AI\Ollama\OllamaClient;
use Symfony\Component\HttpClient\HttpClient;
$client = new OllamaClient(HttpClient::create());
$response = $client->chat('llama3', 'Explain Laravel to a 5-year-old');
echo $response->getContent();
Laravel Integration (HTTP Facade):
use Illuminate\Support\Facades\Http;
use Symfony\Component\AI\Ollama\OllamaClient;
// Register a macro to adapt Laravel's HTTP to Symfony's client
Http::macro('createClient', fn() => HttpClient::create());
$ollama = new OllamaClient(Http::createClient());
$result = $ollama->chat('mistral', 'Write a poem about Laravel');
First Use Case:
$stream = $ollama->chatStream('llama3', 'Tell me a joke');
foreach ($stream as $chunk) {
echo $chunk->getContent(); // NDJSON chunk
}
$embedding = $ollama->embed('nomic-embed-text', 'Laravel is awesome');
$response = $ollama->chat('llama3', 'Summarize this ticket');
$usage = $response->getTokenUsage();
$usage->getModel(); // NEW in 0.14: "llama3" — the model that produced these tokens
$usage->getPromptTokens();
$usage->getCompletionTokens();
chat, embed, and streaming).Provider.TokenUsage — as of v0.14.0 it carries the originating model name, which is what you need to price a request. This is the first place to look when building billing or quota features.OllamaClient and HttpClient integration—avoid Symfony-specific components (e.g., Messenger) unless needed.$response = $ollama->chat('llama3', 'Summarize this: ' . $longText);
$content = $response->getContent(); // JSON string
$data = json_decode($content, true);
$stream = $ollama->chatStream('llama3', 'Explain Laravel');
foreach ($stream as $chunk) {
$delta = $chunk->getDelta(); // DeltaInterface
echo $delta->getContent(); // Incremental text
}
$stream->onEach(function ($chunk) {
broadcast(new OllamaChunk($chunk->getDelta()->getContent()));
});
$embedding = $ollama->embed('nomic-embed-text', 'Laravel vs Symfony');
$vector = $embedding->getEmbedding(); // Normalized array
// Store in vector DB (e.g., Qdrant)
$response = $ollama->chat(
'llama3',
'Extract {name, age} from: "John Doe, 30"',
['structured_output' => true]
);
$data = json_decode($response->getContent(), true);
// $data = ['name' => 'John Doe', 'age' => 30]
class OllamaModelProvider implements ProviderInterface {
public function getModel(string $name): string {
return match ($name) {
'poetry' => 'mistral',
'math' => 'llama3',
default => $name,
};
}
}
OllamaClient:
$client = new OllamaClient(HttpClient::create(), new OllamaModelProvider());
TokenUsage::getModel() (v0.14.0+)The new model property on TokenUsage means you no longer have to thread the requested model name through your own code to price a call — the usage object tells you which model it belongs to. This matters most when a Provider remaps a friendly alias (poetry) to a real model (mistral): you now price the actual model.
// config/ollama.php
return [
'endpoint' => env('OLLAMA_ENDPOINT', 'http://localhost:11434'),
'pricing' => [ // per 1K tokens, in cents
'llama3' => ['prompt' => 0.0, 'completion' => 0.0],
'mistral' => ['prompt' => 0.02, 'completion' => 0.06],
'nomic-embed-text' => ['prompt' => 0.01, 'completion' => 0.0],
],
];
class OllamaCostCalculator
{
public function cost(TokenUsage $usage): float
{
$model = $usage->getModel() ?? 'unknown';
$prices = config("ollama.pricing.{$model}", ['prompt' => 0, 'completion' => 0]);
return ($usage->getPromptTokens() / 1000) * $prices['prompt']
+ ($usage->getCompletionTokens() / 1000) * $prices['completion'];
}
}
AiUsage::create([
'user_id' => $user->id,
'model' => $usage->getModel(), // 0.14.0
'prompt_tokens' => $usage->getPromptTokens(),
'completion_tokens' => $usage->getCompletionTokens(),
'cost_cents' => app(OllamaCostCalculator::class)->cost($usage),
]);
if ($user->ai_spend_cents >= $user->ai_budget_cents) {
abort(429, 'AI budget exhausted');
}
$response = $ollama->chat('gemma:2b', 'Transcribe this audio: [base64]');
OllamaClient as a singleton:
$app->singleton(OllamaClient::class, fn($app) =>
new OllamaClient($app->make(HttpClient::class))
);
try-catch for OllamaException:
try {
$response = $ollama->chat('invalid-model', '...');
} catch (OllamaException $e) {
Log::error('Ollama failed: ' . $e->getMessage());
}
// config/ollama.php
return [
'endpoint' => env('OLLAMA_ENDPOINT', 'http://localhost:11434'),
];
Then inject into OllamaClient:
$client = new OllamaClient(
HttpClient::create(['base_uri' => config('ollama.endpoint')])
);
getTokenUsage() at every call site, wrap OllamaClient in a thin decorator that records usage + cost once. When you later add a second provider, the decorator already handles multi-model accounting.model + token counts, and derive cost at query time if your prices change. Storing only cost_cents means a price correction requires a backfill.Symfony Abstraction Leakage:
Messenger, AI traits) in Laravel. Stick to OllamaClient and HttpClient.ProviderInterface).Streaming in Laravel:
$stream->onEach(function ($chunk) {
OllamaChunkJob::dispatch($chunk->getDelta()->getContent());
});
Model Catalog Management:
OllamaApiCatalog to list models. Cache results in Laravel’s cache or database to avoid repeated API calls:
$catalog = $ollama->getCatalog();
Cache::put('ollama_models', $catalog, now()->addHours(1));
Audio/Data Payloads:
$audio = base64_encode(file_get_contents('audio.wav'));
$response = $ollama->chat('gemma:2b', "Transcribe: $audio");
PHP Version Conflicts:
composer.json enforces:
"config": {
"platform": {
"php": "8.1"
}
}
TokenUsage::getModel() is new in 0.14.0 — and not guaranteed non-null:
getModel() does not exist. Pin your constraint (^0.14) before shipping cost-tracking code, otherwise you'll hit a BadMethodCallException/undefined-method error only on the billing path.?? $requestedModel) so a missing/blank model doesn't produce $0.00 costs silently.llama3:8b, llama3:latest). Normalise (strip the tag) before looking up a price, or your price table lookup will miss and fall through to your zero-cost default.Enable HTTP Logging:
$client = new OllamaClient(
HttpClient::create(['headers' => ['Accept' => 'application/json']])
);
// Add middleware for debugging
$client->getHttpClient()->addSubscriber(new \Symfony\Component\HttpClient\EventListener\DebugListener());
Validate NDJSON Streaming:
{"content":"Hello"} // Missing newline between chunks
\n).Ollama Server Issues:
curl http://localhost:11434/api/tags
journalctl -u ollama -f
Structured Output Parsing:
structured_output:
$response = $ollama->chat('llama3', '...', ['structured_output' => true]);
$data = json_decode($response->getContent(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid structured output');
}
Auditing token accounting:
TokenUsage (model, prompt, completion) alongside your computed cost for the first few weeks. If your cost column and the raw tokens disagree with expectations, the bug is almost always in the price table key, not the SDK.prompt + completion in a tinker session over a day's rows and compare to your quota enforcement counter — drift means a call path is bypassing your decorator.ProviderInterface for dynamic model routing:
class CustomProvider implements ProviderInterface {
public function getModel(string $name): string {
// Logic to map user-friendly names to Ollama models
}
}
OllamaClient to capture TokenUsage (including the new model property) uniformly — the cleanest place to add cost tracking, quotas, and per-tenant metrics without touching call sites.How can I help you explore Laravel packages today?