<?php
declare(strict_types=1);
namespace Infrastructure\AI;
use Infrastructure\Config\CredentialService;
use RuntimeException;
/**
* Service for interacting with Ollama API for embeddings and text generation.
*
* Provides methods to:
* - Generate embeddings from text using models like mxbai-embed-large
* - Generate text responses using LLMs like gemma3:4b-it-qat
* - Check API availability and health status
*
* This service uses cURL for HTTP requests without external dependencies.
* All methods include proper timeout handling and exception management.
*
* @package Infrastructure\AI
* @author System Generated
* @version 1.0.0
*/
final class OllamaService
{
/**
* Default timeout for HTTP requests in seconds.
*/
private const int DEFAULT_TIMEOUT = 60;
/**
* Health check timeout in seconds.
*/
private const int HEALTH_CHECK_TIMEOUT = 5;
private string $host;
/**
* Constructs a new OllamaService instance.
*
* @param string|null $host The Ollama API host URL (uses env OLLAMA_HOST if null)
*/
public function __construct(?string $host = null)
{
$this->host = $host ?? CredentialService::getOllamaHost();
}
/**
* Generates an embedding vector for the given text.
*
* Uses the specified model to convert text into a numerical vector representation.
* The default model mxbai-embed-large produces 1024-dimensional embeddings.
*
* @param string $text The text to embed
* @param string $model The embedding model to use (default: mxbai-embed-large)
*
* @return array<int, float> The embedding vector as an array of floats
*
* @throws RuntimeException If the API request fails or returns invalid data
*
* @example
* $service = new OllamaService();
* $embedding = $service->getEmbedding('Hello world');
* // Returns: [0.123, -0.456, 0.789, ...] (1024 dimensions)
*/
public function getEmbedding(string $text, string $model = 'mxbai-embed-large'): array
{
$url = $this->host . '/api/embeddings';
$payload = [
'model' => $model,
'prompt' => $text,
];
$response = $this->makeRequest($url, $payload, self::DEFAULT_TIMEOUT);
if (!isset($response['embedding']) || !is_array($response['embedding'])) {
throw new RuntimeException('Invalid embedding response from Ollama API');
}
return array_map(
static fn (mixed $value): float => (float) $value,
$response['embedding']
);
}
/**
* Generates text using the specified LLM model.
*
* Sends a prompt to the Ollama API and receives generated text in response.
* This method does not stream responses - it waits for the complete response.
*
* @param string $prompt The prompt to generate text from
* @param string $model The LLM model to use (default: gemma3:4b-it-qat)
* @param float $temperature Sampling temperature 0.0-1.0 (default: 0.7)
*
* @return string The generated text response
*
* @throws RuntimeException If the API request fails or returns invalid data
*
* @example
* $service = new OllamaService();
* $response = $service->generate('Explain quantum computing in simple terms.', 'gemma3:4b-it-qat', 0.7);
* // Returns: "Quantum computing is a type of computing that..."
*/
public function generate(string $prompt, string $model = 'gemma3:4b-it-qat', float $temperature = 0.7): string
{
$url = $this->host . '/api/generate';
$payload = [
'model' => $model,
'prompt' => $prompt,
'stream' => false,
'options' => [
'temperature' => max(0.0, min(1.0, $temperature)),
],
];
$response = $this->makeRequest($url, $payload, 120);
if (!isset($response['response'])) {
throw new RuntimeException('Invalid generate response from Ollama API');
}
return (string) $response['response'];
}
/**
* Checks if the Ollama API is available and responding.
*
* Attempts to fetch the list of available models as a health check.
* This is a quick operation with a short timeout.
*
* @return bool True if the API is available, false otherwise
*
* @example
* $service = new OllamaService();
* if ($service->isAvailable()) {
* echo "Ollama is running";
* } else {
* echo "Ollama is not available";
* }
*/
public function isAvailable(): bool
{
$url = $this->host . '/api/tags';
try {
$ch = curl_init($url);
if ($ch === false) {
return false;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::HEALTH_CHECK_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => self::HEALTH_CHECK_TIMEOUT,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $result !== false && $httpCode === 200;
} catch (\Throwable) {
return false;
}
}
/**
* Makes an HTTP POST request to the Ollama API.
*
* Internal helper method that handles cURL initialization, request execution,
* error handling, and response parsing.
*
* @param string $url The API endpoint URL
* @param array<string, mixed> $payload The JSON payload to send
* @param int $timeout The request timeout in seconds
*
* @return array<string, mixed> The decoded JSON response
*
* @throws RuntimeException If the request fails or returns an error
*/
private function makeRequest(string $url, array $payload, int $timeout): array
{
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Failed to initialize cURL');
}
$jsonPayload = json_encode($payload);
if ($jsonPayload === false) {
curl_close($ch);
throw new RuntimeException('Failed to encode JSON payload');
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Content-Length: ' . strlen($jsonPayload),
],
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($result === false) {
throw new RuntimeException(
sprintf('cURL request failed: %s', $curlError !== '' ? $curlError : 'Unknown error')
);
}
if ($httpCode !== 200) {
throw new RuntimeException(
sprintf('Ollama API returned HTTP %d: %s', $httpCode, $result)
);
}
$decoded = json_decode((string) $result, true);
if (!is_array($decoded)) {
throw new RuntimeException('Failed to decode JSON response from Ollama API');
}
return $decoded;
}
}