403]); } check_admin_referer('aiwend_connection'); $repo = $this->plugin->connections(); $ai = $this->plugin->ai(); $site = get_current_blog_id(); $id = absint($_POST['connection'] ?? 0); $action = sanitize_key(Input::post('operation')); $args = ['view' => 'connection', 'id' => $id]; $notice = 'invalid'; $detail = null; try { if ($repo === null) { throw new \DomainException('setup_required'); } if (!in_array($action, ['create', 'set_default', 'clear_default', 'rotate_keys'], true) && $repo->find($site, $id) === null) { throw new \DomainException('not_found'); } switch ($action) { case 'create': // A refused form sends the owner back to the form with the reason, not to a connection page that does not exist. $args = ['view' => 'add']; $provider = sanitize_key(Input::post('provider')); if (!Providers::isAvailable($provider)) { throw new \DomainException('provider_invalid'); } if (empty($_POST['ack_destination'])) { throw new \DomainException('destination_required'); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- credential kept exactly as typed (sanitising would alter it), validated and encrypted, never echoed; uploads are validated by UploadReader; colour values are sanitised one by one $secret = Input::post('secret'); if (trim($secret) === '') { throw new \DomainException('key_required'); } $label = $this->post('label') !== '' ? $this->post('label') : Providers::name($provider); $id = $repo->create($site, $provider, $label, $secret); $args = ['view' => 'connection', 'id' => $id]; $notice = 'connection_saved'; break; case 'check': if (empty($_POST['authorize_check'])) { throw new \DomainException('confirm_required'); } $result = $ai->checkAccess($id); $detail = $ai->checkDiagnostic($id); // status number and the provider's fixed error identifier only, for the audit trail $notice = $result['ok'] ? 'connection_checked' : 'check_failed_' . (in_array($result['reason'], ['auth', 'rate_limited', 'quota_exhausted', 'workspace_key', 'unavailable'], true) ? $result['reason'] : 'unexpected'); break; case 'set_model': $model = $this->post('model'); if (!$ai->modelIdOk($id, $model) || !$ai->modelKnown($id, $model) || $ai->chatModels($id) === []) { throw new \DomainException('invalid'); } update_option('aiwend_connection_meta_' . $id, array_merge($ai->meta($id), ['default_model' => $model]), false); $notice = 'saved'; break; case 'test': if (empty($_POST['authorize_test'])) { throw new \DomainException('confirm_required'); } try { $result = $ai->sendTest($id, $this->post('model')); } catch (\InvalidArgumentException | \Aiwend\Usage\BudgetExceededException | \Aiwend\Usage\SpendingHaltedException) { throw new \DomainException('test_blocked'); } $notice = $result['ok'] ? 'test_done' : 'test_failed'; break; case 'set_default': $id = absint($_POST['default_connection'] ?? 0); if ($ai->inheriting() !== [] && empty($_POST['confirm_inherit'])) { throw new \DomainException('confirm_required'); } $ai->setDefault($id, $this->post('default_model')); $args = ['view' => 'list']; $notice = 'default_saved'; break; case 'clear_default': if ($ai->inheriting() !== [] && empty($_POST['confirm_inherit'])) { throw new \DomainException('confirm_required'); } $ai->clearDefault(); $args = ['view' => 'list']; $notice = 'default_cleared'; break; case 'assign': if (empty($_POST['authorize_live'])) { throw new \DomainException('confirm_required'); } $model = $this->post('model'); if ($this->plugin->ledger->balance($site, 'site') === null || !$ai->modelKnown($id, $model) || $ai->chatModels($id) === []) { throw new \DomainException('invalid'); } if (!$this->plugin->bots->setProvider($site, absint($_POST['bot'] ?? 0), $id, $model)) { throw new \DomainException('not_found'); } $notice = 'saved'; break; case 'rename': $label = $this->post('label'); if ($label === '' || mb_strlen($label) > 191) { throw new \DomainException('invalid'); } $repo->rename($site, $id, $label); $notice = 'saved'; break; case 'rotate_keys': // P88-2 (Phase 89): re-encrypt saved keys under the current encryption key. Same capability and nonce as every // connection action; the administrator confirms the new key is backed up. Per credential, ConnectionRepository // verifies before writing and leaves any row it cannot convert exactly as it was. if (empty($_POST['confirm_rotate'])) { throw new \DomainException('confirm_required'); } $before = $repo->pendingRotation($site); if ($before === 0) { $args = ['view' => 'list']; $notice = 'keys_rotation_none'; break; } $result = $repo->rotate($site); $args = ['view' => 'list']; $notice = $result['failed'] === 0 && $repo->pendingRotation($site) === 0 ? ($repo->pendingRotationEverywhere() === 0 ? 'keys_rotated' : 'keys_rotated_others_pending') : 'keys_rotation_partial'; break; case 'rotate': // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- credential kept exactly as typed (sanitising would alter it), validated and encrypted, never echoed; uploads are validated by UploadReader; colour values are sanitised one by one $repo->replaceSecret($site, $id, Input::post('secret')); delete_option('aiwend_connection_models_' . $id); update_option('aiwend_connection_meta_' . $id, array_merge($ai->meta($id), ['checked_at' => null, 'check' => null, 'check_http' => null, 'check_error' => null, 'last_test' => null]), false); $notice = 'key_replaced'; break; case 'disable': $repo->setStatus($site, $id, 'disabled'); $notice = 'connection_disabled'; break; case 'enable': $repo->setStatus($site, $id, 'unverified'); $notice = 'connection_enabled'; break; case 'delete': if (empty($_POST['confirm_delete'])) { throw new \DomainException('confirm_required'); } $resolution = sanitize_key(Input::post('resolution')); // The detach choice and the delete are one unit: if the delete is refused, the assistants already moved are put back by the // rollback (InnoDB). Database work only. The connection row is locked first, and binding an assistant (BotRepository::setProvider) // takes the same lock, so a bind and this delete of one connection are serialized (see ConnectionRepository::lock). $this->plugin->db->transaction(function () use ($site, $id, $resolution, $repo): void { $repo->lock($site, $id); // connection row first, assistants after: the same order every bind uses (no lock-order deadlock) foreach ($this->plugin->bots->all($site) as $bot) { if ($bot['connection_id'] !== $id) { continue; } // Explicit resolution chosen by the owner; never a silent move to another provider. match ($resolution) { 'inherit' => $this->plugin->bots->setInherit($site, (int) $bot['id']), 'demo' => $this->plugin->bots->setDemo($site, (int) $bot['id']), default => throw new \DomainException('confirm_required'), }; } if (!$repo->delete($site, $id)) { throw new \DomainException('connection_in_use'); } }); $ai->forget($id); // only after the delete succeeded; also clears the site default if it used this connection $args = ['view' => 'list']; $notice = 'connection_deleted'; break; } } catch (\DomainException $e) { $notice = $e->getMessage(); } catch (\InvalidArgumentException $e) { $notice = $e->getMessage() === 'key_wrong_provider' ? 'key_wrong_provider' : 'invalid'; } catch (\Throwable) { $notice = 'connection_failed'; } $this->plugin->audit->record($site, $this->plugin->currentPrincipal()->actorRef(), 'connection.' . $action, 'connection:' . $id, $notice, null, $detail); wp_safe_redirect(AdminView::url('aiwend-connections', $args + ['aiwend_notice' => $notice])); exit; } private function form(string $operation, int $id = 0, string $attrs = ''): void { echo wp_kses('
', AdminView::allowedHtml()); if (AdminView::setupMarker() > 0) { echo ''; } echo ''; } /** Truthful status: what was checked and when; never "ready" without a check. */ private function statusBadge(array $connection): string { $meta = $this->plugin->ai()->meta((int) $connection['id']); /* translators: %s: time span since the check, for example "5 minutes" */ $when = $meta['checked_at'] !== null ? sprintf(__('%s ago', 'aiwend'), human_time_diff($meta['checked_at'])) : ''; return match ($connection['status']) { /* translators: %s: how long ago the access check happened, for example "5 minutes ago" */ 'verified' => AdminView::badge(sprintf(__('Access checked %s', 'aiwend'), $when), 'success'), /* translators: %s: how long ago the access check happened, for example "5 minutes ago" */ 'failed' => AdminView::badge(sprintf(__('Check failed %s', 'aiwend'), $when), 'danger'), 'disabled' => AdminView::badge(__('Disabled', 'aiwend'), 'neutral'), default => AdminView::badge(__('Not checked', 'aiwend'), 'warn'), }; } private function modelLabel(array $m): string { $parts = [$m['id']]; if (!empty($m['name'])) { $parts[] = $m['name']; } $facts = []; $facts[] = match ($m['chat'] ?? null) { true => __('chat', 'aiwend'), false => __('not for chat', 'aiwend'), default => __('chat support not reported', 'aiwend') }; /* translators: %s: model input limit in tokens */ if (!empty($m['input_limit'])) { $facts[] = sprintf(__('input %s', 'aiwend'), number_format_i18n((int) $m['input_limit'])); } /* translators: %s: model output limit in tokens */ if (!empty($m['output_limit'])) { $facts[] = sprintf(__('output %s', 'aiwend'), number_format_i18n((int) $m['output_limit'])); } if (($m['lifecycle'] ?? null) === 'deprecated') { $facts[] = __('deprecated', 'aiwend'); } return implode(' — ', $parts) . ' (' . implode(', ', $facts) . ')'; } /** Searchable model picker (the filter is a progressive enhancement in admin.js). Models are grouped by how well they suit ordinary text chat; none is hidden. */ private function modelPicker(int $id, string $name, ?string $selected, string $fieldId): void { $ai = $this->plugin->ai(); $models = $ai->chatModels($id); $groups = ModelSuitability::group($models, $ai->testedModel($id)); $titles = [ ModelSuitability::TESTED => __('Tested on this connection', 'aiwend'), ModelSuitability::REPORTED => __('Provider reports text generation', 'aiwend'), ModelSuitability::UNCONFIRMED => __('Not confirmed for text chat', 'aiwend'), ModelSuitability::SPECIALIZED => __('Specialized: probably not for ordinary text chat', 'aiwend'), ]; echo '
'; echo ''; echo '' . esc_html(sprintf(_n('%d model from the last access check.', '%d models from the last access check.', count($models), 'aiwend'), count($models))) . ''; echo '' . esc_html__('Being in this list does not mean your account may use a model, and a model can be retired for new accounts. No model is hidden: groups only show how well each one is known to suit text chat. Send a test message to confirm a model.', 'aiwend') . '
'; } public function render(): void { if (!current_user_can(Capabilities::MANAGE)) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $view = isset($_GET['view']) ? sanitize_key(Input::get('view')) : 'list'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $id = absint($_GET['id'] ?? 0); $repo = $this->plugin->connections(); $connection = $repo !== null && $id > 0 ? $repo->find(get_current_blog_id(), $id) : null; // Without an encryption key a connection cannot be saved: the header points to the key setup below instead of an "Add" form that could not work. $actions = $repo === null ? '' . esc_html__('Set up the encryption key', 'aiwend') . '' : '' . esc_html__('Add AI connection', 'aiwend') . ''; AdminView::open($this->plugin, 'aiwend-connections', __('AI Connections', 'aiwend'), __('Connect your own AI provider account and choose which models your assistants use. Opening or saving these pages sends nothing to a provider.', 'aiwend'), $view === 'add' ? '' : $actions); AdminView::notice(); if ($repo === null) { self::setupPath(1); echo '

' . esc_html__('Nothing has been connected, checked or sent to any AI provider yet. Do this first step once; then you can add your provider key.', 'aiwend') . '

'; KeyPanel::render($this->plugin); AdminView::close(); return; } if ($view === 'add') { $this->renderAdd(); } elseif ($view === 'connection' && $connection !== null) { $this->renderConnection($connection); } else { $this->renderList(); } AdminView::close(); } /** The four setup stages with the current one marked: 1 encryption key, 2 add connection, 3 check access, 4 assign. */ private static function setupPath(int $current): void { $steps = [__('Create the encryption key', 'aiwend'), __('Add your provider key', 'aiwend'), __('Check access', 'aiwend'), __('Assign to an assistant', 'aiwend')]; echo '
    '; foreach ($steps as $i => $label) { echo '' . esc_html($label) . ''; } echo '
'; } /** Shown only while some saved key is still encrypted under an older encryption key. */ private function renderRotation(): void { $repo = $this->plugin->connections(); $pending = $repo->pendingRotation(get_current_blog_id()); $everywhere = $repo->pendingRotationEverywhere(); // the key is shared by the whole installation: retire it only when this is 0 if ($everywhere === 0) { return; } echo '

' . esc_html__('Re-encrypt saved keys', 'aiwend') . '

'; /* translators: %d: number of saved provider keys encrypted under an older encryption key */ echo '

' . esc_html(sprintf(_n('%d saved provider key is still encrypted under an older encryption key.', '%d saved provider keys are still encrypted under an older encryption key.', $pending, 'aiwend'), $pending)) . ' ' . esc_html__('Re-encrypting moves them to the current key. Each one is checked before it is saved, and any key that cannot be converted is left exactly as it was; you can run it again at any time. Keep the older key in your configuration until this notice is gone, and keep a copy of it with any older backup: restoring an older backup needs the key it was made with.', 'aiwend') . '

'; if ($everywhere > $pending) { /* translators: %d: number of saved provider keys on other sites of the network that still use an older encryption key */ echo '

' . esc_html(sprintf(_n('%d saved provider key on another site of this installation also still uses an older key.', '%d saved provider keys on other sites of this installation also still use an older key.', $everywhere - $pending, 'aiwend'), $everywhere - $pending)) . ' ' . esc_html__('Do not remove the older key until each site has re-encrypted its keys.', 'aiwend') . '

'; } if ($pending === 0) { echo '
'; return; } $this->form('rotate_keys'); echo ''; echo '

'; } private function renderList(): void { $site = get_current_blog_id(); $ai = $this->plugin->ai(); $connections = $this->plugin->connections()->list($site); $default = $ai->defaultAi(); if ($connections === []) { self::setupPath(2); echo '

' . esc_html__('Connect your AI service', 'aiwend') . '

' . esc_html__('You use your own API key from the provider you choose (OpenAI, Anthropic, Google Gemini, DeepSeek, xAI, OpenRouter or GroqCloud). Usage is billed to your provider account.', 'aiwend') . '

'; echo '

' . esc_html__('Add AI connection', 'aiwend') . '

'; } else { echo '
'; $panel = new HealthPanel($this->plugin); foreach ($connections as $c) { $meta = $ai->meta((int) $c['id']); $isDefault = $default !== null && $default['connection_id'] === (int) $c['id']; echo wp_kses(sprintf('', esc_url(AdminView::url('aiwend-connections', ['view' => 'connection', 'id' => (int) $c['id']])), esc_html($c['label']), $isDefault ? ' ' . AdminView::badge(__('Site default', 'aiwend'), 'brand') : '', esc_html(Providers::name($c['provider'])), $this->statusBadge($c), $meta['default_model'] !== null ? '' . esc_html($meta['default_model']) . '' : '' . esc_html__('Not chosen', 'aiwend') . '', count($ai->dependents((int) $c['id'])), $panel->connectionBadge((int) $c['id'])), AdminView::allowedHtml()); } echo '
' . esc_html__('Connection', 'aiwend') . '' . esc_html__('Provider', 'aiwend') . '' . esc_html__('Saved status', 'aiwend') . '' . esc_html__('Live requests (last 7 days)', 'aiwend') . '' . esc_html__('Connection default model', 'aiwend') . '' . esc_html__('Assistants', 'aiwend') . '
%2$s%3$s%4$s%5$s%8$s%6$s%7$d
'; } $this->renderRotation(); $this->renderDefault($connections); $this->renderKnowledgeStatus(); $this->renderProviders(); } private function renderDefault(array $connections): void { $ai = $this->plugin->ai(); $default = $ai->defaultAi(); $inheriting = $ai->inheriting(); echo '

' . esc_html__('Site default', 'aiwend') . '

'; echo '

' . esc_html__('Assistants set to “Use site default” follow this connection and model, and new assistants start with that setting. Assistants with their own connection are not affected.', 'aiwend') . '

'; if ($default !== null) { $c = $this->plugin->connections()->find(get_current_blog_id(), $default['connection_id']); /* translators: 1: connection label, 2: provider name, 3: model name */ echo '

' . esc_html(sprintf(__('Current: %1$s (%2$s) — model %3$s', 'aiwend'), $c['label'] ?? '?', Providers::name((string) ($c['provider'] ?? '')), $default['model'])) . '

'; } else { echo wp_kses('

' . AdminView::badge(__('No site default', 'aiwend'), 'warn') . '

', AdminView::allowedHtml()); } /* translators: %d: number of assistants that use the site default */ echo '

' . esc_html(sprintf(_n('%d assistant currently uses the site default.', '%d assistants currently use the site default.', count($inheriting), 'aiwend'), count($inheriting))) . '

'; $verified = array_filter($connections, fn(array $c): bool => $c['status'] === 'verified' && $ai->chatModels((int) $c['id']) !== []); if ($verified === []) { echo '

' . esc_html__('Check access on a connection first; then you can choose it as the site default.', 'aiwend') . '

'; return; } $this->form('set_default', 0, ' class="aw-form-grid"'); echo '
'; foreach ($verified as $c) { foreach ($ai->chatModels((int) $c['id']) as $m) { echo '' . esc_html__('Must be one of the chosen connection’s models from its last access check.', 'aiwend') . '
'; if ($inheriting !== []) { /* translators: %d: number of assistants that use the site default */ echo ''; } echo '
'; if ($default !== null) { $this->form('clear_default'); if ($inheriting !== []) { echo ''; } echo '

'; } echo ''; } private function renderKnowledgeStatus(): void { echo '

' . esc_html__('Knowledge search', 'aiwend') . '

'; echo wp_kses('

' . AdminView::badge(__('Keyword matching (active)', 'aiwend'), 'info') . ' ' . AdminView::badge(__('Semantic search: not available in this version', 'aiwend'), 'neutral') . '

', AdminView::allowedHtml()); echo '

' . esc_html__('Assistants find knowledge by matching words in the visitor’s question. Connecting an AI model improves how answers are written, but it does not add semantic search: questions phrased very differently from your content may still go unanswered.', 'aiwend') . '

'; echo '
' . esc_html__('What semantic search still needs', 'aiwend') . '
'; } private function renderProviders(): void { echo '

' . esc_html__('Supported providers', 'aiwend') . '

'; foreach (Providers::all() as $pid => $p) { $available = $p['status'] === Providers::AVAILABLE; echo wp_kses('

' . esc_html($p['name']) . ' ' . AdminView::badge($available ? __('Available to configure', 'aiwend') : __('Not implemented', 'aiwend'), $available ? 'success' : 'neutral') . '

', AdminView::allowedHtml()); echo '

' . esc_html($p['product']) . '

'; if ($available) { echo '

' . esc_html($p['embeddings']) . '

'; } else { echo '

' . esc_html($p['note']) . '

'; } echo '
'; } echo '
'; } private function renderAdd(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $chosen = isset($_GET['provider']) ? sanitize_key(Input::get('provider')) : ''; echo '

← ' . esc_html__('Back to AI Connections', 'aiwend') . '

'; echo '
  1. ' . esc_html__('Choose provider', 'aiwend') . '
  2. ' . esc_html__('Name and key', 'aiwend') . '
  3. ' . esc_html__('Review destination', 'aiwend') . '
  4. ' . esc_html__('Check access', 'aiwend') . '
  5. ' . esc_html__('Choose model', 'aiwend') . '
  6. ' . esc_html__('Test (optional)', 'aiwend') . '
  7. ' . esc_html__('Assign', 'aiwend') . '
'; $this->form('create', 0, ' class="aw-card aw-add-connection" autocomplete="off"'); echo '
' . esc_html__('1. Choose provider', 'aiwend') . '
'; foreach (Providers::all() as $pid => $p) { if ($p['status'] !== Providers::AVAILABLE) { echo wp_kses('
' . esc_html($p['name']) . ' ' . AdminView::badge(__('Not implemented', 'aiwend'), 'neutral') . '

' . esc_html($p['note']) . '

', AdminView::allowedHtml()); continue; } printf('', esc_attr($pid), checked($chosen, $pid, false), esc_html($p['name']), esc_html($p['product'])); } echo '
'; foreach (Providers::available() as $pid => $p) { echo '
'; /* translators: %s: provider name */ echo '

' . esc_html(sprintf(__('2. Name and %s API key', 'aiwend'), $p['name'])) . '

'; echo '

' . esc_html($p['key_hint']) . ' ' . esc_html__('Where to get a key', 'aiwend') . '

'; if ($pid === 'xai') { self::printXaiNotice(); } echo '

' . esc_html__('3. Where your data goes', 'aiwend') . '

'; } echo '
' . esc_html__('Use separate connections for development and production keys.', 'aiwend') . '
'; echo '
' . esc_html__('Stored encrypted on this site and never shown again. Paste it only here, never into a chat or email.', 'aiwend') . '
'; echo ''; echo '
' . esc_html__('Cancel', 'aiwend') . '
'; } /** Phase 154: display-only xAI privacy notice (the connection screens). Aiwend neither checks nor enables xAI Zero Data Retention; this text does not establish compliance. */ private static function printXaiNotice(): void { echo '

' . esc_html__('xAI keeps API requests and responses for a limited period by default under its published policy. Zero Data Retention (ZDR) is an optional team-level setting in the xAI console; Aiwend does not check or enable it. xAI\'s terms for API use may restrict sending personal data, for example what visitors type into chat or forms, unless ZDR is used. Check the terms that apply to your xAI account before using this connection with public chat or forms. Reading this notice does not make a site compliant.', 'aiwend') . '

' . (\Aiwend\Provider\XaiPublicChat::optedInForSite(get_current_blog_id()) ? esc_html__('Public chat on xAI is allowed on this site because wp-config.php defines AIWEND_XAI_ALLOW_PUBLIC_CHAT. Aiwend does not verify that your xAI account meets xAI\'s terms; that choice and its consequences are the site owner\'s.', 'aiwend') : esc_html__('Public chat on xAI is off by default on this site: visitors cannot send messages to an assistant that uses xAI. Private preview and administrator tools still work. Only the site owner can allow it, in wp-config.php (constant AIWEND_XAI_ALLOW_PUBLIC_CHAT), after reviewing the xAI terms that apply to their account. Aiwend cannot check xAI Zero Data Retention.', 'aiwend')) . '

