isset($meta['checked_at']) ? (int) $meta['checked_at'] : null, 'check' => isset($meta['check']) ? (string) $meta['check'] : null, 'check_http' => isset($meta['check_http']) ? (int) $meta['check_http'] : null, 'check_error' => isset($meta['check_error']) && is_string($meta['check_error']) ? $meta['check_error'] : null, 'default_model' => isset($meta['default_model']) && $this->modelIdOk($connectionId, (string) $meta['default_model']) ? (string) $meta['default_model'] : null, 'last_test' => isset($meta['last_test']) && is_array($meta['last_test']) ? $meta['last_test'] : null, ]; } /** The provider id of one of this site's connections, or null (unknown connection or no encryption key). */ public function providerOf(int $connectionId): ?string { $connection = $this->plugin->connections()?->find(get_current_blog_id(), $connectionId); return is_array($connection) ? (string) $connection['provider'] : null; } /** A stored or listed model ID is acceptable for this connection's provider (OpenRouter has its own naming; unknown provider: the strict shared pattern). */ public function modelIdOk(int $connectionId, string $model): bool { $provider = $this->providerOf($connectionId); return $provider === null ? Providers::validModelId($model) : Providers::validModelIdFor($provider, $model); } private function setMeta(int $connectionId, array $changes): void { update_option('aiwend_connection_meta_' . $connectionId, array_merge($this->meta($connectionId), $changes), false); } /** Models from the last access check (older builds stored plain IDs). @return list> */ public function models(int $connectionId): array { $stored = get_option('aiwend_connection_models_' . $connectionId, []); $out = []; $provider = $this->providerOf($connectionId); // once, not per model foreach (is_array($stored) ? $stored : [] as $m) { if (is_string($m)) { $m = ['id' => $m, 'name' => null, 'chat' => null, 'embedding' => null, 'input_limit' => null, 'output_limit' => null, 'lifecycle' => null]; } if (is_array($m) && ($provider === null ? Providers::validModelId((string) ($m['id'] ?? '')) : Providers::validModelIdFor($provider, (string) ($m['id'] ?? '')))) { $out[] = $m; } } return $out; } /** Models that may answer chat: confirmed chat models, or unknown (OpenAI reports IDs only). */ public function chatModels(int $connectionId): array { return array_values(array_filter($this->models($connectionId), static fn(array $m): bool => ($m['chat'] ?? null) !== false)); } /** Models a module picker offers for this connection: the chosen default and the tested model first, then list order, bounded (see ModelPicker). */ public function pickerModels(int $connectionId): array { return \Aiwend\Provider\ModelPicker::choose($this->chatModels($connectionId), $this->meta($connectionId)['default_model'], $this->testedModel($connectionId)); } /** The model that last answered a test message on this connection (the only evidence that confirms a model), or null. */ public function testedModel(int $connectionId): ?string { $t = $this->meta($connectionId)['last_test']; return is_array($t) && !empty($t['ok']) && is_string($t['model'] ?? null) ? $t['model'] : null; } /** * Advisory suitability of one model for ordinary text chat on this connection (see ModelSuitability). A model that is not in the last * list (for example a saved ID that has since disappeared) is judged by its ID alone. * * @return array{level:string, reasons:list} */ public function suitability(int $connectionId, string $model): array { foreach ($this->models($connectionId) as $m) { if ($m['id'] === $model) { return ModelSuitability::classify($m, $this->testedModel($connectionId)); } } return ModelSuitability::classify(['id' => $model, 'name' => null, 'chat' => null], $this->testedModel($connectionId)); } /** One plain sentence for a suitability result, or '' when there is nothing to warn about. */ public static function suitabilityNote(array $s): string { $why = [ 'not_chat' => __('the provider says it is not for chat', 'aiwend'), 'speech' => __('it looks like a speech model', 'aiwend'), 'audio' => __('it looks like an audio or music model', 'aiwend'), 'image' => __('it looks like an image model', 'aiwend'), 'video' => __('it looks like a video model', 'aiwend'), 'transcription' => __('it looks like a transcription model', 'aiwend'), 'embedding' => __('it looks like an embedding model', 'aiwend'), 'moderation' => __('it looks like a moderation model', 'aiwend'), 'realtime' => __('it looks like a live or realtime model', 'aiwend'), 'robotics' => __('it looks like a robotics model', 'aiwend'), 'agent' => __('it looks like an agent, research or tool model', 'aiwend'), 'not_reported' => __('the provider does not say whether it answers text chat', 'aiwend'), 'preview' => __('it is a preview, which a provider can change or withdraw', 'aiwend'), 'small_context' => __('its reported context is small', 'aiwend'), ]; $reasons = array_values(array_filter(array_map(static fn(string $r): string => $why[$r] ?? '', (array) ($s['reasons'] ?? [])))); return match ($s['level'] ?? '') { ModelSuitability::TESTED => __('This model answered a test message through this connection.', 'aiwend'), ModelSuitability::SPECIALIZED => sprintf( /* translators: %s: reasons, for example "it looks like an image model" */ __('May not suit ordinary text chat: %s. Choose it only if you know it answers text questions, and send a test message first.', 'aiwend'), implode('; ', $reasons) ), ModelSuitability::UNCONFIRMED => sprintf( /* translators: %s: reasons, for example "it is a preview" */ __('Not confirmed for text chat: %s. Send a test message to confirm it.', 'aiwend'), implode('; ', $reasons) ), ModelSuitability::REPORTED => __('The provider reports text generation. That does not guarantee your account may use this model: send a test message to confirm.', 'aiwend'), default => '', }; } /** True when the model is in the last checked list (or the list is empty: never checked). */ public function modelKnown(int $connectionId, string $model): bool { $models = $this->chatModels($connectionId); return $models === [] || in_array($model, array_column($models, 'id'), true); } /** Removes the recorded live-request failures of one connection: a per-site option keyed by that connection's id, so no other connection or site is touched. */ public function forgetHealth(int $connectionId): void { if ($connectionId > 0) { delete_option(\Aiwend\Usage\ConnectionHealth::OPTION_PREFIX . $connectionId); } } public function forget(int $connectionId): void { delete_option('aiwend_connection_meta_' . $connectionId); delete_option('aiwend_connection_models_' . $connectionId); $this->forgetHealth($connectionId); $default = $this->defaultAi(); if ($default !== null && $default['connection_id'] === $connectionId) { delete_option(self::DEFAULT_OPTION); } } // ---------------------------------------------------------------- site default /** @return array{connection_id:int, model:string}|null */ public function defaultAi(): ?array { $value = get_option(self::DEFAULT_OPTION, null); if (!is_array($value) || !isset($value['connection_id'], $value['model']) || !$this->modelIdOk((int) $value['connection_id'], (string) $value['model'])) { return null; } return ['connection_id' => (int) $value['connection_id'], 'model' => (string) $value['model']]; } public function setDefault(int $connectionId, string $model): void { $connection = $this->plugin->connections()?->find(get_current_blog_id(), $connectionId); if ($connection === null || $connection['status'] !== 'verified' || !Providers::isAvailable($connection['provider']) || !Providers::validModelIdFor((string) $connection['provider'], $model) || !$this->modelKnown($connectionId, $model)) { throw new \InvalidArgumentException('Choose a checked connection and one of its chat models.'); } update_option(self::DEFAULT_OPTION, ['connection_id' => $connectionId, 'model' => $model], false); } public function clearDefault(): void { delete_option(self::DEFAULT_OPTION); } /** Assistants that follow the site default. @return list> */ public function inheriting(): array { return array_values(array_filter($this->plugin->bots->all(get_current_blog_id()), static fn(array $b): bool => $b['connection_id'] === null && $b['model'] === BotRepository::MODEL_INHERIT)); } /** * Evidence that this site has ever had an AI connection: a stored connection, or a connection * entry in the audit trail. Absence is not proof (the audit trail is purged after its retention). */ public function siteHadConnection(int $siteId): bool { $db = $this->plugin->db; return $db->selectOne('SELECT id FROM {connections} WHERE site_id = ? LIMIT 1', [$siteId]) !== null || $db->selectOne("SELECT id FROM {audit} WHERE site_id = ? AND object_ref LIKE 'connection:%' LIMIT 1", [$siteId]) !== null; } /** Assistants whose effective connection is $connectionId (explicit, or via the default). */ public function dependents(int $connectionId): array { $default = $this->defaultAi(); return array_values(array_filter($this->plugin->bots->all(get_current_blog_id()), static fn(array $b): bool => $b['connection_id'] === $connectionId || ($b['connection_id'] === null && $b['model'] === BotRepository::MODEL_INHERIT && $default !== null && $default['connection_id'] === $connectionId))); } // ---------------------------------------------------------------- resolution /** * The provider and model an assistant actually uses. * * @return array{mode:string, connection:?array, provider:?string, model:?string, ready:bool, problem:?string} * mode: inherit | explicit | demo; problem: no_default | connection_missing | connection_disabled | * connection_unchecked | model_unavailable | demo_off | encryption_missing | null */ public function resolve(array $bot): array { $mode = $bot['connection_id'] !== null ? 'explicit' : ($bot['model'] === BotRepository::MODEL_INHERIT ? 'inherit' : 'demo'); if ($mode === 'demo') { $on = $this->plugin->mockProviderEnabled(); return ['mode' => 'demo', 'connection' => null, 'provider' => null, 'model' => null, 'ready' => $on, 'problem' => $on ? null : 'demo_off']; } if ($mode === 'inherit') { $default = $this->defaultAi(); if ($default === null) { return ['mode' => 'inherit', 'connection' => null, 'provider' => null, 'model' => null, 'ready' => false, 'problem' => 'no_default']; } [$connectionId, $model] = [$default['connection_id'], $default['model']]; } else { [$connectionId, $model] = [(int) $bot['connection_id'], (string) $bot['model']]; } $repo = $this->plugin->connections(); if ($repo === null) { return ['mode' => $mode, 'connection' => null, 'provider' => null, 'model' => $model, 'ready' => false, 'problem' => 'encryption_missing']; } $connection = $repo->find((int) $bot['site_id'], $connectionId); $problem = match (true) { $connection === null || !Providers::isAvailable($connection['provider']) => 'connection_missing', $connection['status'] === 'disabled' => 'connection_disabled', $connection['status'] !== 'verified' => 'connection_unchecked', !$this->modelKnown($connectionId, $model) => 'model_unavailable', default => null, }; return ['mode' => $mode, 'connection' => $connection, 'provider' => $connection['provider'] ?? null, 'model' => $model, 'ready' => $problem === null, 'problem' => $problem]; } /** Owner-facing explanation of a resolution problem. */ public static function problemText(?string $problem): string { return match ($problem) { 'no_default' => __('No site default AI connection is set.', 'aiwend'), 'connection_missing' => __('The connection no longer exists.', 'aiwend'), 'connection_disabled' => __('The connection is disabled.', 'aiwend'), 'connection_unchecked' => __('The connection needs an access check (new or replaced key, or the last check failed).', 'aiwend'), 'model_unavailable' => __('The model was not in the list returned by the last access check.', 'aiwend'), 'demo_off' => __('Simulated demo mode is off, and no AI connection is assigned.', 'aiwend'), 'encryption_missing' => __('The site encryption key is missing, so saved keys cannot be used.', 'aiwend'), default => '', }; } /** Adapter for a ready connection (server-side only; the key never leaves this method). */ /** * How Aiwend asks Claude models to think. The default ("chat") requests plain visible answers; a developer can return "provider_default" * from the aiwend_anthropic_thinking_mode filter to send no thinking or effort setting at all. */ private static function anthropicThinkingMode(): string { $mode = function_exists('apply_filters') ? apply_filters('aiwend_anthropic_thinking_mode', AnthropicMessagesAdapter::THINKING_CHAT) : AnthropicMessagesAdapter::THINKING_CHAT; return $mode === AnthropicMessagesAdapter::THINKING_PROVIDER_DEFAULT ? $mode : AnthropicMessagesAdapter::THINKING_CHAT; } public function adapter(int $siteId, array $connection): ModelAdapter { $repo = $this->plugin->connections(); if ($repo === null) { throw new \RuntimeException('encryption'); } $id = (int) $connection['id']; $secret = $repo->secretForDispatch($siteId, $id); if ($this->adapterFactory !== null) { return ($this->adapterFactory)($connection); } $provider = (string) $connection['provider']; if (in_array($provider, OpenAiCompatAdapter::providers(), true)) { // DeepSeek, xAI, OpenRouter, GroqCloud: one Chat Completions adapter with a fixed profile per provider; same transport rules as the others. return new OpenAiCompatAdapter(new HttpsStreamTransport(OpenAiCompatAdapter::url($provider), OpenAiCompatAdapter::headers($secret), [OpenAiCompatAdapter::host($provider)], $secret, null, Providers::rejectionStatuses($provider), false, in_array($provider, ['deepseek', 'openrouter'], true) ? [402] : []), $provider, $id); } return match ($connection['provider']) { 'openai' => new OpenAiResponsesAdapter(new OpenAiTransport($secret, null, Providers::rejectionStatuses('openai')), 32768, $id), 'anthropic' => new AnthropicMessagesAdapter(new HttpsStreamTransport(AnthropicMessagesAdapter::URL, AnthropicMessagesAdapter::headers($secret), ['api.anthropic.com'], $secret, null, Providers::rejectionStatuses('anthropic'), true), $id, 32768, self::anthropicThinkingMode()), 'gemini' => new GeminiAdapter(static fn(string $url) => new HttpsStreamTransport($url, GeminiAdapter::headers($secret), [GeminiAdapter::HOST], $secret, null, Providers::rejectionStatuses('gemini')), $id), default => throw new \RuntimeException('provider'), }; } // ---------------------------------------------------------------- owner-initiated requests /** * Access check: sends the key to the provider's model-list endpoint (no text generation). * Updates status, the model list and the check time. * * @return array{ok:bool, reason:?string, models:int, http:int, error_type:?string} */ public function checkAccess(int $connectionId): array { $site = get_current_blog_id(); $repo = $this->plugin->connections(); $connection = $repo?->find($site, $connectionId); if ($repo === null || $connection === null || $connection['status'] === 'disabled') { return ['ok' => false, 'reason' => 'unavailable_connection', 'models' => 0]; } $result = (new ModelCatalog($this->fetcher ?? ModelCatalog::wordpressFetcher()))->check($connection['provider'], $repo->secretForDispatch($site, $connectionId)); $repo->setStatus($site, $connectionId, $result['ok'] ? 'verified' : 'failed'); if ($result['ok']) { update_option('aiwend_connection_models_' . $connectionId, $result['models'], false); } $this->setMeta($connectionId, ['checked_at' => time(), 'check' => $result['ok'] ? 'ok' : (string) $result['reason'], 'check_http' => $result['http'], 'check_error' => $result['error_type']]); return ['ok' => $result['ok'], 'reason' => $result['reason'], 'models' => count($result['models']), 'http' => $result['http'], 'error_type' => $result['error_type']]; } /** The last access check in diagnostic form, for example "http 401 authentication_error" (a status number and the provider's fixed error identifier only), or null. */ public function checkDiagnostic(int $connectionId): ?string { $meta = get_option('aiwend_connection_meta_' . $connectionId, []); $http = is_array($meta) && isset($meta['check_http']) ? (int) $meta['check_http'] : null; if ($http === null) { return null; } $error = is_array($meta) && isset($meta['check_error']) && is_string($meta['check_error']) && preg_match('/^[A-Za-z_]{3,40}$/D', $meta['check_error']) === 1 ? ' ' . $meta['check_error'] : ''; return 'http ' . $http . $error; } /** * Explicitly authorized test message with fixed synthetic content. Reserved against the * site usage limit, attributed as a test, settled with the provider-reported usage. Never * retried automatically. * * @return array{ok:bool, text:?string, error:?string, latency_ms:int, input:?int, output:?int, model:string, at:int} */ public function sendTest(int $connectionId, string $model): array { $site = get_current_blog_id(); $connection = $this->plugin->connections()?->find($site, $connectionId); if ($connection === null || $connection['status'] !== 'verified' || !Providers::validModelIdFor((string) $connection['provider'], $model) || !$this->modelKnown($connectionId, $model)) { throw new \InvalidArgumentException('Check access first and choose one of the connection’s models.'); } if ($this->plugin->ledger->balance($site, 'site') === null) { throw new \InvalidArgumentException('Set a site usage limit first (Usage page).'); } $requestId = 'req_' . bin2hex(random_bytes(12)); $adapter = $this->adapter($site, $connection); $request = new ModelRequest($model, self::TEST_INSTRUCTION, [['role' => 'user', 'content' => self::TEST_MESSAGE]], self::TEST_MAX_OUTPUT, $requestId, 30); $units = ($adapter instanceof \Aiwend\Provider\RequestBudget ? $adapter->inputReservation($request) : $request->estimatedInputTokens()) + self::TEST_MAX_OUTPUT; $this->plugin->ledger->reserve($site, $requestId, ['site'], $units); // throws if the limit is reached or spending is paused $this->plugin->ledger->attribute($site, $requestId, Reservation::ORIGIN_TEST, null, $connectionId, $model); $this->plugin->ledger->markDispatched($site, $requestId); $started = microtime(true); $out = ['ok' => false, 'text' => null, 'error' => null, 'latency_ms' => 0, 'input' => null, 'output' => null, 'model' => $model, 'at' => time()]; try { $result = $adapter->complete($request, CancellationToken::none()); $this->plugin->ledger->settle($site, $requestId, $result->totalTokens(), $result->providerRef, Reservation::BASIS_REPORTED, $result->inputTokens, $result->outputTokens, $result->detail); $out = ['ok' => true, 'text' => mb_substr($result->text, 0, 300), 'input' => $result->inputTokens, 'output' => $result->outputTokens] + $out; } catch (ProviderException $e) { if (!$e->dispatched) { $this->plugin->ledger->settle($site, $requestId, 0, null, Reservation::BASIS_NOT_DISPATCHED); } elseif ($e->rejected) { $this->plugin->ledger->settle($site, $requestId, 0, null, Reservation::BASIS_REJECTED); } elseif ($e->usageTokens !== null) { $this->plugin->ledger->settle($site, $requestId, $e->usageTokens, null, Reservation::BASIS_REPORTED); } else { $this->plugin->ledger->markUncertain($site, $requestId, 'test-' . $e->kind); } $out['error'] = $e->kind; } $out['latency_ms'] = (int) round((microtime(true) - $started) * 1000); $this->setMeta($connectionId, ['last_test' => $out]); $this->plugin->audit->record($site, $this->plugin->currentPrincipal()->actorRef(), 'connection.test', 'connection:' . $connectionId, $out['ok'] ? 'ok' : 'failed', $requestId, $out['error'] ?? ''); return $out; } }