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, 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, 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, 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, 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, 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, max_tokens: int, temperature: float, stream: bool} $payload * @return array */ 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, max_tokens: int, temperature: float, stream: bool} $payload * @return array */ 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, max_tokens: int, temperature: float, stream: bool} $payload * @return array */ 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, ]; } }