route('/modules', 'GET', 'modules.manage', [$this, 'listModules']); $this->route('/modules/(?P[a-z][a-z0-9_]{1,31})', 'POST', 'modules.manage', [$this, 'setModule'], [ 'enabled' => ['type' => 'boolean', 'required' => true], ]); $this->route('/connections', 'GET', 'connections.manage', [$this, 'listConnections']); $this->route('/connections', 'POST', 'connections.manage', [$this, 'createConnection'], [ 'provider' => ['type' => 'string', 'required' => true, 'pattern' => '^[a-z][a-z0-9_-]{1,63}$'], 'label' => ['type' => 'string', 'required' => true, 'maxLength' => 191], 'secret' => ['type' => 'string', 'required' => true, 'maxLength' => 4096], ]); $this->route('/connections/(?P\d+)', 'DELETE', 'connections.manage', [$this, 'deleteConnection']); $this->route('/usage', 'GET', 'usage.view', [$this, 'usage']); $this->route('/settings/budget', 'POST', 'settings.manage', [$this, 'setBudget'], [ 'site_limit_units' => ['type' => 'integer', 'minimum' => 0], 'session_limit_units' => ['type' => 'integer', 'minimum' => 0], 'halted' => ['type' => 'boolean'], ]); $this->route('/jobs/health', 'GET', 'jobs.view', [$this, 'jobsHealth']); } public function listModules(): \WP_REST_Response { $out = []; foreach ($this->plugin->modules->all() as $id => $m) { $out[] = [ 'id' => $id, 'label' => $m->label(), 'enabled' => $this->plugin->modules->isEnabled($id), 'available' => $m->prerequisitesMet(), 'requirement' => $m->prerequisitesMet() ? null : $m->prerequisiteMessage(), ]; } return new \WP_REST_Response($out); } public function setModule(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $id = (string) $r['id']; try { $r['enabled'] ? $this->plugin->modules->enable($id) : $this->plugin->modules->disable($id); } catch (ModuleException $e) { return new \WP_Error('aiwend_module', $e->getMessage(), ['status' => 409]); } $this->audit('modules.set', "module:$id", $r['enabled'] ? 'enabled' : 'disabled'); return $this->listModules(); } public function listConnections(): \WP_REST_Response|\WP_Error { $repo = $this->plugin->connections(); if ($repo === null) { return Errors::response('setup_required', 503); } return new \WP_REST_Response($repo->list(get_current_blog_id())); } /** Write-only secret: the response contains only the fingerprint, never the credential. */ public function createConnection(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $repo = $this->plugin->connections(); if ($repo === null) { return Errors::response('setup_required', 503); } try { $id = $repo->create(get_current_blog_id(), (string) $r['provider'], sanitize_text_field((string) $r['label']), (string) $r['secret']); } catch (\InvalidArgumentException $e) { return new \WP_Error('aiwend_invalid', $e->getMessage(), ['status' => 400]); } $this->audit('connections.create', "connection:$id", 'ok'); return new \WP_REST_Response($repo->find(get_current_blog_id(), $id), 201); } public function deleteConnection(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $repo = $this->plugin->connections(); if ($repo === null) { return Errors::response('setup_required', 503); } $id = (int) $r['id']; if (!$repo->delete(get_current_blog_id(), $id)) { return Errors::response('not_found', 404); } $this->plugin->ai()->forgetHealth($id); // after the confirmed delete only: a refused or repeated delete leaves every record alone $this->audit('connections.delete', "connection:$id", 'ok'); return new \WP_REST_Response(['deleted' => true]); } public function usage(): \WP_REST_Response { $site = get_current_blog_id(); return new \WP_REST_Response([ 'site' => $this->plugin->ledger->balance($site, 'site'), 'halted' => $this->plugin->ledger->isHalted(), 'client_daily_units' => \Aiwend\Usage\ClientBudget::dailyUnits($this->plugin->options->get(\Aiwend\Usage\ClientBudget::OPTION_DAILY)), 'client_share_percent' => \Aiwend\Usage\ClientBudget::sharePercent($this->plugin->options->get(\Aiwend\Usage\ClientBudget::OPTION_SHARE)), // Counts only (no addresses): site, client, shared, unknown. 'exhaustion' => $this->plugin->alerts->current(), 'uncertain' => array_map(static fn($r) => ['request_id' => $r->requestId, 'held_units' => $r->reservedUnits, 'note' => $r->note], $this->plugin->ledger->uncertain($site)), // Estimates and held amounts are kept apart from usage the provider reported or an administrator entered. 'by_basis' => $this->plugin->ledger->usageSummary($site), 'units' => 'tokens; not money; not matched against provider invoices automatically', ]); } public function setBudget(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $site = get_current_blog_id(); // The same rule as the Usage screen (Usage\ConversationBudget), checked before anything is changed: a conversation limit no // answer can fit is refused with its figures, never saved or adjusted silently. $session = null; if ($r->has_param('session_limit_units')) { $lengths = array_values(array_map(static fn(array $b): int => (int) ($b['max_output_tokens'] ?? \Aiwend\Usage\ConversationBudget::DEFAULT_MAX_ANSWER), $this->plugin->bots->all($site))); $session = \Aiwend\Usage\ConversationBudget::assess($r['session_limit_units'], $lengths); if (in_array($session['status'], [\Aiwend\Usage\ConversationBudget::REFUSED, \Aiwend\Usage\ConversationBudget::INVALID], true)) { return new \WP_Error('aiwend_session_limit_too_low', __('The limit per conversation must be a whole number of at least the least one answer can reserve; no conversation could be answered with less. Nothing was changed.', 'aiwend'), ['status' => 400, 'least_units' => $session['floor'], 'provider_least_units' => $session['provider_floor'], 'max_answer_tokens' => $session['max_answer']]); } } if ($r->has_param('site_limit_units')) { $this->plugin->ledger->setLimit($site, 'site', (int) $r['site_limit_units']); } if ($session !== null) { $this->plugin->options->set(\Aiwend\Usage\ConversationBudget::OPTION, $session['value']); } // Phase 89: per-visitor daily limit and share of the site limit; an unusable value falls back to the default. if ($r->has_param('client_daily_units')) { $this->plugin->options->set(\Aiwend\Usage\ClientBudget::OPTION_DAILY, \Aiwend\Usage\ClientBudget::dailyUnits($r['client_daily_units'])); } if ($r->has_param('client_share_percent')) { $this->plugin->options->set(\Aiwend\Usage\ClientBudget::OPTION_SHARE, \Aiwend\Usage\ClientBudget::sharePercent($r['client_share_percent'])); } if ($r->has_param('halted')) { $this->plugin->ledger->halt((bool) $r['halted']); } $this->plugin->alerts->clearSite(); $this->audit('settings.budget', 'site', 'ok'); return $this->usage(); } public function jobsHealth(): \WP_REST_Response { $health = $this->plugin->queue->health(get_current_blog_id()); $health['cron_next_run'] = wp_next_scheduled(Plugin::CRON_HOOK) ?: null; $health['cron_event_count'] = \Aiwend\WordPress\CronDiagnostics::count(); $last = (int) get_option(\Aiwend\WordPress\CronDiagnostics::LAST_RUN_OPTION, 0); $health['jobs_last_run'] = $last ?: null; $health['jobs_last_run_age'] = $last ? time() - $last : null; $health['cron_diagnostics_suppressed'] = (int) get_option(\Aiwend\WordPress\CronDiagnostics::SUPPRESSED_OPTION, 0); $health['wp_cron_disabled'] = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON; $health['database'] = $this->plugin->databaseCompatibility(); $health['mock_provider'] = $this->plugin->mockProviderEnabled(); return new \WP_REST_Response($health); } /** @param callable(\WP_REST_Request): mixed $callback @param array $args */ private function route(string $path, string $method, string $operation, callable $callback, array $args = []): void { register_rest_route(self::NS, $path, [ 'methods' => $method, 'callback' => $callback, 'args' => $args, 'permission_callback' => function () use ($operation): bool|\WP_Error { $decision = $this->plugin->policy->check($this->plugin->currentPrincipal(), $operation); return $decision->allowed ? true : Errors::response($decision->code, $decision->httpStatus()); }, ]); } private function audit(string $action, string $object, string $outcome): void { $p = $this->plugin->currentPrincipal(); $this->plugin->audit->record($p->siteId, $p->actorRef(), $action, $object, $outcome); } }