'; } private function renderConnection(array $c): void { $id = (int) $c['id']; $ai = $this->plugin->ai(); $meta = $ai->meta($id); $p = Providers::all()[$c['provider']] ?? ['name' => $c['provider'], 'status' => Providers::NOT_IMPLEMENTED]; $models = $ai->chatModels($id); $dependents = $ai->dependents($id); echo '

← ' . esc_html__('Back to AI Connections', 'aiwend') . '

'; echo wp_kses('

' . esc_html($c['label']) . ' ' . $this->statusBadge($c) . '

', AdminView::allowedHtml()); echo '
' . esc_html__('Provider', 'aiwend') . '
' . esc_html($p['name'] . ' — ' . ($p['product'] ?? '')) . '
'; echo '
' . esc_html__('Key', 'aiwend') . '
' . esc_html__('Saved (encrypted). Fingerprint:', 'aiwend') . ' ' . esc_html(substr((string) $c['fingerprint'], 0, 12)) . '
'; echo '
' . esc_html__('Connection default model', 'aiwend') . '
' . ($meta['default_model'] !== null ? '' . esc_html($meta['default_model']) . '' : esc_html__('Not chosen', 'aiwend')) . '
'; echo '
' . esc_html__('Used by', 'aiwend') . '
' . esc_html($dependents === [] ? __('No assistants', 'aiwend') : implode(', ', array_column($dependents, 'name'))) . '
'; echo '

