Save workspace changes
This commit is contained in:
507
app/Services/AiBiography/VisionLlmClient.php
Normal file
507
app/Services/AiBiography/VisionLlmClient.php
Normal file
@@ -0,0 +1,507 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user