Files
SkinbaseNova/app/Services/AiBiography/VisionLlmClient.php
2026-04-18 17:02:56 +02:00

508 lines
17 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 App\Services\AiBiography;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client for the Skinbase Vision LLM gateway.
*
* Uses the existing Vision gateway infrastructure (VISION_GATEWAY_URL / VISION_GATEWAY_API_KEY).
* Prefers /ai/chat; falls back to /v1/chat/completions if configured.
*
* Error codes handled:
* 401 invalid API key
* 413 oversized request
* 422 invalid payload
* 503 LLM unavailable
* 504 timeout / upstream
*/
final class VisionLlmClient
{
public function isConfigured(): bool
{
return match ($this->provider()) {
'together' => $this->togetherApiKey() !== '' && $this->togetherModel() !== '',
'gemini' => $this->geminiBaseUrl() !== '' && $this->geminiApiKey() !== '' && $this->geminiModel() !== '',
'home' => $this->homeBaseUrl() !== '' && $this->homeModel() !== '',
default => $this->baseUrl() !== '' && $this->apiKey() !== '',
};
}
public function configuredModel(): string
{
return match ($this->provider()) {
'together' => $this->togetherModel(),
'gemini' => $this->geminiModel(),
'home' => $this->homeModel(),
default => trim((string) config('ai_biography.llm_model', 'vision-gateway')),
};
}
/**
* Send a chat completion payload to the Vision gateway.
*
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return string The generated text content.
*
* @throws VisionLlmException On structured gateway failure.
*/
public function chat(array $payload): string
{
if (! $this->isConfigured()) {
throw new VisionLlmException(
match ($this->provider()) { 'together' => 'Together.ai is not configured. Set TOGETHER_API_KEY and optionally AI_BIOGRAPHY_TOGETHER_MODEL.', 'gemini' => 'Gemini API is not configured. Set GEMINI_API_KEY and optionally AI_BIOGRAPHY_GEMINI_MODEL.',
'home' => 'Home LM Studio is not configured. Set AI_BIOGRAPHY_HOME_BASE_URL and AI_BIOGRAPHY_HOME_MODEL.',
default => 'Vision LLM gateway is not configured. Set VISION_GATEWAY_URL and VISION_GATEWAY_API_KEY.',
},
0
);
}
return match ($this->provider()) {
'together' => $this->chatWithTogether($payload),
'gemini' => $this->chatWithGemini($payload),
'home' => $this->chatWithHome($payload),
default => $this->chatWithVisionGateway($payload),
};
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithTogether(array $payload): string
{
$response = $this->sendTogetherRequest($this->togetherEndpoint(), $this->toTogetherPayload($payload));
$this->assertSuccessful($response, 'together');
return $this->extractContent($response);
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithVisionGateway(array $payload): string
{
$endpoint = $this->primaryEndpoint();
$response = $this->sendRequest($endpoint, $payload);
// If primary endpoint returned a 404, fall back to the OpenAI-compatible path.
if ($response->status() === 404) {
$fallbackEndpoint = $this->fallbackEndpoint();
if ($fallbackEndpoint !== $endpoint) {
Log::debug('VisionLlmClient: primary endpoint returned 404, trying fallback', [
'primary' => $endpoint,
'fallback' => $fallbackEndpoint,
]);
$response = $this->sendRequest($fallbackEndpoint, $payload);
}
}
$this->assertSuccessful($response, 'vision_gateway');
return $this->extractContent($response);
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithGemini(array $payload): string
{
$response = $this->sendGeminiRequest($this->geminiEndpoint(), $this->toGeminiPayload($payload));
$this->assertSuccessful($response, 'gemini');
return $this->extractGeminiContent($response);
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithHome(array $payload): string
{
$response = $this->sendHomeRequest($this->homeEndpoint(), $this->toHomePayload($payload));
$this->assertSuccessful($response, 'home');
return $this->extractContent($response);
}
// -------------------------------------------------------------------------
private function sendRequest(string $url, array $payload): Response
{
try {
return $this->buildRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Vision LLM gateway connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function sendTogetherRequest(string $url, array $payload): Response
{
try {
return $this->buildTogetherRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Together.ai connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function sendGeminiRequest(string $url, array $payload): Response
{
try {
return $this->buildGeminiRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Gemini API connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function sendHomeRequest(string $url, array $payload): Response
{
try {
return $this->buildHomeRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Home LM Studio connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function assertSuccessful(Response $response, string $provider): void
{
if ($response->successful()) {
return;
}
$status = $response->status();
$body = mb_substr(trim($response->body()), 0, 300);
$label = match ($provider) {
'together' => 'Together.ai',
'gemini' => 'Gemini API',
'home' => 'Home LM Studio',
default => 'Vision LLM gateway',
};
$message = match ($status) {
401, 403 => "{$label}: invalid or unauthorized API key ({$status}).",
413 => "{$label}: request payload too large (413).",
422 => "{$label}: invalid payload (422). {$body}",
429 => "{$label}: rate limit or quota exceeded (429).",
503 => "{$label}: LLM service unavailable (503).",
504 => "{$label}: upstream timeout (504).",
default => "{$label}: unexpected HTTP {$status}. {$body}",
};
Log::warning('VisionLlmClient: gateway error', [
'provider' => $provider,
'status' => $status,
'excerpt' => $body,
]);
throw new VisionLlmException($message, $status);
}
private function extractContent(Response $response): string
{
$json = $response->json();
// Standard OpenAI-compatible shape: choices[0].message.content
if (isset($json['choices'][0]['message']['content'])) {
return trim((string) $json['choices'][0]['message']['content']);
}
// Simple shape: { "content": "..." }
if (isset($json['content']) && is_string($json['content'])) {
return trim($json['content']);
}
// Shape: { "text": "..." }
if (isset($json['text']) && is_string($json['text'])) {
return trim($json['text']);
}
throw new VisionLlmException(
'Vision LLM gateway: unrecognized response shape. Could not extract generated text.',
0
);
}
private function extractGeminiContent(Response $response): string
{
$json = $response->json();
$parts = $json['candidates'][0]['content']['parts'] ?? null;
if (! is_array($parts) || $parts === []) {
throw new VisionLlmException(
'Gemini API: unrecognized response shape. Could not extract generated text.',
0
);
}
$text = collect($parts)
->map(fn ($part) => is_array($part) ? trim((string) ($part['text'] ?? '')) : '')
->filter(fn (string $value): bool => $value !== '')
->implode("\n");
if ($text === '') {
throw new VisionLlmException(
'Gemini API: response did not contain text content.',
0
);
}
return $text;
}
private function buildRequest(): PendingRequest
{
return Http::acceptJson()
->contentType('application/json')
->withHeaders(['X-API-Key' => $this->apiKey()])
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
}
private function buildTogetherRequest(): PendingRequest
{
return Http::acceptJson()
->contentType('application/json')
->withToken($this->togetherApiKey())
->connectTimeout(max(1, (int) config('ai_biography.together.connect_timeout_seconds', 5)))
->timeout(max(5, (int) config('ai_biography.together.timeout_seconds', config('ai_biography.llm_timeout_seconds', 90))));
}
private function buildGeminiRequest(): PendingRequest
{
return Http::acceptJson()
->contentType('application/json')
->withHeaders(['X-goog-api-key' => $this->geminiApiKey()])
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
}
private function buildHomeRequest(): PendingRequest
{
$request = Http::acceptJson()
->contentType('application/json')
->connectTimeout(max(1, (int) config('ai_biography.home.connect_timeout_seconds', 3)))
->timeout(max(5, (int) config('ai_biography.home.timeout_seconds', config('ai_biography.llm_timeout_seconds', 30))));
if (! (bool) config('ai_biography.home.verify_ssl', true)) {
$request = $request->withoutVerifying();
}
if ($this->homeApiKey() !== '') {
$request = $request->withToken($this->homeApiKey());
}
return $request;
}
private function baseUrl(): string
{
return rtrim((string) config('vision.gateway.base_url', ''), '/');
}
private function apiKey(): string
{
return trim((string) config('vision.gateway.api_key', ''));
}
private function primaryEndpoint(): string
{
$path = ltrim((string) config('ai_biography.llm_endpoint', '/ai/chat'), '/');
return $this->baseUrl() . '/' . $path;
}
private function fallbackEndpoint(): string
{
$path = ltrim((string) config('ai_biography.llm_fallback_endpoint', '/v1/chat/completions'), '/');
return $this->baseUrl() . '/' . $path;
}
private function provider(): string
{
$override = trim(strtolower((string) config('ai_biography.provider_override', '')));
if (in_array($override, ['together', 'vision_gateway', 'gemini', 'home'], true)) {
return $override;
}
if ($this->togetherApiKey() !== '' && $this->togetherModel() !== '') {
return 'together';
}
$provider = trim(strtolower((string) config('ai_biography.provider', 'together')));
return in_array($provider, ['together', 'vision_gateway', 'gemini', 'home'], true) ? $provider : 'together';
}
private function geminiBaseUrl(): string
{
return rtrim((string) config('ai_biography.gemini.base_url', 'https://generativelanguage.googleapis.com'), '/');
}
private function geminiApiKey(): string
{
return trim((string) config('ai_biography.gemini.api_key', ''));
}
private function geminiModel(): string
{
return trim((string) config('ai_biography.gemini.model', 'gemini-flash-latest'));
}
private function geminiEndpoint(): string
{
return $this->geminiBaseUrl() . '/v1beta/models/' . rawurlencode($this->geminiModel()) . ':generateContent';
}
private function togetherApiKey(): string
{
return trim((string) config('ai_biography.together.api_key', ''));
}
private function togetherModel(): string
{
return trim((string) config('ai_biography.together.model', 'google/gemma-3n-E4B-it'));
}
private function togetherEndpoint(): string
{
$base = rtrim((string) config('ai_biography.together.base_url', 'https://api.together.xyz'), '/');
$path = ltrim((string) config('ai_biography.together.endpoint', '/v1/chat/completions'), '/');
return $base . '/' . $path;
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return array<string, mixed>
*/
private function toTogetherPayload(array $payload): array
{
return [
'model' => $this->togetherModel(),
'messages' => array_values((array) ($payload['messages'] ?? [])),
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
'temperature' => (float) ($payload['temperature'] ?? 0.3),
'stream' => false,
];
}
private function homeBaseUrl(): string
{
return rtrim((string) config('ai_biography.home.base_url', 'http://home.klevze.si:8200'), '/');
}
private function homeApiKey(): string
{
return trim((string) config('ai_biography.home.api_key', ''));
}
private function homeModel(): string
{
return trim((string) config('ai_biography.home.model', 'qwen/qwen3.5-9b'));
}
private function homeEndpoint(): string
{
$path = ltrim((string) config('ai_biography.home.endpoint', '/v1/chat/completions'), '/');
return $this->homeBaseUrl() . '/' . $path;
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return array<string, mixed>
*/
private function toGeminiPayload(array $payload): array
{
$systemParts = [];
$contents = [];
foreach ((array) ($payload['messages'] ?? []) as $message) {
$role = strtolower((string) ($message['role'] ?? 'user'));
$content = trim((string) ($message['content'] ?? ''));
if ($content === '') {
continue;
}
if ($role === 'system') {
$systemParts[] = ['text' => $content];
continue;
}
$contents[] = [
'role' => $role === 'assistant' ? 'model' : 'user',
'parts' => [
['text' => $content],
],
];
}
if ($contents === [] && $systemParts !== []) {
$contents[] = [
'role' => 'user',
'parts' => $systemParts,
];
$systemParts = [];
}
$geminiPayload = [
'contents' => $contents,
'generationConfig' => [
'temperature' => (float) ($payload['temperature'] ?? 0.3),
'maxOutputTokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
],
];
if ($systemParts !== []) {
$geminiPayload['systemInstruction'] = [
'parts' => $systemParts,
];
}
return $geminiPayload;
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return array<string, mixed>
*/
private function toHomePayload(array $payload): array
{
return [
'model' => $this->homeModel(),
'messages' => array_values((array) ($payload['messages'] ?? [])),
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
'temperature' => (float) ($payload['temperature'] ?? 0.3),
'stream' => false,
];
}
}