' . esc_html__('An access check shows the key worked at that moment. It does not guarantee ongoing availability, sufficient balance or provider limits.', 'aiwend') . '

'; (new HealthPanel($this->plugin))->connectionFacts($id); if (($c['provider'] ?? '') === 'xai') { self::printXaiNotice(); } if ($c['status'] === 'disabled') { echo '

' . esc_html__('This connection is disabled', 'aiwend') . '

' . esc_html__('Assistants using it do not answer. Enabling it requires a new access check.', 'aiwend') . '

'; $this->form('enable', $id); echo '
'; } else { // Check access echo '

' . esc_html__('Check access', 'aiwend') . '

' . esc_html($p['check'] ?? '') . '

'; $this->form('check', $id); /* translators: %s: provider host name or names */ echo ''; echo '
'; $diagnostic = $c['status'] === 'failed' ? $this->plugin->ai()->checkDiagnostic($id) : null; if ($diagnostic !== null) { /* translators: %s: the last access check's HTTP status and the provider's short error identifier, for example "http 401 authentication_error" */ echo '

' . esc_html(sprintf(__('Last check answered: %s', 'aiwend'), $diagnostic)) . '

'; } echo '
'; if ($c['status'] === 'verified' && $models !== []) { // Model echo '

' . esc_html__('Default model for this connection', 'aiwend') . '

