> */ public static function all(): array { return [ 'openai' => [ 'name' => 'OpenAI', 'status' => self::AVAILABLE, 'product' => 'OpenAI API (Responses API)', 'hosts' => ['api.openai.com'], 'key_url' => 'https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key', 'key_hint' => 'Create a secret key on the API Keys page of the OpenAI developer platform (the linked OpenAI help article shows where). API usage is billed through the developer platform, separately from a ChatGPT subscription.', 'check' => 'GET https://api.openai.com/v1/models — lists the models your key can use. It does not generate text.', 'chat' => 'Streams answers from https://api.openai.com/v1/responses.', 'model_info' => 'The model list contains IDs only; OpenAI does not report which models support text answers through Responses, so choose a text model.', 'embeddings' => 'Offered by OpenAI. Aiwend has an adapter for it, but semantic search is not switched on in this version.', ], 'anthropic' => [ 'name' => 'Anthropic', 'status' => self::AVAILABLE, 'product' => 'Claude API (Messages API)', 'hosts' => ['api.anthropic.com'], 'key_url' => 'https://platform.claude.com/settings/keys', 'key_hint' => 'Create a key on the API keys page of the Claude Console (keys start with sk-ant-). Keys can be tied to a workspace or not. Aiwend sends no workspace header, so if Check access says the key is not scoped to a workspace, create a new key inside a workspace and use that one. Claude API usage is billed through the Console, separately from a Claude.ai subscription.', 'check' => 'GET https://api.anthropic.com/v1/models — lists the models your key can use. It does not generate text.', 'chat' => 'Streams answers from https://api.anthropic.com/v1/messages.', 'model_info' => 'The model list includes display names, lifecycle and token limits reported by Anthropic. Claude models can think before answering; that thinking counts toward the output limit, is billed as output and is never shown. Where a model allows it, Aiwend asks for a plain answer without up-front thinking.', 'embeddings' => 'Anthropic does not offer its own embedding model, so a Claude connection cannot provide semantic search.', ], 'gemini' => [ 'name' => 'Google Gemini', 'status' => self::AVAILABLE, 'product' => 'Gemini API (Google AI Studio key) — not Vertex AI', 'hosts' => ['generativelanguage.googleapis.com'], 'key_url' => 'https://aistudio.google.com/apikey', 'key_hint' => 'Create a Gemini API key in Google AI Studio. Billing and quota belong to the linked Google Cloud project. Vertex AI credentials are a different product and do not work here.', 'check' => 'GET https://generativelanguage.googleapis.com/v1beta/models — lists the models your key can use. It does not generate text.', 'chat' => 'Streams answers from https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent.', 'model_info' => 'The model list reports supported methods, display names and token limits; only models that support generateContent can answer chat. A model in the list can still be refused for your account (for example retired for new users): if answers fail, check Settings > Diagnostics > Recent activity for an HTTP 404 and choose a newer model.', 'embeddings' => 'Offered by Google (embedContent). Not integrated in this version; semantic search is not switched on.', ], // The four providers below use Chat Completions streaming (OpenAiCompatAdapter). Facts come from the providers' official documentation read on // 2026-11-06 (docs/provider-expansion-phase134.md). They are tested offline only: 'live_verified' stays false until a separately authorized, // cost-bounded live check has been recorded. 'deepseek' => [ 'name' => 'DeepSeek', 'status' => self::AVAILABLE, 'live_verified' => false, 'product' => 'DeepSeek API (Chat Completions)', 'hosts' => ['api.deepseek.com'], 'key_url' => 'https://api-docs.deepseek.com/', 'key_hint' => 'Create an API key in your DeepSeek platform account (the linked documentation starts from the first API call). Usage is billed to your DeepSeek balance; a request with no balance is refused with HTTP 402.', 'check' => 'GET https://api.deepseek.com/models — lists the models your key can use. It does not generate text.', 'chat' => 'Streams answers from https://api.deepseek.com/chat/completions. Aiwend asks for plain visible answers (thinking mode off) so the answer limit is not spent on reasoning.', 'model_info' => 'The model list reports the context window and the largest output of each model. Choose a current DeepSeek chat model; older names may be retired by DeepSeek.', 'embeddings' => 'Not integrated: semantic search is not switched on in this version.', ], 'xai' => [ 'name' => 'xAI (Grok)', 'status' => self::AVAILABLE, 'live_verified' => false, 'product' => 'xAI API (Chat Completions)', 'hosts' => ['api.x.ai'], 'key_url' => 'https://docs.x.ai/', 'key_hint' => 'Create an API key in your xAI console (the linked documentation starts from the API overview). Usage is billed by xAI; reasoning tokens are billed as output.', 'check' => 'GET https://api.x.ai/v1/language-models — lists the text models your key can use. It does not generate text.', 'chat' => 'Streams answers from https://api.x.ai/v1/chat/completions. xAI recommends its Responses API for new integrations and documents Chat Completions as the older, still available interface; Aiwend uses Chat Completions for now.', 'model_info' => 'The model list reports each model\'s context length and which reasoning efforts it supports. Reasoning models think before answering and cannot be told not to; their reasoning counts toward usage.', 'embeddings' => 'Not integrated: semantic search is not switched on in this version.', ], 'openrouter' => [ 'name' => 'OpenRouter', 'status' => self::AVAILABLE, 'live_verified' => false, 'product' => 'OpenRouter API (routes your request to a model provider)', 'hosts' => ['openrouter.ai'], 'key_url' => 'https://openrouter.ai/docs/', 'key_hint' => 'Create an API key in your OpenRouter account (the linked documentation starts from the quickstart). OpenRouter forwards each request to the provider that hosts the model you choose, so that provider\'s data handling applies as well as OpenRouter\'s. Aiwend asks OpenRouter to use only providers that do not collect prompts.', 'check' => 'GET https://openrouter.ai/api/v1/models — lists models that are currently routable. It does not generate text.', 'chat' => 'Streams answers from https://openrouter.ai/api/v1/chat/completions with the model ID you choose (for example vendor/model).', 'model_info' => 'The list contains many models from many vendors; not every model supports every Aiwend feature, and availability and prices change. Aiwend does not rank or recommend models here. The model that actually answered is recorded when OpenRouter reports it.', 'embeddings' => 'Not integrated: semantic search is not switched on in this version.', ], 'groq' => [ 'name' => 'GroqCloud', 'status' => self::AVAILABLE, 'live_verified' => false, 'product' => 'GroqCloud API (not Grok from xAI)', 'hosts' => ['api.groq.com'], 'key_url' => 'https://console.groq.com/docs/api-reference', 'key_hint' => 'Create an API key in your GroqCloud console (the linked reference describes the key header). GroqCloud is a different company from xAI\'s Grok. Rate limits apply per organization and are returned as HTTP 429.', 'check' => 'GET https://api.groq.com/openai/v1/models — lists the models your key can use. It does not generate text.', 'chat' => 'Streams answers from https://api.groq.com/openai/v1/chat/completions.', 'model_info' => 'The model list also contains speech and safety models that cannot answer chat; choose a text model. GroqCloud retires models on published dates, so a model in an old list can stop working.', 'embeddings' => 'Not integrated: semantic search is not switched on in this version.', ], 'vertex' => [ 'name' => 'Google Vertex AI', 'status' => self::NOT_IMPLEMENTED, 'product' => 'Vertex AI (Google Cloud project, region and service account)', 'note' => 'Requires additional Google Cloud configuration that this version does not support.', ], 'azure_openai' => [ 'name' => 'Azure OpenAI', 'status' => self::NOT_IMPLEMENTED, 'product' => 'Azure OpenAI (resource endpoint and deployment)', 'note' => 'Requires a resource endpoint and deployment configuration that this version does not support.', ], ]; } /** @return array> */ public static function available(): array { return array_filter(self::all(), static fn(array $p): bool => $p['status'] === self::AVAILABLE); } public static function isAvailable(string $id): bool { return isset(self::available()[$id]); } /** * True when a credential has the unmistakable format of ANOTHER provider (found in live acceptance: an Anthropic key pasted into an OpenAI * connection would be sent to api.openai.com). Only the documented public prefixes are used: Anthropic "sk-ant-", Google "AIza", OpenAI "sk-" * (not "sk-ant-"). Anything else is accepted, so new key formats are never refused. The credential is never echoed or logged. */ public static function looksLikeAnotherProvider(string $provider, string $secret): bool { $s = ltrim($secret); $anthropic = str_starts_with($s, 'sk-ant-'); $google = str_starts_with($s, 'AIza'); $openai = str_starts_with($s, 'sk-') && !$anthropic; return match ($provider) { 'openai' => $anthropic || $google, 'anthropic' => $google || $openai, 'gemini' => $anthropic || $openai, // The four newer providers' key formats are not documented as public prefixes, so only the two unmistakable foreign formats are refused. 'deepseek', 'xai', 'openrouter', 'groq' => $anthropic || $google, default => false, }; } public static function name(string $id): string { return (string) (self::all()[$id]['name'] ?? $id); } /** * HTTP statuses each provider documents as a refusal BEFORE processing (its error tables list them with no generation and no usage). * Deliberately narrow and provider-specific, not one shared list: a status that a provider does not document this way is treated as * ambiguous for that provider. 402 is on no list (no supported provider documents it). Every 5xx (500, 502, 503, 504, 529), 408, 409 * and status 0 are never listed. All of this is an ASSUMPTION until D7 confirms it with real answers and the provider's usage page. * * @return list */ public static function rejectionStatuses(string $provider): array { return match ($provider) { 'openai' => [400, 401, 403, 404, 422, 429], 'anthropic' => [400, 401, 403, 404, 413, 429], 'gemini' => [400, 403, 404, 429], // From each provider's documented error table (read 2026-11-06). 402 (insufficient balance or credits) is a refusal before processing for // DeepSeek and OpenRouter; OpenRouter's 404 (no provider satisfies the routing needs) and 403 (moderation or guardrail) likewise. 408, 5xx and // status 0 stay ambiguous everywhere. All of it is an assumption until a live check confirms it. 'deepseek' => [400, 401, 402, 422, 429], 'xai' => [400, 401, 403, 404, 422, 429], 'openrouter' => [400, 401, 402, 403, 404, 429], 'groq' => [400, 401, 403, 404, 413, 422, 429], default => [], }; } /** * Model identifiers accepted anywhere a model ID is stored or placed in a request. With $routed (OpenRouter) a single "/" separating a vendor * from the model is also allowed, as in OpenRouter's documented naming ("vendor/model", optionally with a ":variant" suffix); no other character * is added, so nothing that could alter a URL, a header or the JSON structure is accepted. */ public static function validModelId(string $model, bool $routed = false): bool { if ($routed) { // "vendor/model[:variant]" or a plain id without a slash and without a variant suffix. return (bool) preg_match('/^(?:[a-zA-Z0-9][a-zA-Z0-9._-]{0,80}\/[a-zA-Z0-9][a-zA-Z0-9._:-]{0,100}|[a-zA-Z0-9][a-zA-Z0-9._-]{0,190})$/D', $model); } return (bool) preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,190}$/D', $model); } /** * Whether a connection's provider id can store this model ID. OpenRouter ids are always "vendor/model[:variant]"; GroqCloud names some models with * one slash (for example "openai/gpt-oss-20b") and others without; every other provider uses the shared pattern. */ public static function validModelIdFor(string $provider, string $model): bool { if ($provider === 'openrouter') { return str_contains($model, '/') && self::validModelId($model, true); } return $provider === 'groq' ? self::validModelId($model, true) : self::validModelId($model); } }