|null */ private ?array $modelOptionsCache = null; public function __construct( IClientService $clientService, SettingsService $settingsService, LoggerInterface $logger, ?IConfig $config = null ) { $this->clientService = $clientService; $this->settingsService = $settingsService; $this->logger = $logger; $this->config = $config; } /** * Send a chat completion request to the LLM provider * * @param string $systemPrompt * @param array $messages Array of ['role' => 'user|assistant', 'content' => '...'] * @param array $options * @return array Response with 'content' key, optional 'tool_calls', and 'rate_limit_headers' * @throws \Exception */ public function sendChatCompletion(string $systemPrompt, array $messages, ?string $modelOverride = null, array $options = []): array { $settings = $this->settingsService->getSettings(); $fullMessages = $this->prepareMessages($systemPrompt, $messages); $modelConfig = $this->resolveModelConfig($settings, $modelOverride ?: $settings->getDefaultModel()); try { return $this->sendResolvedChatCompletion($fullMessages, $modelConfig, $settings, $options); } catch (\Exception $e) { $fallbackConfig = $this->isFallbackEligibleException($e) ? $this->tryResolveFallbackModelConfig($settings, $modelConfig, $e) : null; if ($fallbackConfig !== null) { $this->logger->warning('LLM API request failed, retrying with fallback model', [ 'exception' => $e, 'primary_model' => $modelConfig['id'], 'fallback_model' => $fallbackConfig['id'], ]); try { return $this->sendResolvedChatCompletion($fullMessages, $fallbackConfig, $settings, $options); } catch (\Exception $fallbackException) { $this->logger->error('LLM fallback request failed: ' . $fallbackException->getMessage(), [ 'exception' => $fallbackException, 'fallback_model' => $fallbackConfig['id'], ]); throw new \Exception(self::CHAT_FAILURE_MESSAGE, 0, $fallbackException); } } $this->logger->error('LLM API request failed: ' . $e->getMessage(), [ 'exception' => $e, 'model' => $modelConfig['id'], ]); throw new \Exception(self::CHAT_FAILURE_MESSAGE, 0, $e); } } /** * Send a streaming chat completion request * * @param string $systemPrompt * @param array $messages * @param callable $onChunk function(array $delta): void * @param string|null $modelOverride * @param array $options * @return array Final assembled response * @throws \Exception */ public function streamChatCompletion(string $systemPrompt, array $messages, callable $onChunk, ?string $modelOverride = null, array $options = []): array { $settings = $this->settingsService->getSettings(); $fullMessages = $this->prepareMessages($systemPrompt, $messages); $modelConfig = $this->resolveModelConfig($settings, $modelOverride ?: $settings->getDefaultModel()); $streamStarted = false; $trackedOnChunk = function (array $delta) use ($onChunk, &$streamStarted): void { if ($delta !== []) { $streamStarted = true; } $onChunk($delta); }; try { return $this->streamResolvedChatCompletion($fullMessages, $trackedOnChunk, $modelConfig, $settings, $options); } catch (\Exception $e) { if (!$streamStarted && $this->shouldRetryStreamingWithoutUsage($e, $options)) { try { return $this->streamResolvedChatCompletion( $fullMessages, $trackedOnChunk, $modelConfig, $settings, array_merge($options, ['_disable_stream_usage' => true]) ); } catch (\Exception $retryException) { $e = $retryException; } } $fallbackConfig = !$streamStarted && $this->isFallbackEligibleException($e) ? $this->tryResolveFallbackModelConfig($settings, $modelConfig, $e) : null; if ($fallbackConfig !== null) { $this->logger->warning('LLM streaming failed before first chunk, retrying with fallback model', [ 'exception' => $e, 'primary_model' => $modelConfig['id'], 'fallback_model' => $fallbackConfig['id'], ]); try { return $this->streamResolvedChatCompletion($fullMessages, $trackedOnChunk, $fallbackConfig, $settings, $options); } catch (\Exception $fallbackException) { $this->logger->error('LLM fallback streaming failed: ' . $fallbackException->getMessage(), [ 'exception' => $fallbackException, 'fallback_model' => $fallbackConfig['id'], ]); throw new \Exception(self::STREAM_FAILURE_MESSAGE, 0, $fallbackException); } } $this->logger->error('LLM Streaming failed: ' . $e->getMessage(), [ 'exception' => $e, 'model' => $modelConfig['id'], ]); throw new \Exception(self::STREAM_FAILURE_MESSAGE, 0, $e); } } /** * Build the provider JSON payload used for a chat completion request without sending it. * * @param array> $messages * @param array $options * @return array{endpoint:string,model_reference:string,payload:array} */ public function buildTraceChatCompletionPayload(string $systemPrompt, array $messages, ?string $modelOverride = null, array $options = [], bool $stream = false): array { $settings = $this->settingsService->getSettings(); $fullMessages = $this->prepareMessages($systemPrompt, $messages); $modelConfig = $this->resolveModelConfig($settings, $modelOverride ?: $settings->getDefaultModel()); return [ 'endpoint' => $modelConfig['endpoint_key'], 'model_reference' => $modelConfig['id'], 'payload' => $this->buildPayload($modelConfig['model'], $fullMessages, $options, $stream), ]; } /** * Helper to prepare messages array */ private function prepareMessages(string $systemPrompt, array $messages): array { $fullMessages = [ ['role' => 'system', 'content' => $systemPrompt] ]; $lastRole = 'system'; foreach ($messages as $msg) { $role = $msg['role'] ?? 'user'; $content = (string)($msg['content'] ?? ''); if ($role === 'tool') { $fullMessages[] = $msg; $lastRole = $role; continue; } if ($role === 'system') { $role = 'user'; } if ($lastRole === 'system' && $role === 'assistant') { $fullMessages[] = ['role' => 'user', 'content' => '']; $lastRole = 'user'; } if ($lastRole === $role && $lastRole !== 'tool') { $lastIndex = count($fullMessages) - 1; if ($lastIndex >= 0 && ($fullMessages[$lastIndex]['role'] ?? '') === $role) { $separator = $fullMessages[$lastIndex]['content'] !== '' && $content !== '' ? "\n\n" : ''; $fullMessages[$lastIndex]['content'] .= $separator . $content; continue; } } if ($role === 'assistant' && isset($msg['tool_calls'])) { $fullMessages[] = $msg; } else { $fullMessages[] = ['role' => $role, 'content' => $content]; } $lastRole = $role; } return $fullMessages; } /** * @return array{ * id: string, * endpoint_key: string, * endpoint: string, * api_key: string, * model: string * } */ private function resolveModelConfig($settings, ?string $modelReference): array { $reference = trim((string)($modelReference ?: $settings->getDefaultModel())); if ($reference === '') { throw new \Exception('Model is not configured'); } $endpointKey = 'primary'; $model = $reference; if (preg_match('/^(primary|secondary):(.+)$/', $reference, $matches) === 1) { $endpointKey = $matches[1]; $model = $matches[2]; } else { $endpointKey = $this->resolveEndpointKeyForUnprefixedModel($settings, $reference); } $model = trim($model); if ($model === '') { throw new \Exception('Model is not configured'); } if ($endpointKey === 'secondary') { $endpoint = trim((string)$settings->getSecondaryApiEndpoint()); if ($endpoint === '') { throw new \Exception('Secondary API endpoint is not configured'); } $apiKey = (string)($this->settingsService->getSecondaryApiKey() ?? ''); if ($apiKey === '') { throw new \Exception('Secondary API key is not configured'); } return [ 'id' => 'secondary:' . $model, 'endpoint_key' => 'secondary', 'endpoint' => $endpoint, 'api_key' => $apiKey, 'model' => $model, ]; } $apiKey = $this->settingsService->getApiKey(); if ($apiKey === '') { throw new \Exception('API key not configured'); } return [ 'id' => 'primary:' . $model, 'endpoint_key' => 'primary', 'endpoint' => $this->getEndpoint($settings->getApiProvider(), $settings->getApiEndpoint()), 'api_key' => $apiKey, 'model' => $model, ]; } private function resolveEndpointKeyForUnprefixedModel($settings, string $model): string { $model = trim($model); if ($model === '') { return 'primary'; } $primaryHasModel = false; $secondaryHasModel = false; try { $options = $this->getCachedModelOptions($settings); } catch (\Exception $e) { $this->logger->warning('LLM model lookup failed, defaulting unprefixed model to primary endpoint', [ 'exception' => $e, 'model' => $model, ]); return 'primary'; } foreach ($options as $option) { if (($option['model'] ?? '') !== $model) { continue; } if (($option['endpoint'] ?? 'primary') === 'secondary') { $secondaryHasModel = true; } else { $primaryHasModel = true; } } if ($primaryHasModel) { return 'primary'; } return $secondaryHasModel ? 'secondary' : 'primary'; } /** * @param array{id:string} $currentModelConfig * @return array{id:string,endpoint_key:string,endpoint:string,api_key:string,model:string}|null */ private function resolveFallbackModelConfig($settings, array $currentModelConfig): ?array { $fallbackModel = trim((string)$settings->getFallbackModel()); if ($fallbackModel === '') { return null; } $fallbackConfig = $this->resolveModelConfig($settings, $fallbackModel); if ($fallbackConfig['id'] === $currentModelConfig['id']) { return null; } return $fallbackConfig; } /** * @param array{id:string} $currentModelConfig * @return array{id:string,endpoint_key:string,endpoint:string,api_key:string,model:string}|null */ private function tryResolveFallbackModelConfig($settings, array $currentModelConfig, \Exception $originalException): ?array { try { return $this->resolveFallbackModelConfig($settings, $currentModelConfig); } catch (\Exception $fallbackConfigException) { $this->logger->warning('LLM fallback model is configured but could not be resolved', [ 'exception' => $fallbackConfigException, 'original_exception' => $originalException, 'primary_model' => $currentModelConfig['id'], ]); return null; } } /** * @param array> $fullMessages * @param array{id:string,endpoint_key:string,endpoint:string,api_key:string,model:string} $modelConfig * @param array $options * @return array */ private function sendResolvedChatCompletion(array $fullMessages, array $modelConfig, $settings, array $options): array { $client = $this->clientService->newClient(); $payload = $this->buildPayload($modelConfig['model'], $fullMessages, $options, false); $this->logger->debug('Sending chat completion request', [ 'model' => $modelConfig['id'], 'endpoint' => $modelConfig['endpoint_key'], 'message_count' => count($fullMessages), 'message_roles' => array_map(fn($m) => $m['role'] ?? 'unknown', $fullMessages), 'has_tools' => isset($payload['tools']) && count($payload['tools']) > 0, ]); $response = $client->post($modelConfig['endpoint'], [ 'headers' => [ 'Authorization' => 'Bearer ' . $modelConfig['api_key'], 'Content-Type' => 'application/json', ], 'json' => $payload, 'timeout' => $options['timeout'] ?? $this->settingsService->normalizePositiveInteger( $settings->getLlmChatTimeout(), SettingsService::DEFAULT_LLM_CHAT_TIMEOUT ), ]); $this->throwForHttpError($response); $body = json_decode($response->getBody(), true); $rateLimitHeaders = $this->extractRateLimitHeaders($response); if (isset($body['choices'][0]['message'])) { $message = $body['choices'][0]['message']; return [ 'content' => $message['content'] ?? '', 'model' => $body['model'] ?? $modelConfig['model'], 'model_reference' => $modelConfig['id'], 'model_endpoint' => $modelConfig['endpoint_key'], 'usage' => $body['usage'] ?? null, 'tool_calls' => $message['tool_calls'] ?? [], 'finish_reason' => $body['choices'][0]['finish_reason'] ?? null, 'raw' => $body, 'rate_limit_headers' => $rateLimitHeaders, ]; } throw new \Exception('Invalid response from LLM provider'); } /** * @param array> $fullMessages * @param callable $onChunk * @param array{id:string,endpoint_key:string,endpoint:string,api_key:string,model:string} $modelConfig * @param array $options * @return array */ private function streamResolvedChatCompletion(array $fullMessages, callable $onChunk, array $modelConfig, $settings, array $options): array { $client = $this->clientService->newClient(); $payload = $this->buildPayload($modelConfig['model'], $fullMessages, $options, true); $this->logger->debug('Starting streaming chat completion', [ 'model' => $modelConfig['id'], 'endpoint' => $modelConfig['endpoint_key'], 'message_count' => count($fullMessages), ]); $response = $client->post($modelConfig['endpoint'], [ 'headers' => [ 'Authorization' => 'Bearer ' . $modelConfig['api_key'], 'Content-Type' => 'application/json', ], 'json' => $payload, 'timeout' => $options['timeout'] ?? $this->settingsService->normalizePositiveInteger( $settings->getLlmStreamTimeout(), SettingsService::DEFAULT_LLM_STREAM_TIMEOUT ), 'stream' => true, ]); $this->throwForHttpError($response); $rateLimitHeaders = $this->extractRateLimitHeaders($response); $body = $response->getBody(); if (!is_resource($body)) { $stream = fopen('php://temp', 'r+'); fwrite($stream, (string)$body); rewind($stream); $body = $stream; } $finalContent = ''; $finalToolCalls = []; $usage = null; while (!feof($body)) { $line = fgets($body); if ($line === false) { break; } $line = trim($line); if ($line === '') { continue; } if (str_starts_with($line, 'data: ')) { $data = substr($line, 6); if ($data === '[DONE]') { break; } $chunk = json_decode($data, true); if (is_array($chunk)) { if (isset($chunk['usage']) && is_array($chunk['usage'])) { $usage = $chunk['usage']; } $delta = $chunk['choices'][0]['delta'] ?? []; $onChunk($delta); if (isset($delta['content'])) { $finalContent .= $delta['content']; } if (isset($delta['tool_calls'])) { foreach ($delta['tool_calls'] as $toolCallChunk) { $index = $toolCallChunk['index']; if (!isset($finalToolCalls[$index])) { $finalToolCalls[$index] = [ 'id' => $toolCallChunk['id'] ?? '', 'type' => 'function', 'function' => ['name' => '', 'arguments' => ''] ]; } if (isset($toolCallChunk['id'])) { $finalToolCalls[$index]['id'] = $toolCallChunk['id']; } if (isset($toolCallChunk['function']['name'])) { $finalToolCalls[$index]['function']['name'] .= $toolCallChunk['function']['name']; } if (isset($toolCallChunk['function']['arguments'])) { $finalToolCalls[$index]['function']['arguments'] .= $toolCallChunk['function']['arguments']; } } } } } } return [ 'content' => $finalContent, 'model' => $modelConfig['model'], 'model_reference' => $modelConfig['id'], 'model_endpoint' => $modelConfig['endpoint_key'], 'usage' => $usage, 'tool_calls' => array_values($finalToolCalls), 'finish_reason' => 'stop', 'rate_limit_headers' => $rateLimitHeaders, ]; } /** * @param array> $fullMessages * @param array $options * @return array */ private function buildPayload(string $model, array $fullMessages, array $options, bool $stream): array { $payload = [ 'model' => $model, 'messages' => $fullMessages, ]; $maxTokens = $options['max_tokens'] ?? 1000; // GPT-5 reasoning models reject custom temperature values and use the // newer max_completion_tokens parameter instead of max_tokens. if ($this->modelUsesReasoningParameters($model)) { $payload['max_completion_tokens'] = $maxTokens; } else { $payload['temperature'] = $options['temperature'] ?? 0.7; $payload['max_tokens'] = $maxTokens; } if ($stream) { $payload['stream'] = true; if (array_key_exists('stream_options', $options) && is_array($options['stream_options'])) { $payload['stream_options'] = $options['stream_options']; } elseif (empty($options['_disable_stream_usage'])) { $payload['stream_options'] = ['include_usage' => true]; } } if (isset($options['tools']) && is_array($options['tools']) && count($options['tools']) > 0) { $payload['tools'] = $options['tools']; } if (array_key_exists('tool_choice', $options) && $options['tool_choice'] !== null && $options['tool_choice'] !== 'auto') { $payload['tool_choice'] = $options['tool_choice']; } foreach (['presence_penalty', 'frequency_penalty', 'top_p'] as $opt) { if (isset($options[$opt])) { $payload[$opt] = $options[$opt]; } } // Anthropic/Claude rejects requests that set both temperature and top_p // ("`temperature` and `top_p` cannot both be specified"). OpenAI tolerates // it, so only drop top_p for Anthropic models and keep temperature. if (isset($payload['temperature'], $payload['top_p']) && preg_match('/claude|anthropic/i', $model) === 1) { unset($payload['top_p']); } return $this->sanitizePayloadForJson($payload); } private function modelUsesReasoningParameters(string $model): bool { return preg_match('/(?:^|\/)gpt-5(?:[.\-]|$)/i', trim($model)) === 1; } /** * @param array $payload * @return array */ private function sanitizePayloadForJson(array $payload): array { $sanitized = []; foreach ($payload as $key => $value) { $sanitizedKey = is_string($key) ? $this->ensureValidUtf8($key) : $key; $sanitized[$sanitizedKey] = $this->sanitizeValueForJson($value); } return $sanitized; } /** * @param mixed $value * @return mixed */ private function sanitizeValueForJson($value) { if (is_string($value)) { return $this->ensureValidUtf8($value); } if (is_array($value)) { $sanitized = []; foreach ($value as $key => $item) { $sanitizedKey = is_string($key) ? $this->ensureValidUtf8($key) : $key; $sanitized[$sanitizedKey] = $this->sanitizeValueForJson($item); } return $sanitized; } return $value; } private function ensureValidUtf8(string $text): string { if (mb_check_encoding($text, 'UTF-8')) { return $text; } return mb_convert_encoding($text, 'UTF-8', 'UTF-8'); } private function throwForHttpError($response): void { if (!method_exists($response, 'getStatusCode')) { return; } $statusCode = (int)$response->getStatusCode(); if ($statusCode >= 400) { throw new \Exception('LLM provider returned HTTP status ' . $statusCode, $statusCode); } } private function isFallbackEligibleException(\Exception $e): bool { $code = (int)$e->getCode(); $message = strtolower($e->getMessage()); // Transient / availability errors: the selected model or its deployment // is temporarily unavailable or overloaded, so retrying with the // configured fallback model is the right recovery. This mirrors the // OpenAI/LiteLLM "retryable" set (408, 409, 429, 5xx) plus a 404 // "model not found" (self-hosted model not loaded), plus network errors // and provider/LiteLLM availability markers below. if (in_array($code, [404, 408, 409, 429], true) || ($code >= 500 && $code < 600)) { return true; } foreach ([ // network / transport 'timeout', 'timed out', 'connection', 'connect', 'network', 'could not resolve', 'dns', 'ssl', 'curl error 6', 'curl error 7', 'curl error 28', // model / deployment availability 'not found', 'notfounderror', 'model_not_found', 'no deployments available', 'no healthy deployment', // temporarily overloaded / provider issues 'overloaded', 'service unavailable', 'temporarily unavailable', 'bad gateway', 'gateway timeout', 'try again in', ] as $needle) { if (str_contains($message, $needle)) { return true; } } // Everything else (400 invalid request / bad schema / unsupported param, // 401 auth, 403 model access, 422 validation, budget exceeded) is a // request or config problem a different model will not fix -> surface it // instead of masking it behind a silent fallback. return false; } /** * @param array $options */ private function shouldRetryStreamingWithoutUsage(\Exception $e, array $options): bool { if (array_key_exists('stream_options', $options) || !empty($options['_disable_stream_usage'])) { return false; } return in_array((int)$e->getCode(), [400, 422], true); } /** * Get API endpoint based on provider * * @param string $provider * @param ?string $customEndpoint * @return string */ private function getEndpoint(string $provider, ?string $customEndpoint): string { switch ($provider) { case 'openai': case 'gwdg': return self::GWDG_CHAT_COMPLETIONS_ENDPOINT; case 'azure': return $customEndpoint ?? ''; // Azure requires custom endpoint case 'custom': return $customEndpoint ?? ''; default: throw new \Exception('Unknown or unconfigured API provider: ' . $provider); } } /** * List available endpoint-specific model identifiers. * * @return array */ public function listModels(): array { return array_map( static fn (array $option): string => $option['id'], $this->listModelOptions() ); } /** * @return array */ public function listModelOptions(): array { $settings = $this->settingsService->getSettings(); $timeout = $this->settingsService->normalizePositiveInteger( $settings->getLlmModelsTimeout(), SettingsService::DEFAULT_LLM_MODELS_TIMEOUT ); $options = $this->fetchConfiguredModelOptions($settings, $timeout); $this->storeModelOptionsCache($settings, $options); return $options; } /** * @return array */ private function fetchConfiguredModelOptions($settings, int $timeout): array { $options = []; $options = array_merge( $options, $this->fetchModelOptionsForEndpoint( 'primary', 'Primary', $this->getModelsEndpoint($settings->getApiProvider(), $settings->getApiEndpoint()), $this->settingsService->getApiKey(), $timeout ) ); $secondaryEndpoint = trim((string)$settings->getSecondaryApiEndpoint()); if ($secondaryEndpoint !== '') { $options = array_merge( $options, $this->fetchModelOptionsForEndpoint( 'secondary', 'Secondary', $this->getModelsEndpoint('custom', $secondaryEndpoint), (string)($this->settingsService->getSecondaryApiKey() ?? ''), $timeout ) ); } return $options; } /** * @return array */ private function getCachedModelOptions($settings): array { if ($this->modelOptionsCache !== null) { return $this->modelOptionsCache; } $fingerprint = $this->modelEndpointFingerprint($settings); if ($this->config !== null) { $raw = $this->config->getAppValue(Application::APP_ID, self::MODEL_OPTIONS_CACHE_KEY, ''); $decoded = $raw !== '' ? json_decode($raw, true) : null; if ( is_array($decoded) && ($decoded['fingerprint'] ?? '') === $fingerprint && (int)($decoded['expires_at'] ?? 0) >= time() && isset($decoded['options']) && is_array($decoded['options']) ) { $this->modelOptionsCache = $this->normalizeCachedModelOptions($decoded['options']); return $this->modelOptionsCache; } } $timeout = $this->settingsService->normalizePositiveInteger( $settings->getLlmModelsTimeout(), SettingsService::DEFAULT_LLM_MODELS_TIMEOUT ); $options = $this->fetchConfiguredModelOptions($settings, $timeout); $this->storeModelOptionsCache($settings, $options); return $options; } /** * @param array $options */ private function storeModelOptionsCache($settings, array $options): void { $this->modelOptionsCache = $options; if ($this->config === null) { return; } $this->config->setAppValue(Application::APP_ID, self::MODEL_OPTIONS_CACHE_KEY, json_encode([ 'fingerprint' => $this->modelEndpointFingerprint($settings), 'expires_at' => time() + self::MODEL_OPTIONS_CACHE_TTL, 'options' => $options, ]) ?: ''); } private function modelEndpointFingerprint($settings): string { return sha1(implode('|', [ trim((string)$settings->getApiProvider()), rtrim(trim((string)$settings->getApiEndpoint()), '/'), rtrim(trim((string)$settings->getSecondaryApiEndpoint()), '/'), ])); } /** * @param array $options * @return array */ private function normalizeCachedModelOptions(array $options): array { $normalized = []; foreach ($options as $option) { if (!is_array($option) || !isset($option['id'], $option['model'])) { continue; } $endpoint = ($option['endpoint'] ?? 'primary') === 'secondary' ? 'secondary' : 'primary'; $model = trim((string)$option['model']); if ($model === '') { continue; } $normalized[] = [ 'id' => (string)$option['id'], 'label' => (string)($option['label'] ?? (($endpoint === 'secondary' ? 'Secondary' : 'Primary') . ' · ' . $model)), 'model' => $model, 'endpoint' => $endpoint, ]; } return $normalized; } /** * @return array */ private function fetchModelOptionsForEndpoint(string $endpointKey, string $labelPrefix, string $modelsEndpoint, string $apiKey, int $timeout): array { if ($apiKey === '') { throw new \Exception($labelPrefix . ' API key not configured'); } $client = $this->clientService->newClient(); try { $response = $client->get($modelsEndpoint, [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json', ], 'timeout' => $timeout, ]); $this->throwForHttpError($response); $body = json_decode($response->getBody(), true); $models = $this->extractModelIds($body); sort($models); return array_values(array_map( static fn (string $model): array => [ 'id' => $endpointKey . ':' . $model, 'label' => $labelPrefix . ' · ' . $model, 'model' => $model, 'endpoint' => $endpointKey, ], array_values(array_unique($models)) )); } catch (\Exception $e) { $this->logger->error('LLM list models failed: ' . $e->getMessage(), [ 'exception' => $e, 'endpoint' => $endpointKey, ]); throw new \Exception('Failed to list models: ' . $e->getMessage()); } } /** * @param mixed $body * @return array */ private function extractModelIds($body): array { $models = []; if (isset($body['data']) && is_array($body['data'])) { foreach ($body['data'] as $item) { if (isset($item['id'])) { $models[] = (string)$item['id']; } elseif (isset($item['name'])) { $models[] = (string)$item['name']; } } } elseif (isset($body['models']) && is_array($body['models'])) { foreach ($body['models'] as $item) { if (is_string($item)) { $models[] = $item; } elseif (isset($item['id'])) { $models[] = (string)$item['id']; } } } return $models; } private function getModelsEndpoint(string $provider, ?string $customEndpoint): string { switch ($provider) { case 'openai': case 'gwdg': return self::GWDG_MODELS_ENDPOINT; case 'azure': case 'custom': // For custom endpoints, we try to infer base URL if a chat/completions path is provided if (!empty($customEndpoint)) { $u = rtrim($customEndpoint, '/'); // If ends with /v1/chat/completions -> /v1/models if (preg_match('#/v1/chat/completions$#', $u)) { return (string)preg_replace('#/v1/chat/completions$#', '/v1/models', $u); } // If ends with /chat/completions -> /models if (preg_match('#/chat/completions$#', $u)) { return (string)preg_replace('#/chat/completions$#', '/models', $u); } // If ends with /v1 -> append /models if (preg_match('#/v1$#', $u)) { return $u . '/models'; } // Otherwise, append /v1/models return rtrim($u, '/') . '/v1/models'; } // Fallback if (empty($customEndpoint)) { throw new \Exception('Custom API endpoint is not configured'); } return rtrim($customEndpoint, '/') . '/models'; default: throw new \Exception('Unknown or unconfigured API provider: ' . $provider); } } /** * Extract rate limit headers from HTTP response * * Supports GWDG/AcademicCloud format: * - x-ratelimit-limit-second, x-ratelimit-limit-minute, x-ratelimit-limit-hour, x-ratelimit-limit-day * - x-ratelimit-remaining-second, x-ratelimit-remaining-minute, x-ratelimit-remaining-hour, x-ratelimit-remaining-day * - ratelimit-reset (seconds until window resets) * * @param \OCP\Http\Client\IResponse $response * @return array */ private function extractRateLimitHeaders($response): array { $headers = []; $headerNames = [ 'x-ratelimit-limit-second', 'x-ratelimit-limit-minute', 'x-ratelimit-limit-hour', 'x-ratelimit-limit-day', 'x-ratelimit-limit-month', 'x-ratelimit-remaining-second', 'x-ratelimit-remaining-minute', 'x-ratelimit-remaining-hour', 'x-ratelimit-remaining-day', 'x-ratelimit-remaining-month', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset', ]; foreach ($headerNames as $name) { try { $value = $response->getHeader($name); if ($value !== '' && $value !== null) { // Handle case where getHeader returns array if (is_array($value)) { $value = $value[0] ?? ''; } $headers[$name] = is_numeric($value) ? (int)$value : $value; } } catch (\Exception $e) { // Header not present, skip } } if (count($headers) > 0) { $this->logger->debug('Extracted rate limit headers', [ 'headers' => $headers, ]); } return $headers; } }