' . esc_html($p['model_info'] ?? '') . ' ' . esc_html__('Aiwend does not recommend a “best” model and shows no prices; unknown values stay unknown.', 'aiwend') . '

'; $this->form('set_model', $id); $this->modelPicker($id, 'model', $meta['default_model'], 'aw-conn-model'); echo '
'; // Test $testModel = $meta['default_model'] ?? ($models[0]['id'] ?? ''); echo '

' . esc_html__('Send test message', 'aiwend') . '

'; echo '

' . esc_html__('Sends one fixed, synthetic message to the selected model. This is a real provider request and may be charged by the provider. It is counted against the site usage limit.', 'aiwend') . '

'; echo '
' . esc_html__('Instruction', 'aiwend') . '
' . esc_html(AiSetup::TEST_INSTRUCTION) . '
' . esc_html__('Message', 'aiwend') . '
' . esc_html(AiSetup::TEST_MESSAGE) . '
' . esc_html__('Output limit', 'aiwend') . '
' . (int) AiSetup::TEST_MAX_OUTPUT . ' ' . esc_html__('tokens', 'aiwend') . '
'; $this->form('test', $id); $this->modelPicker($id, 'model', $testModel, 'aw-test-model'); /* translators: %s: provider name */ echo ''; echo '
'; if ($meta['last_test'] !== null) { $t = $meta['last_test']; /* translators: 1: date and time, 2: model name, 3: response time in milliseconds */ echo '

