tool. */ public const ACTIONS = [ 'backfill_run' => 'backfill', 'backfill_reset' => 'backfill', 'embed_estimate' => 'embeddings', 'embed_prepare' => 'embeddings', 'embed_test' => 'embeddings', 'embed_run' => 'embeddings', 'embed_reconcile' => 'embeddings', 'embed_retry' => 'embeddings', 'embed_rebuild' => 'embeddings', 'embed_purge' => 'embeddings', 'embed_move_cancel' => 'embeddings', 'embed_cleanup_old' => 'embeddings', 'embed_cleanup_keep' => 'embeddings', 'related_build' => 'related', 'insights_score' => 'insights', 'insights_analyse' => 'insights', 'insights_clear' => 'insights', 'licence_accept' => 'models', 'licence_recheck' => 'models', 'refresh_models' => 'models', 'gateway_test' => 'gateway', 'prompt_test' => 'prompts', 'setup_apply' => 'setup', 'setup_skip' => 'setup', 'test_connection' => 'connection', 'reset' => 'plugin', 'activity_clear' => 'activity', 'chat_save' => 'chat', 'chat_clear' => 'chat', 'chat_toggle' => 'chat', ]; /** Nonce actions that differ from "aipcf_" + the action. */ private const NONCES = [ 'test_connection' => 'aipcf_admin', ]; /** Settings whose values are never written to the log. */ private const SECRETS = ['api_key', 'gateway_token', 'qdrant_api_key', 'pg_password']; private const NOTICE_STATUS = ['success' => 'ok', 'warning' => 'warning', 'error' => 'failed', 'info' => 'info']; /** How bad a status is, for keeping the worse of two. */ private const RANK = ['info' => 0, 'ok' => 1, 'warning' => 2, 'failed' => 3]; /** @var array|null The entry this request is building. */ private static ?array $pending = null; /** Neurons recorded in this request, in total and by task. */ private static float $neurons = 0.0; /** @var array */ private static array $taskNeurons = []; /** Embedding neurons already put on an entry in this request (WP-CLI runs many batches). */ private static float $embedLogged = 0.0; /** @var array|null Prompt overrides before a delete_option, for the diff. */ private static ?array $promptsBefore = null; private static bool $booted = false; public static function boot(): void { if (self::$booted) { return; } self::$booted = true; foreach (array_keys(self::ACTIONS) as $action) { add_action('admin_post_aipcf_' . $action, static function () use ($action): void { self::begin($action); }, 0); } add_action('set_transient', [self::class, 'onTransient'], 10, 2); add_action('add_option_' . AIPCF_SETTINGS_OPTION, [self::class, 'onSettingsAdded'], 10, 2); add_action('update_option_' . AIPCF_SETTINGS_OPTION, [self::class, 'onSettingsUpdated'], 10, 2); add_action('add_option_' . Prompts::OPTION, [self::class, 'onPromptsAdded'], 10, 2); add_action('update_option_' . Prompts::OPTION, [self::class, 'onPromptsUpdated'], 10, 2); add_action('delete_option', [self::class, 'beforeDelete']); add_action('delete_option_' . Prompts::OPTION, [self::class, 'onPromptsDeleted']); add_action('aipcf_usage_recorded', [self::class, 'onUsage'], 10, 4); add_action('aipcf_embed_tick_done', [self::class, 'onEmbedTick'], 5); add_action('aipcf_gateway_marked_down', [self::class, 'onGatewayDown']); add_action('aipcf_report_sent', [self::class, 'onReportSent']); add_action('aipcf_version_changed', [self::class, 'onVersionChanged']); add_action('shutdown', [self::class, 'flush']); } /** * Opens the entry for a plugin button, before its handler runs. */ public static function begin(string $action): void { if (!current_user_can('manage_options')) { return; } $nonce = isset($_REQUEST['_wpnonce']) && is_string($_REQUEST['_wpnonce']) ? sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])) : ''; if (!wp_verify_nonce($nonce, self::NONCES[$action] ?? 'aipcf_' . $action)) { return; // the handler refuses it; nothing happened } // The prompt test link also resets a prompt to its default. if ($action === 'prompt_test' && !empty($_GET['reset'])) { $action = 'prompt_reset'; } self::start(self::ACTIONS[$action] ?? 'prompts', $action); } /** * Starts the entry this request builds; it is written on shutdown, or by flush(). */ public static function start(string $tool, string $action, string $status = 'info'): void { [$actor, $user] = ActivityLog::actor(); self::$pending = [ 'tool' => $tool, 'action' => $action, 'status' => $status, 'message' => '', 'items' => [], 'items_ok' => 0, 'items_failed' => 0, 'neurons' => 0.0, 'actor' => $actor, 'user_id' => $user, 'noticed' => false, 'worked' => '', ]; } public static function pending(): bool { return self::$pending !== null; } /** * Sets the outcome of the entry this request builds, as a notice would. */ public static function outcome(string $status, string $message): void { if (self::$pending === null) { return; } self::$pending['status'] = self::worse($status, (string) self::$pending['worked']); self::$pending['message'] = $message; self::$pending['noticed'] = true; } /** * Records work the plugin did. Inside a person's action (Run one batch now) it becomes part * of that entry; otherwise it is an entry of its own. * * @param array $entry * @param list> $items */ public static function record(array $entry, array $items = []): void { if (self::$pending !== null) { $status = (string) ($entry['status'] ?? 'ok'); self::$pending['items'] = array_merge(self::$pending['items'], $items); self::$pending['items_ok'] += (int) ($entry['items_ok'] ?? 0); self::$pending['items_failed'] += (int) ($entry['items_failed'] ?? 0); // The worst outcome of the work done, so a cheerful notice cannot hide a failed item. self::$pending['worked'] = self::worse($status, (string) self::$pending['worked']); self::$pending['status'] = self::worse($status, self::$pending['noticed'] ? (string) self::$pending['status'] : ''); if (!self::$pending['noticed']) { self::$pending['message'] = (string) ($entry['message'] ?? self::$pending['message']); } return; // its neurons are already counted by onUsage() } ActivityLog::add($entry, $items); } /** * Neurons recorded so far in this request; the difference over a piece of work is what it cost. */ public static function neurons(): float { return self::$neurons; } public static function taskNeurons(string $task): float { return self::$taskNeurons[$task] ?? 0.0; } /** * The handler's notice is the action's outcome. * * @param mixed $transient * @param mixed $value */ public static function onTransient($transient, $value): void { if (self::$pending === null || $transient !== 'aipcf_notice_' . get_current_user_id() || !is_array($value)) { return; } $type = (string) ($value['type'] ?? 'info'); self::outcome(self::NOTICE_STATUS[$type] ?? 'info', (string) ($value['message'] ?? '')); } /** * @param mixed $option * @param mixed $value */ public static function onSettingsAdded($option, $value): void { self::onSettingsUpdated([], $value); } /** * Each changed setting, old and new. Secrets are noted as changed without their values. * * @param mixed $old * @param mixed $new */ public static function onSettingsUpdated($old, $new): void { self::changes(self::settingsDiff(is_array($old) ? $old : [], is_array($new) ? $new : [])); } /** * @param mixed $option * @param mixed $value */ public static function onPromptsAdded($option, $value): void { self::onPromptsUpdated([], $value); } /** * A prompt edited on the Prompts tab, or reset to its default. * * @param mixed $old * @param mixed $new */ public static function onPromptsUpdated($old, $new): void { $old = is_array($old) ? $old : []; $new = is_array($new) ? $new : []; $items = []; foreach (array_unique(array_merge(array_keys($old), array_keys($new))) as $key) { $a = (string) ($old[$key] ?? ''); $b = (string) ($new[$key] ?? ''); if ($a !== $b) { $items[] = ['object_type' => 'prompt', 'field' => (string) $key, 'old' => $a, 'new' => $b, 'result' => 'changed', 'reason' => $b === '' ? __('Back to the default prompt.', 'aipcf-ai-provider-for-cloudflare') : '']; } } self::changes($items); } /** * @param mixed $option */ public static function beforeDelete($option): void { if ($option === Prompts::OPTION) { $value = get_option(Prompts::OPTION, []); self::$promptsBefore = is_array($value) ? $value : []; } } public static function onPromptsDeleted(): void { self::onPromptsUpdated(self::$promptsBefore ?? [], []); self::$promptsBefore = null; } /** * Setting or prompt changes: part of the action under way, an entry of their own for * WP-CLI, or the settings form's entry (written on shutdown, so a save that updates twice * is one entry). Changes the plugin makes by itself on cron are not a person's doing. * * @param list> $items */ private static function changes(array $items): void { if ($items === []) { return; } if (self::$pending === null) { [$actor] = ActivityLog::actor(); if ($actor === 'cli') { ActivityLog::add(['tool' => 'settings', 'action' => 'settings_saved', 'status' => 'ok'], $items); return; } if (!($actor === 'user' && current_user_can('manage_options'))) { return; } self::start('settings', 'settings_saved', 'ok'); } self::$pending['items'] = array_merge(self::$pending['items'], $items); } /** * Settings that differ, with both sides filled from the defaults so a key that was never * saved before is not a change, and 0, "0" and false count as the same value. * * @param array $old * @param array $new * @return list> */ public static function settingsDiff(array $old, array $new): array { $defaults = default_settings(); $old = array_merge($defaults, $old); $new = array_merge($defaults, $new); $items = []; foreach (array_unique(array_merge(array_keys($old), array_keys($new))) as $key) { $key = (string) $key; $a = $old[$key] ?? null; $b = $new[$key] ?? null; if (self::same($a, $b)) { continue; } $secret = in_array($key, self::SECRETS, true) || (bool) preg_match('/(_key|token|password|secret)$/', $key); $reason = $secret ? __('A secret: its value is not logged.', 'aipcf-ai-provider-for-cloudflare') : ''; if (!$secret) { [$a, $hidA] = self::withoutCredentials($a); [$b, $hidB] = self::withoutCredentials($b); if ($hidA || $hidB) { $reason = __('Credentials and query in the address are not logged.', 'aipcf-ai-provider-for-cloudflare'); } } $items[] = [ 'object_type' => 'setting', 'field' => $key, 'old' => $secret ? '' : $a, 'new' => $secret ? '' : $b, 'result' => $secret ? 'secret' : 'changed', 'reason' => $reason, ]; } return $items; } /** * A URL without its user name, password and query string, which can hold credentials * (a Qdrant address with user:pass@ or ?api-key=). * * @param mixed $value * @return array{0: mixed, 1: bool} */ private static function withoutCredentials($value): array { if (!is_string($value) || !preg_match('#^[a-z][a-z0-9+.-]*://#i', $value)) { return [$value, false]; } $p = wp_parse_url($value); if (!is_array($p) || (!isset($p['user']) && !isset($p['pass']) && !isset($p['query']))) { return [$value, false]; } $clean = ($p['scheme'] ?? '') . '://' . ($p['host'] ?? '') . (isset($p['port']) ? ':' . $p['port'] : '') . ($p['path'] ?? ''); return [$clean, true]; } /** * Counts this request's neurons, and notes when today's use crosses the daily budget (or, * with no budget set, the free daily allowance). * * @param mixed $modelId * @param mixed $neurons * @param mixed $task * @param mixed $day */ public static function onUsage($modelId, $neurons, $task, $day): void { $n = (float) $neurons; $task = (string) $task; self::$neurons += $n; self::$taskNeurons[$task] = (self::$taskNeurons[$task] ?? 0.0) + $n; if (self::$pending !== null) { self::$pending['neurons'] += $n; } $after = is_array($day) ? (float) ($day['neurons'] ?? 0) : 0.0; $before = $after - $n; $budget = UsageTracker::budgetEnabled(); $limit = $budget ? UsageTracker::budget() : UsageTracker::FREE_TIER; if ($n <= 0 || $before >= $limit || $after < $limit) { return; } ActivityLog::add([ 'actor' => 'plugin', 'tool' => 'budget', 'action' => $budget ? 'budget_reached' : 'free_passed', 'status' => 'warning', 'message' => $budget /* translators: %s: daily budget in neurons */ ? sprintf(__('Today\'s use reached the daily budget of %s neurons. The budget setting decides what pauses until midnight UTC.', 'aipcf-ai-provider-for-cloudflare'), number_format($limit)) /* translators: %s: free daily allowance in neurons */ : sprintf(__('Today\'s use passed the free daily allowance of %s neurons. Cloudflare bills what goes beyond it on Workers Paid.', 'aipcf-ai-provider-for-cloudflare'), number_format($limit)), ]); } /** * An embedding run that changed something. * * @param mixed $out */ public static function onEmbedTick($out): void { if (!is_array($out) || empty($out['ran'])) { return; } // Only this run's neurons: WP-CLI and one cron request can run several. $neurons = self::taskNeurons('embed') - self::$embedLogged; self::$embedLogged = self::taskNeurons('embed'); $done = (int) ($out['embedded'] ?? 0) + (int) ($out['deleted'] ?? 0); $failed = (int) ($out['failed'] ?? 0); if ($done + $failed === 0) { return; } $message = sprintf( /* translators: 1: embedded, 2: unchanged, 3: removed, 4: failed */ __('Embedded %1$d, unchanged %2$d, removed %3$d, failed %4$d.', 'aipcf-ai-provider-for-cloudflare'), (int) ($out['embedded'] ?? 0), (int) ($out['unchanged'] ?? 0), (int) ($out['deleted'] ?? 0), $failed ); if (!empty($out['message'])) { $message .= ' ' . (string) $out['message']; } self::record([ 'tool' => 'embeddings', 'action' => 'embed_batch', 'status' => $failed > 0 ? ($done > 0 ? 'warning' : 'failed') : 'ok', 'message' => $message, 'items_ok' => $done, 'items_failed' => $failed, 'neurons' => $neurons, ]); } /** * @param mixed $reason */ public static function onGatewayDown($reason): void { ActivityLog::add([ 'actor' => 'plugin', 'tool' => 'gateway', 'action' => 'gateway_down', 'status' => 'failed', 'message' => sprintf( /* translators: %s: the error */ __('Requests go straight to Workers AI for now. %s', 'aipcf-ai-provider-for-cloudflare'), (string) $reason ), ]); } /** * @param mixed $report */ public static function onReportSent($report): void { $month = is_array($report) ? (string) ($report['month'] ?? '') : ''; ActivityLog::add([ 'actor' => 'plugin', 'tool' => 'report', 'action' => 'report_sent', 'status' => 'ok', /* translators: %s: month, like 2026-09 */ 'message' => sprintf(__('The usage report for %s was emailed to the site\'s admin email address.', 'aipcf-ai-provider-for-cloudflare'), $month), ]); } /** * @param mixed $previous */ public static function onVersionChanged($previous): void { $previous = (string) $previous; $newest = ActivityLog::installed() ? ActivityLog::newest() : null; if ($previous === '' && $newest !== null && $newest['action'] === 'reset') { return; // Reset clears the stored version too; that is not an activation } ActivityLog::add([ 'actor' => 'plugin', 'tool' => 'plugin', 'action' => $previous === '' ? 'activated' : 'updated', 'status' => 'info', 'message' => $previous === '' /* translators: %s: plugin version */ ? sprintf(__('Version %s.', 'aipcf-ai-provider-for-cloudflare'), AIPCF_VERSION) /* translators: 1: previous version, 2: new version */ : sprintf(__('From %1$s to %2$s.', 'aipcf-ai-provider-for-cloudflare'), $previous, AIPCF_VERSION), ]); } /** * Writes the entry this request built. */ public static function flush(): void { if (self::$pending === null) { return; } $p = self::$pending; self::$pending = null; ActivityLog::add([ 'tool' => $p['tool'], 'action' => $p['action'], 'status' => $p['status'], 'message' => $p['message'], 'items_ok' => $p['items_ok'], 'items_failed' => $p['items_failed'], 'neurons' => $p['neurons'], 'actor' => $p['actor'], 'user_id' => $p['user_id'], ], $p['items']); } /** * For tests: forget this request's state. */ public static function clear(): void { self::$pending = null; self::$neurons = 0.0; self::$taskNeurons = []; self::$embedLogged = 0.0; self::$promptsBefore = null; } private static function worse(string $a, string $b): string { if ($b === '' || !isset(self::RANK[$b])) { return $a; } return (self::RANK[$a] ?? 0) >= self::RANK[$b] ? $a : $b; } /** * @param mixed $a * @param mixed $b */ private static function same($a, $b): bool { return wp_json_encode(self::normal($a)) === wp_json_encode(self::normal($b)); } /** * A value as the settings mean it: null and '' alike, booleans and numeric strings as * numbers, lists in order, maps by key. * * @param mixed $v * @return mixed */ private static function normal($v) { if ($v === null) { return ''; } if (is_bool($v)) { return $v ? '1' : '0'; } if (is_int($v) || is_float($v) || (is_string($v) && is_numeric($v))) { return (string) ((float) $v + 0); } if (is_array($v)) { $v = array_map([self::class, 'normal'], $v); if (array_is_list($v)) { sort($v); } else { ksort($v); } } return $v; } }