$headers): array{0:int,1:string} $fetch GET returning * [status, body]; the default uses WordPress HTTP with redirects disabled. */ public function __construct(private \Closure $fetch) { } /** * @return array{ok:bool, reason:?string, models:list>, http:int, error_type:?string} * reason: auth | rate_limited | quota_exhausted | workspace_key | unavailable | unexpected | unsupported_provider * http: the provider's exact HTTP status (0: no answer, 200: success); error_type: the provider's own short error identifier (for example * Anthropic's authentication_error, billing_error, permission_error), read only when it is a short letters-and-underscores token; never a message. */ public function check(string $provider, #[\SensitiveParameter] string $secret): array { if (!Providers::isAvailable($provider)) { return ['ok' => false, 'reason' => 'unsupported_provider', 'models' => [], 'http' => 0, 'error_type' => null]; } if ($secret === '' || strlen($secret) > 4096 || preg_match('/[^\x21-\x7e]/', $secret)) { return ['ok' => false, 'reason' => 'auth', 'models' => [], 'http' => 0, 'error_type' => null]; } $models = []; $cursor = null; for ($page = 0; $page < self::MAX_PAGES; $page++) { [$url, $headers] = $this->request($provider, $secret, $cursor); [$status, $body] = ($this->fetch)($url, $headers); if ($status !== 200) { return ['ok' => false, 'reason' => match (true) { // Anthropic only: a spend or usage limit (not a momentary rate limit) and a key that is not scoped to one workspace $provider === 'anthropic' && HttpsStreamTransport::isSpendCap($status, substr($body, 0, 4096)) => 'quota_exhausted', $provider === 'anthropic' && HttpsStreamTransport::isUnscopedKey($status, substr($body, 0, 4096)) => 'workspace_key', // DeepSeek and OpenRouter document 402 as insufficient balance or credits; for the other providers 402 stays an access problem, as before $status === 402 && in_array($provider, ['deepseek', 'openrouter'], true) => 'quota_exhausted', in_array($status, [401, 402, 403], true) => 'auth', $status === 429 => 'rate_limited', $status === 0 || $status >= 500 => 'unavailable', default => 'unexpected', }, 'models' => [], 'http' => $status, 'error_type' => self::errorToken($body)]; } $data = json_decode($body, true, 32); if (!is_array($data)) { return ['ok' => false, 'reason' => 'unexpected', 'models' => [], 'http' => $status, 'error_type' => null]; } [$items, $cursor] = $this->parse($provider, $data); array_push($models, ...$items); if ($cursor === null || count($models) >= 2000) { break; } } $unique = []; foreach ($models as $m) { $unique[$m['id']] = $m; } ksort($unique); return ['ok' => true, 'reason' => null, 'models' => array_values($unique), 'http' => 200, 'error_type' => null]; } /** * The provider's own error identifier from an error body: Anthropic and OpenAI `error.type`, Google `error.status`, OpenAI `error.code`. Only a short * token of letters and underscores is accepted (a key, a message or anything else is ignored), so nothing but a fixed identifier is ever kept. */ public static function errorToken(string $body): ?string { $json = json_decode(substr($body, 0, 4096), true, 8); $error = is_array($json) && is_array($json['error'] ?? null) ? $json['error'] : []; foreach (['type', 'status', 'code'] as $field) { $value = $error[$field] ?? null; if (is_string($value) && preg_match('/^[A-Za-z_]{3,40}$/D', $value) === 1) { return $value; } } return null; } /** @return array{0:string, 1:list} */ private function request(string $provider, #[\SensitiveParameter] string $secret, ?string $cursor): array { return match ($provider) { 'openai' => ['https://api.openai.com/v1/models', ['Authorization: Bearer ' . $secret, 'Accept: application/json']], 'anthropic' => ['https://api.anthropic.com/v1/models?limit=1000' . ($cursor !== null ? '&after_id=' . rawurlencode($cursor) : ''), ['x-api-key: ' . $secret, 'anthropic-version: ' . AnthropicMessagesAdapter::API_VERSION, 'Accept: application/json']], 'gemini' => ['https://' . GeminiAdapter::HOST . '/v1beta/models?pageSize=1000' . ($cursor !== null ? '&pageToken=' . rawurlencode($cursor) : ''), ['x-goog-api-key: ' . $secret, 'Accept: application/json']], // The four Chat Completions providers: a plain model list, one page, bearer authentication (official documentation, 2026-11-06). 'deepseek' => ['https://api.deepseek.com/models', ['Authorization: Bearer ' . $secret, 'Accept: application/json']], 'xai' => ['https://api.x.ai/v1/language-models', ['Authorization: Bearer ' . $secret, 'Accept: application/json']], 'openrouter' => ['https://openrouter.ai/api/v1/models', ['Authorization: Bearer ' . $secret, 'Accept: application/json']], 'groq' => ['https://api.groq.com/openai/v1/models', ['Authorization: Bearer ' . $secret, 'Accept: application/json']], }; } /** @return array{0:list>, 1:?string} items and next-page cursor */ private function parse(string $provider, array $data): array { $items = []; $cursor = null; $limit = static fn($v): ?int => is_int($v) && $v > 0 && $v <= 100000000 ? $v : null; $text = static fn($v): ?string => is_string($v) && $v !== '' && mb_strlen($v) <= 191 ? $v : null; if ($provider === 'openai') { foreach (array_slice((array) ($data['data'] ?? []), 0, 2000) as $m) { if (is_string($m['id'] ?? null) && Providers::validModelId($m['id'])) { // OpenAI's list reports IDs only: chat support is unknown, not assumed. $items[] = ['id' => $m['id'], 'name' => null, 'chat' => null, 'embedding' => null, 'input_limit' => null, 'output_limit' => null, 'lifecycle' => null]; } } } elseif ($provider === 'anthropic') { foreach (array_slice((array) ($data['data'] ?? []), 0, 1000) as $m) { if (!is_string($m['id'] ?? null) || !Providers::validModelId($m['id']) || ($m['lifecycle'] ?? 'active') === 'retired') { continue; } $items[] = ['id' => $m['id'], 'name' => $text($m['display_name'] ?? null), 'chat' => true, 'embedding' => false, 'input_limit' => $limit($m['max_input_tokens'] ?? null), 'output_limit' => $limit($m['max_tokens'] ?? null), 'lifecycle' => in_array($m['lifecycle'] ?? null, ['active', 'deprecated'], true) ? $m['lifecycle'] : null]; } $cursor = !empty($data['has_more']) && is_string($data['last_id'] ?? null) ? $data['last_id'] : null; } elseif ($provider === 'gemini') { foreach (array_slice((array) ($data['models'] ?? []), 0, 1000) as $m) { $name = is_string($m['name'] ?? null) && str_starts_with($m['name'], 'models/') ? substr($m['name'], 7) : null; if ($name === null || !Providers::validModelId($name) || str_contains($name, ':')) { continue; } $methods = array_values(array_filter((array) ($m['supportedGenerationMethods'] ?? []), 'is_string')); $items[] = ['id' => $name, 'name' => $text($m['displayName'] ?? null), 'chat' => in_array('generateContent', $methods, true), 'embedding' => in_array('embedContent', $methods, true), 'input_limit' => $limit($m['inputTokenLimit'] ?? null), 'output_limit' => $limit($m['outputTokenLimit'] ?? null), 'lifecycle' => null]; } $cursor = is_string($data['nextPageToken'] ?? null) && $data['nextPageToken'] !== '' ? $data['nextPageToken'] : null; } elseif (in_array($provider, OpenAiCompatAdapter::providers(), true)) { $list = $provider === 'xai' ? ($data['models'] ?? []) : ($data['data'] ?? []); foreach (array_slice((array) $list, 0, 2000) as $m) { $id = is_array($m) ? ($m['id'] ?? null) : null; if (!is_string($id) || !OpenAiCompatAdapter::validModel($provider, $id) || (is_array($m) && ($m['active'] ?? true) === false)) { continue; // Groq marks retired models active=false; they are not offered } $out = $provider === 'openrouter' ? ($m['top_provider']['max_completion_tokens'] ?? null) : ($m['max_output_tokens'] ?? null); $ctx = $m['context_length'] ?? $m['context_window'] ?? null; $modalities = $m['output_modalities'] ?? ($m['architecture']['output_modalities'] ?? null); // Only an explicit report counts: text among the output modalities means chat; a list without modalities says nothing (null). $chat = is_array($modalities) ? in_array('text', $modalities, true) : null; $items[] = ['id' => $id, 'name' => $text($m['name'] ?? null), 'chat' => $chat, 'embedding' => null, 'input_limit' => $limit($ctx), 'output_limit' => $limit($out), 'lifecycle' => null]; } } return [$items, $cursor]; } /** Default fetcher: WordPress HTTP API, fixed hosts, no redirects, bounded size and time. */ public static function wordpressFetcher(): \Closure { return static function (string $url, array $headers): array { // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- pure PHP helper without a WordPress dependency (unit-tested standalone) $host = (string) parse_url($url, PHP_URL_HOST); $allowed = array_merge(...array_map(static fn(array $p): array => (array) ($p['hosts'] ?? []), array_values(Providers::available()))); if (!str_starts_with($url, 'https://') || !in_array($host, $allowed, true)) { return [0, '']; } if (OutboundGuard::blocked()) { return [0, '']; // integration-test site: reported as unreachable, nothing is sent } $assoc = []; foreach ($headers as $line) { [$k, $v] = array_map('trim', explode(':', $line, 2)); $assoc[$k] = $v; } $response = wp_safe_remote_get($url, ['timeout' => 15, 'redirection' => 0, 'limit_response_size' => 2097152, 'headers' => $assoc]); if (is_wp_error($response)) { return [0, '']; } return [(int) wp_remote_retrieve_response_code($response), (string) wp_remote_retrieve_body($response)]; }; } }