' . esc_html(!empty($t['ok']) ? __('Last test succeeded', 'aiwend') : __('Last test did not complete', 'aiwend')) . ' — ' . esc_html(sprintf(__('%1$s, model %2$s, %3$d ms', 'aiwend'), wp_date('Y-m-d H:i', (int) ($t['at'] ?? 0)), (string) ($t['model'] ?? ''), (int) ($t['latency_ms'] ?? 0))) . '

'; if (!empty($t['ok'])) { /* translators: 1: input tokens, 2: output tokens */ echo '

' . esc_html__('Reply:', 'aiwend') . ' ' . esc_html((string) $t['text']) . '

' . esc_html(sprintf(__('Provider-reported usage: %1$d input + %2$d output tokens.', 'aiwend'), (int) $t['input'], (int) $t['output'])) . '

'; } else { /* translators: %s: short error type reported by the transport */ echo '

' . esc_html(sprintf(__('Error type: %s. If the request reached the provider, its usage is uncertain and is listed under Usage for review.', 'aiwend'), (string) ($t['error'] ?? ''))) . '

'; } echo '
'; } echo '
'; // Assign $bots = $this->plugin->bots->all(get_current_blog_id()); if ($bots !== []) { echo '

' . esc_html__('Assign to an assistant', 'aiwend') . '

