212 lines
6.9 KiB
PHP
212 lines
6.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Steuerkram\Services;
|
||
|
||
/**
|
||
* Einheitlicher LLM-Client für lokales Ollama und OpenAI-kompatible Remote-APIs.
|
||
*
|
||
* Provider-Erkennung (Reihenfolge):
|
||
* 1. OLLAMA_URL in Umgebungsvariablen → Ollama-Modus
|
||
* 2. LLM_API_URL + LLM_API_KEY in Umgebungsvariablen → OpenAI-kompatibler Modus
|
||
* 3. Auto-Discovery: HEAD-Request auf http://localhost:11434 → Ollama-Modus
|
||
* 4. Kein Provider gefunden → isAvailable() = false
|
||
*/
|
||
class LlmService
|
||
{
|
||
private ?string $mode = null; // 'ollama' | 'openai' | null
|
||
private string $url = '';
|
||
private string $model = '';
|
||
private string $visionModel = '';
|
||
private ?string $apiKey = null;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->detect();
|
||
}
|
||
|
||
public function isAvailable(): bool
|
||
{
|
||
return $this->mode !== null;
|
||
}
|
||
|
||
public function isVisionAvailable(): bool
|
||
{
|
||
return $this->mode === 'ollama';
|
||
}
|
||
|
||
public function getProviderLabel(): ?string
|
||
{
|
||
return match($this->mode) {
|
||
'ollama' => 'Ollama (' . $this->url . ')',
|
||
'openai' => 'Remote-API (' . $this->url . ')',
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
public function generate(string $prompt): string
|
||
{
|
||
return match($this->mode) {
|
||
'ollama' => $this->callOllama($prompt),
|
||
'openai' => $this->callOpenAi($prompt),
|
||
default => '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Sendet Bilder (base64-kodiert) zusammen mit einem Prompt an das Vision-Modell.
|
||
* Nur im Ollama-Modus verfügbar. Gibt '' zurück wenn Vision nicht verfügbar.
|
||
*
|
||
* @param string $prompt
|
||
* @param string[] $base64Images Array von base64-kodierten Bildern (JPEG/PNG)
|
||
*/
|
||
public function generateVision(string $prompt, array $base64Images): string
|
||
{
|
||
if ($this->mode !== 'ollama' || empty($base64Images)) return '';
|
||
|
||
$payload = [
|
||
'model' => $this->visionModel,
|
||
'messages' => [[
|
||
'role' => 'user',
|
||
'content' => $prompt,
|
||
'images' => $base64Images,
|
||
]],
|
||
'stream' => false,
|
||
'options' => ['temperature' => 0.1],
|
||
];
|
||
|
||
$ch = curl_init($this->url . '/api/chat');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_POST => true,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 300,
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$err = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($err || !$response) return '';
|
||
$data = json_decode($response, true);
|
||
return $data['message']['content'] ?? '';
|
||
}
|
||
|
||
/**
|
||
* Prüft ohne Timeout-Kosten, ob der Provider überhaupt erreichbar ist.
|
||
* Nur für Diagnosezwecke (Einstellungsseite) – nicht im normalen Request-Pfad nutzen.
|
||
*/
|
||
public function isReachable(): bool
|
||
{
|
||
if ($this->mode === null) return false;
|
||
if ($this->mode === 'ollama') return $this->probeOllama($this->url);
|
||
return true; // OpenAI-Modus: kein schneller Probe verfügbar
|
||
}
|
||
|
||
private function detect(): void
|
||
{
|
||
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
||
$apiUrl = getenv('LLM_API_URL') ?: ($_ENV['LLM_API_URL'] ?? '');
|
||
$apiKey = getenv('LLM_API_KEY') ?: ($_ENV['LLM_API_KEY'] ?? '');
|
||
|
||
if ($ollamaUrl !== '') {
|
||
$this->mode = 'ollama';
|
||
$this->url = rtrim($ollamaUrl, '/');
|
||
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
|
||
$this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest');
|
||
return;
|
||
}
|
||
|
||
if ($apiUrl !== '' && $apiKey !== '') {
|
||
$this->mode = 'openai';
|
||
$this->url = rtrim($apiUrl, '/');
|
||
$this->model = getenv('LLM_MODEL') ?: ($_ENV['LLM_MODEL'] ?? 'gpt-4o-mini');
|
||
$this->apiKey = $apiKey;
|
||
return;
|
||
}
|
||
|
||
// Auto-Discovery: lokales Ollama auf Port 11434
|
||
if ($this->probeOllama('http://localhost:11434')) {
|
||
$this->mode = 'ollama';
|
||
$this->url = 'http://localhost:11434';
|
||
$this->model = 'llama3.2';
|
||
}
|
||
}
|
||
|
||
private function probeOllama(string $baseUrl): bool
|
||
{
|
||
$ch = curl_init($baseUrl . '/api/tags');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 2,
|
||
CURLOPT_CONNECTTIMEOUT => 2,
|
||
CURLOPT_NOBODY => false,
|
||
]);
|
||
curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$err = curl_errno($ch);
|
||
curl_close($ch);
|
||
return $err === 0 && $code >= 200 && $code < 300;
|
||
}
|
||
|
||
private function callOllama(string $prompt): string
|
||
{
|
||
$payload = [
|
||
'model' => $this->model,
|
||
'prompt' => $prompt,
|
||
'stream' => false,
|
||
];
|
||
|
||
$endpoint = $this->url . '/api/generate';
|
||
$ch = curl_init($endpoint);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_POST => true,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 300,
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$err = curl_error($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($err) {
|
||
error_log("[Steuerkram] LlmService::callOllama() cURL-Fehler auf {$endpoint}: {$err}");
|
||
return '';
|
||
}
|
||
if (!$response) {
|
||
error_log("[Steuerkram] LlmService::callOllama() leere Antwort von {$endpoint} (HTTP {$httpCode})");
|
||
return '';
|
||
}
|
||
return json_decode($response, true)['response'] ?? '';
|
||
}
|
||
|
||
private function callOpenAi(string $prompt): string
|
||
{
|
||
$payload = [
|
||
'model' => $this->model,
|
||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||
];
|
||
|
||
$ch = curl_init($this->url . '/chat/completions');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_POST => true,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 300,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'Authorization: Bearer ' . $this->apiKey,
|
||
],
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$err = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($err || !$response) return '';
|
||
$data = json_decode($response, true);
|
||
return $data['choices'][0]['message']['content'] ?? '';
|
||
}
|
||
}
|