Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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,
];
}
}