508 lines
17 KiB
PHP
508 lines
17 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|