204 lines
5.4 KiB
PHP
204 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure;
|
|
|
|
use App\Entity\ModelGenerationConfig;
|
|
use App\Service\ModelGenerationConfigProvider;
|
|
use Generator;
|
|
use JsonException;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class OllamaClient
|
|
{
|
|
private ?ModelGenerationConfig $cachedConfig = null;
|
|
|
|
public function __construct(
|
|
private string $apiUrl,
|
|
private string $model,
|
|
private int $timeoutSeconds,
|
|
private ModelGenerationConfigProvider $configProvider
|
|
) {}
|
|
|
|
/**
|
|
* Public Streaming API
|
|
*/
|
|
public function stream(string $prompt): Generator
|
|
{
|
|
$config = $this->getConfig();
|
|
|
|
if ($config->isStream()) {
|
|
yield from $this->streamInternal($prompt);
|
|
return;
|
|
}
|
|
|
|
// Fallback: Blocking generate → Generator-kompatibel ausgeben
|
|
yield $this->generateInternal($prompt);
|
|
}
|
|
|
|
/**
|
|
* Public Blocking API
|
|
*/
|
|
public function generate(string $prompt): string
|
|
{
|
|
return $this->generateInternal($prompt);
|
|
}
|
|
|
|
/**
|
|
* Internal streaming transport
|
|
*/
|
|
private function streamInternal(string $prompt): Generator
|
|
{
|
|
$payload = $this->buildPayload($prompt, true);
|
|
|
|
$buffer = '';
|
|
$done = false;
|
|
|
|
$ch = curl_init($this->apiUrl);
|
|
if ($ch === false) {
|
|
throw new RuntimeException('Failed to initialize cURL');
|
|
}
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_POSTFIELDS => $payload,
|
|
CURLOPT_RETURNTRANSFER => false,
|
|
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
|
CURLOPT_WRITEFUNCTION => function ($curl, string $data) use (&$buffer): int {
|
|
$buffer .= $data;
|
|
return strlen($data);
|
|
},
|
|
]);
|
|
|
|
$mh = curl_multi_init();
|
|
if ($mh === false) {
|
|
curl_close($ch);
|
|
throw new RuntimeException('Failed to initialize cURL multi handle');
|
|
}
|
|
|
|
curl_multi_add_handle($mh, $ch);
|
|
|
|
try {
|
|
do {
|
|
do {
|
|
$status = curl_multi_exec($mh, $running);
|
|
} while ($status === CURLM_CALL_MULTI_PERFORM);
|
|
|
|
while (($pos = strpos($buffer, "\n")) !== false) {
|
|
$line = trim(substr($buffer, 0, $pos));
|
|
$buffer = substr($buffer, $pos + 1);
|
|
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$json = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
|
|
} catch (Throwable) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($json['response'])) {
|
|
yield $json['response'];
|
|
}
|
|
|
|
if (!empty($json['done'])) {
|
|
$done = true;
|
|
}
|
|
}
|
|
|
|
if ($running) {
|
|
curl_multi_select($mh, 0.2);
|
|
}
|
|
|
|
} while ($running && !$done);
|
|
|
|
if (curl_errno($ch)) {
|
|
throw new RuntimeException('LLM connection error: ' . curl_error($ch));
|
|
}
|
|
|
|
} finally {
|
|
curl_multi_remove_handle($mh, $ch);
|
|
curl_multi_close($mh);
|
|
curl_close($ch);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal blocking transport
|
|
*/
|
|
private function generateInternal(string $prompt): string
|
|
{
|
|
$payload = $this->buildPayload($prompt, false);
|
|
|
|
$ch = curl_init($this->apiUrl);
|
|
if ($ch === false) {
|
|
throw new RuntimeException('Failed to initialize cURL');
|
|
}
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_POSTFIELDS => $payload,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
|
|
if ($response === false) {
|
|
throw new RuntimeException('LLM error: ' . curl_error($ch));
|
|
}
|
|
|
|
curl_close($ch);
|
|
|
|
$json = json_decode($response, true, flags: JSON_THROW_ON_ERROR);
|
|
|
|
return $json['response'] ?? '';
|
|
}
|
|
|
|
/**
|
|
* Central Payload Builder (DRY)
|
|
*/
|
|
private function buildPayload(string $prompt, bool $stream): string
|
|
{
|
|
return json_encode([
|
|
'model' => $this->model,
|
|
'prompt' => $prompt,
|
|
'stream' => $stream,
|
|
'options' => $this->buildOptions()
|
|
], JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/**
|
|
* Central Options Builder (DRY)
|
|
*/
|
|
private function buildOptions(): array
|
|
{
|
|
$config = $this->getConfig();
|
|
|
|
return [
|
|
'temperature' => $config->getTemperature(),
|
|
'top_k' => $config->getTopK(),
|
|
'top_p' => $config->getTopP(),
|
|
'repeat_penalty' => $config->getRepeatPenalty(),
|
|
'num_ctx' => $config->getNumCtx(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Config caching per request
|
|
*/
|
|
private function getConfig(): ModelGenerationConfig
|
|
{
|
|
if ($this->cachedConfig === null) {
|
|
$this->cachedConfig = $this->configProvider->getActiveForModel($this->model);
|
|
}
|
|
|
|
return $this->cachedConfig;
|
|
}
|
|
}
|