Steuerkram/src/Services/LlmService.php

212 lines
6.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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'] ?? '';
}
}