' . esc_html__('Gives the assistant its own connection and model, independent of the site default. You can also do this in the assistant’s “AI & model” tab.', 'aiwend') . '

'; $this->form('assign', $id); echo '
'; $this->modelPicker($id, 'model', $meta['default_model'], 'aw-assign-model'); /* translators: %s: provider name */ echo ''; echo '
'; } } } // Manage echo '

' . esc_html__('Manage connection', 'aiwend') . '

'; $this->form('rename', $id, ' class="aw-manage-item"'); echo '
'; $this->form('rotate', $id, ' class="aw-manage-item" autocomplete="off"'); echo '
' . esc_html__('The previous check and model list stop applying; assistants using this connection pause until access is checked again. The old key is not revoked at the provider.', 'aiwend') . '
'; if ($c['status'] !== 'disabled') { $this->form('disable', $id, ' class="aw-manage-item"'); /* translators: %d: number of assistants that would stop answering */ echo '

' . esc_html(sprintf(_n('Disabling stops %d assistant from answering until the connection is enabled and checked again, or the assistant is reassigned. Nothing is moved to another provider.', 'Disabling stops %d assistants from answering until the connection is enabled and checked again, or they are reassigned. Nothing is moved to another provider.', count($dependents), 'aiwend'), count($dependents))) . '

'; } $this->form('delete', $id, ' class="aw-manage-item"'); $explicit = array_filter($dependents, static fn(array $b): bool => $b['connection_id'] !== null); $default = $ai->defaultAi(); echo '

' . esc_html__('Removes the connection and its encrypted key from this site. It does not revoke the key at the provider.', 'aiwend') . '

'; if ($explicit !== []) { /* translators: %s: comma-separated names of the assistants assigned to the connection */ echo '
' . esc_html(sprintf(__('Assistants assigned to it: %s. Choose what happens to them:', 'aiwend'), implode(', ', array_column($explicit, 'name')))) . ''; echo ''; echo '
'; } if ($default !== null && $default['connection_id'] === $id) { /* translators: %d: number of assistants that use the site default */ echo '

' . esc_html(sprintf(_n('This is the site default. Removing it clears the default, and %d assistant using the site default will stop answering until a new default is set.', 'This is the site default. Removing it clears the default, and %d assistants using the site default will stop answering until a new default is set.', count($ai->inheriting()), 'aiwend'), count($ai->inheriting()))) . '

'; } echo '
'; echo '
'; } }