'overview', 'models' => 'models', 'budget' => 'budget', 'gateway' => 'gateway', 'backfill' => 'backfill', 'prompts' => 'prompts', 'embeddings' => 'embeddings', 'content' => 'content', ]; /** * Each tab's label, description and icon, as literal strings so they can be translated. * * @return array */ public static function tabMeta(): array { return [ 'overview' => [__('Overview', 'aipcf-ai-provider-for-cloudflare'), __('Status and quick actions', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-dashboard'], 'models' => [__('Models', 'aipcf-ai-provider-for-cloudflare'), __('Current model setup', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-layout'], 'budget' => [__('Budget & cache', 'aipcf-ai-provider-for-cloudflare'), __('Daily budget and caching', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-chart-bar'], 'gateway' => [__('AI Gateway', 'aipcf-ai-provider-for-cloudflare'), __('Edge cache, logs, analytics', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-cloud'], 'backfill' => [__('Backfill', 'aipcf-ai-provider-for-cloudflare'), __('Fill missing meta and alt text', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-update'], 'prompts' => [__('Prompts', 'aipcf-ai-provider-for-cloudflare'), __('What the plugin asks', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-editor-quote'], 'embeddings' => [__('Embeddings', 'aipcf-ai-provider-for-cloudflare'), __('Your content in a vector store', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-database'], 'content' => [__('Content tools', 'aipcf-ai-provider-for-cloudflare'), __('Related posts, links, search, insights', 'aipcf-ai-provider-for-cloudflare'), 'dashicons-admin-links'], ]; } /** Settings (2.0): its sections, each saved as one settings "tab". */ public const SETTINGS_SLUG = 'aipcf-settings'; /** Section => [the tab it saves as, the older Settings API pages it shows until it moves]. */ public const SECTIONS = [ 'connection' => ['connection', ['overview', 'gateway']], 'models' => ['models', ['models']], 'budget' => ['budget', ['budget']], 'content-index' => ['embeddings', ['embeddings']], 'prompts' => ['prompts', ['prompts']], ]; /** * The plugin's admin pages by slug, for the shared styles, the page background and the * settings messages. * * @return list */ public static function pageSlugs(): array { return [self::PAGE_SLUG, self::SETTINGS_SLUG, FeaturesPage::SLUG, HelpPage::SLUG, InsightsTab::SLUG, ActivityPage::SLUG, AssistantPage::SLUG, LinkAssistant::SLUG, Wizard::SLUG]; } public static function isPluginScreen(): bool { $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return in_array($page, self::pageSlugs(), true); } public static function panel(string $collapsedTitle = ''): array { if ($collapsedTitle !== '') { return [ 'before_section' => '
' . esc_html($collapsedTitle) . '', 'after_section' => '
', ]; } return ['before_section' => '
', 'after_section' => '
']; } public const TAB_KEYS = [ 'overview' => ['account_id', 'api_key'], 'models' => ['preferred_model', 'light_model', 'light_tasks', 'image_model', 'vision_model'], 'budget' => ['budget_enabled', 'budget_neurons', 'budget_action', 'budget_email', 'budget_account', 'cloudflare_only', 'gateway_cache_ttl', 'local_cache_ttl'], 'gateway' => ['gateway_id', 'gateway_token', 'gateway_log_payload', 'dynamic_route'], 'backfill' => ['backfill_enabled', 'backfill_tasks', 'backfill_daily_neurons', 'backfill_batch'], 'embeddings' => EmbeddingSettings::KEYS, 'content' => \WordPress\CloudflareWorkersAiProvider\Features\Settings::KEYS, 'prompts' => [], ]; /** The content tools' settings, one feature page each (2.0), by key prefix. */ public const FEATURE_TABS = ['related', 'search', 'links', 'insights', 'agents']; /** * The settings a screen ("tab") owns. Saving a screen keeps every other setting as it is; * an owned checkbox that is not sent means off. * * @return list */ public static function tabKeys(string $tab): array { if ($tab === 'connection') { return array_merge(self::TAB_KEYS['overview'], self::TAB_KEYS['gateway']); } if (in_array($tab, self::FEATURE_TABS, true)) { return array_values(array_filter( \WordPress\CloudflareWorkersAiProvider\Features\Settings::KEYS, static fn(string $k): bool => strpos($k, $tab . '_') === 0 )); } return self::TAB_KEYS[$tab] ?? []; } /** Whether a screen name is one the settings know (an unknown one must not blank anything). */ public static function isTab(string $tab): bool { return isset(self::TAB_KEYS[$tab]) || $tab === 'connection' || in_array($tab, self::FEATURE_TABS, true); } public static function tabPage(string $tab): string { return 'aipcf-tab-' . $tab; } public static function currentTab(): string { $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return isset(self::TABS[$tab]) ? $tab : 'overview'; } /** * Where an old settings tab lives now (2.0): Home, a Settings section or a Features page. * Every link to a tab goes through here, so older links land in the right place. */ public static function tabUrl(string $tab): string { $map = [ 'models' => self::settingsUrl('models'), 'budget' => self::settingsUrl('budget'), 'gateway' => self::settingsUrl('connection'), 'prompts' => self::settingsUrl('prompts'), 'embeddings' => self::settingsUrl('content-index'), 'backfill' => FeaturesPage::url('backfill'), 'content' => FeaturesPage::url(), ]; return $map[$tab] ?? admin_url('admin.php?page=' . self::PAGE_SLUG); } public static function settingsUrl(string $section = 'connection'): string { return add_query_arg(['page' => self::SETTINGS_SLUG, 'section' => $section], admin_url('admin.php')); } public static function currentSection(): string { $section = isset($_GET['section']) ? sanitize_key(wp_unslash((string) $_GET['section'])) : 'connection'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return isset(self::SECTIONS[$section]) ? $section : 'connection'; } /** * One sentence on where to enter the connection details. With the core Connectors screen * the API token lives there and only the Account ID stays on Settings > Connection. * * @param bool $here True when the message shows on Settings > Connection, above the fields. */ public static function credentialsHint(bool $here = false): string { // A token saved in this plugin earlier still wins over the Connectors screen. $pluginToken = (string) (get_plugin_settings()['api_key'] ?? '') !== ''; if (core_connector_is_available() && $pluginToken) { // The saved token is in use and the page shows no token field: only the Account ID can be missing. return $here ? __('Add your Account ID below.', 'aipcf-ai-provider-for-cloudflare') : __('Add your Account ID on Settings > Connection.', 'aipcf-ai-provider-for-cloudflare'); } if (core_connector_is_available()) { return $here ? __('Add your API token on Settings > Connectors and your Account ID below.', 'aipcf-ai-provider-for-cloudflare') : __('Add your API token on Settings > Connectors and your Account ID on Settings > Connection.', 'aipcf-ai-provider-for-cloudflare'); } return $here ? __('Add your Account ID and API token below.', 'aipcf-ai-provider-for-cloudflare') : __('Add your Account ID and API token on Settings > Connection.', 'aipcf-ai-provider-for-cloudflare'); } public static function pageUrl(string $fragment = ''): string { return admin_url('admin.php?page=' . self::PAGE_SLUG) . ($fragment !== '' ? '#' . $fragment : ''); } public static function boot(): void { static $booted = false; if ($booted) { return; } $booted = true; add_action('admin_menu', [self::class, 'registerMenu']); add_action('admin_menu', [self::class, 'registerMenuEnd'], 30); add_filter('submenu_file', [self::class, 'highlightMenu']); add_filter('parent_file', [self::class, 'highlightParent']); add_action('current_screen', [self::class, 'titleHiddenPage']); add_action('admin_init', [self::class, 'redirectLegacyUrl']); add_action('admin_post_aipcf_reset', [self::class, 'handleReset']); add_action('admin_init', [self::class, 'registerSettings']); add_action('admin_init', [self::class, 'maybeHandleAdminPost']); add_action('admin_notices', [self::class, 'maybeRenderNotice']); add_filter('admin_body_class', [self::class, 'bodyClass']); add_action('admin_enqueue_scripts', [self::class, 'enqueueAdminScripts']); add_action('wp_ajax_' . self::AJAX_ACTION, [self::class, 'ajaxTestConnection']); add_action('admin_init', [self::class, 'privacyPolicyContent']); } /** * Suggested text for Settings > Privacy > Policy Guide. */ public static function privacyPolicyContent(): void { if (!function_exists('wp_add_privacy_policy_content')) { return; } $text = '

' . esc_html__('AIPCF sends content to Cloudflare to run AI features. Adapt the suggested text below to the features you use.', 'aipcf-ai-provider-for-cloudflare') . '

' . '' . esc_html__('Suggested text:', 'aipcf-ai-provider-for-cloudflare') . ' ' . '

' . esc_html__('This site uses Cloudflare Workers AI (Cloudflare, Inc.) to generate text such as summaries, image descriptions and translations. When these features run, the content they work on (for example a post, its title or an image) is sent to Cloudflare for processing. Cloudflare\'s privacy policy: https://www.cloudflare.com/privacypolicy/', 'aipcf-ai-provider-for-cloudflare') . '

' . '

' . esc_html__('If the content index is switched on, the content chosen in the plugin\'s settings, such as published posts and pages, is sent to Cloudflare Workers AI. There it becomes embeddings, which are kept with that text in the vector store the site owner chose: Cloudflare Vectorize, a Qdrant server, or a PostgreSQL database. Comments and visitors\' personal data are not included unless they are part of the selected content; author names are included only if the site owner selects them. Embedding requests are not stored in Cloudflare AI Gateway logs.', 'aipcf-ai-provider-for-cloudflare') . '

' . '

' . esc_html__('If search by meaning is switched on, what a logged-in member types into it is sent to Cloudflare Workers AI to find matching posts. It is not stored by this plugin unless Search insights is on.', 'aipcf-ai-provider-for-cloudflare') . '

' . '

' . esc_html__('If the Site assistant is switched on, the questions a logged-in member asks it are sent to Cloudflare Workers AI. They go with the earlier messages of the same conversation and with passages from this site\'s posts, to write an answer. Before the first question the member is asked for a name (no email or phone) and whether to save the chat. Only if they agree are the name they gave, their questions and the answers stored with their user account, for the site owner to read, for 90 days by default. Otherwise nothing is stored and the conversation stays in the browser tab. Saved chats are included in personal data exports and erasures, and deleted with the account.', 'aipcf-ai-provider-for-cloudflare') . '

' . '

' . esc_html__('If the Site assistant is open to visitors who are not logged in, their questions are sent to Cloudflare Workers AI in the same way. To limit how many questions each visitor may ask, a one-way key made from the visitor\'s IP address is kept in the site\'s cache for up to a day. The address itself is not stored. If the site uses Cloudflare Turnstile for the assistant, Cloudflare checks the visitor\'s browser once a day. A visitor\'s chat is saved only if they agree, without an account or IP address, so a personal data request cannot find it. It is deleted after the retention period.', 'aipcf-ai-provider-for-cloudflare') . '

' . '

' . esc_html__('If Search insights is switched on, searches made on this site are recorded to understand what visitors look for. Each record holds the search text, the number of results, when it was made and whether the searcher was logged in. No name, user account, IP address or browser details are stored with it. To count a repeated search once, a one-way key made from the visitor\'s IP address may be kept in the site\'s memory cache for up to an hour. Searches are kept for 90 days by default and then deleted, also if Search insights is later switched off. To compare them with the site\'s content, search texts are sent to Cloudflare Workers AI.', 'aipcf-ai-provider-for-cloudflare') . '

' . '

' . esc_html__('The plugin keeps an activity log that only administrators can see: which administrator used which of its tools and when, and the settings they changed. It also keeps the text Backfill wrote into meta descriptions and image alt text, with the text it replaced. Visitors and members are not recorded in it. Entries are kept for 90 days by default and then deleted; when a user account is deleted, its entries stay without the user.', 'aipcf-ai-provider-for-cloudflare') . '

'; wp_add_privacy_policy_content(__('AIPCF - AI Provider for Cloudflare', 'aipcf-ai-provider-for-cloudflare'), wp_kses_post($text)); } public static function handleReset(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_reset'); \WordPress\CloudflareWorkersAiProvider\Reset::run(); set_transient('aipcf_notice_' . get_current_user_id(), [ 'type' => 'success', 'message' => __('Plugin reset. Settings, usage history and caches were cleared; everything it generated was kept.', 'aipcf-ai-provider-for-cloudflare'), ], 60); wp_safe_redirect(self::tabUrl('overview')); exit; } public static function redirectLegacyUrl(): void { global $pagenow; // Search insights moved from a settings tab to its own page (1.9.0). $page = sanitize_key(wp_unslash((string) ($_GET['page'] ?? ''))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tab = sanitize_key(wp_unslash((string) ($_GET['tab'] ?? ''))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ($pagenow === 'admin.php' && $page === self::PAGE_SLUG && $tab === 'insights') { wp_safe_redirect(admin_url('admin.php?page=aipcf-search-insights'), 302); exit; } // 2.0: the settings tabs moved to Settings and Features; old addresses follow them. if ($pagenow === 'admin.php' && $page === self::PAGE_SLUG && $tab !== '' && $tab !== 'overview' && isset(self::TABS[$tab])) { wp_safe_redirect(self::tabUrl($tab), 302); exit; } if ($pagenow !== 'options-general.php' || !isset($_GET['page']) || $_GET['page'] !== self::PAGE_SLUG) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended wp_safe_redirect(self::tabUrl(isset(self::TABS[$tab]) ? $tab : 'overview'), 302); exit; } /** * The menu (2.0): AIPCF > Home, Features, Insights, Settings, Help. Each is a job. The tool * pages (Site assistant, Link assistant, Activity) open from their cards and keep their * parent highlighted; editors, who cannot manage options, see the Link assistant itself. */ public static function registerMenu(): void { add_menu_page( Brand::FULL, Brand::NAME, 'manage_options', self::PAGE_SLUG, [self::class, 'renderPage'], Brand::menuIcon(), 81 ); add_submenu_page(self::PAGE_SLUG, __('Home', 'aipcf-ai-provider-for-cloudflare') . ' ‹ ' . Brand::NAME, __('Home', 'aipcf-ai-provider-for-cloudflare'), 'manage_options', self::PAGE_SLUG, [self::class, 'renderPage']); add_submenu_page(self::PAGE_SLUG, __('Features', 'aipcf-ai-provider-for-cloudflare') . ' ‹ ' . Brand::NAME, __('Features', 'aipcf-ai-provider-for-cloudflare'), 'manage_options', FeaturesPage::SLUG, [FeaturesPage::class, 'render']); } /** * Settings and Help, after Insights (registered at 21). */ public static function registerMenuEnd(): void { add_submenu_page(self::PAGE_SLUG, __('Settings', 'aipcf-ai-provider-for-cloudflare') . ' ‹ ' . Brand::NAME, __('Settings', 'aipcf-ai-provider-for-cloudflare'), 'manage_options', self::SETTINGS_SLUG, [self::class, 'renderSettingsPage']); add_submenu_page(self::PAGE_SLUG, __('Activity', 'aipcf-ai-provider-for-cloudflare') . ' ‹ ' . Brand::NAME, __('Activity', 'aipcf-ai-provider-for-cloudflare'), 'manage_options', ActivityPage::SLUG, [ActivityPage::class, 'render']); add_submenu_page(self::PAGE_SLUG, __('Help', 'aipcf-ai-provider-for-cloudflare') . ' ‹ ' . Brand::NAME, __('Help', 'aipcf-ai-provider-for-cloudflare'), 'manage_options', HelpPage::SLUG, [HelpPage::class, 'render']); } /** * On a tool page that is not in the menu, highlight the menu item it belongs to. * * @param string|null $submenu * @return string|null */ public static function highlightMenu($submenu) { $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if (!current_user_can('manage_options')) { return $submenu; } $parent = [ AssistantPage::SLUG => FeaturesPage::SLUG, LinkAssistant::SLUG => FeaturesPage::SLUG, ]; return $parent[$page] ?? $submenu; } /** * @param string $file */ public static function highlightParent($file): string { $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return in_array($page, [AssistantPage::SLUG, LinkAssistant::SLUG], true) && current_user_can('manage_options') ? self::PAGE_SLUG : (string) $file; } /** * Pages outside the menu (Setup, Site assistant, Link assistant for administrators) get no * title from WordPress: the browser tab would read "‹ Site" and admin-header.php warns. */ public static function titleHiddenPage(): void { global $title; $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $titles = [ Wizard::SLUG => __('Setup', 'aipcf-ai-provider-for-cloudflare'), AssistantPage::SLUG => __('Site assistant', 'aipcf-ai-provider-for-cloudflare'), LinkAssistant::SLUG => __('Link assistant', 'aipcf-ai-provider-for-cloudflare'), ]; if (isset($titles[$page]) && empty($title)) { $title = $titles[$page] . ' ‹ ' . Brand::NAME; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- WordPress leaves it empty for these pages } } public static function enqueueAdminScripts(string $hookSuffix): void { // Every plugin screen shares the styles and the confirm/busy behaviour. if (!self::isPluginScreen()) { return; } $base = plugin_dir_url(AIPCF_FILE) . 'assets/admin/'; wp_enqueue_style('dashicons'); wp_enqueue_style('aipcf-admin', $base . 'settings.css', ['dashicons'], AIPCF_VERSION); wp_enqueue_script('aipcf-admin', $base . 'settings.js', [], AIPCF_VERSION, true); // The 2.0 components (scoped to .aipcf-ui, so the screens not moved yet are unchanged). wp_enqueue_style('aipcf-ui', $base . 'ui.css', ['aipcf-admin'], AIPCF_VERSION); wp_enqueue_script('aipcf-ui', $base . 'ui.js', [], AIPCF_VERSION, true); wp_localize_script( 'aipcf-admin', 'aipcfAdmin', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::AJAX_NONCE), 'i18n' => [ 'testing' => __('Testing...', 'aipcf-ai-provider-for-cloudflare'), 'working' => __('Working...', 'aipcf-ai-provider-for-cloudflare'), /* translators: a tag next to a setting that is hidden until "Show advanced settings" is clicked */ 'advanced' => __('Advanced', 'aipcf-ai-provider-for-cloudflare'), /* translators: 1: posts embedded, 2: waiting, 3: failed */ 'progress' => __('%1$s embedded, %2$s waiting, %3$s failed.', 'aipcf-ai-provider-for-cloudflare'), 'showKey' => __('Show API token', 'aipcf-ai-provider-for-cloudflare'), 'hideKey' => __('Hide API token', 'aipcf-ai-provider-for-cloudflare'), 'networkError' => __('Network error. Try again.', 'aipcf-ai-provider-for-cloudflare'), ], ] ); } public static function registerSettings(): void { register_setting( self::SETTINGS_GROUP, AIPCF_SETTINGS_OPTION, [ 'type' => 'array', 'sanitize_callback' => [self::class, 'sanitizeSettings'], 'default' => \WordPress\CloudflareWorkersAiProvider\default_settings(), ] ); add_settings_section( 'aipcf_credentials', __('API credentials', 'aipcf-ai-provider-for-cloudflare'), static function (): void { if (core_connector_is_available()) { $connectorsUrl = admin_url('options-connectors.php'); echo '

'; printf( /* translators: %s: link to the core Connectors admin screen */ esc_html__( 'Your Cloudflare API token is managed on the %s screen. Add your Account ID below.', // phpcs:ignore Generic.Files.LineLength.TooLong 'aipcf-ai-provider-for-cloudflare' ), '' . esc_html__('Settings > Connectors', 'aipcf-ai-provider-for-cloudflare') . '' ); echo '

'; if ((string) (get_plugin_settings()['api_key'] ?? '') !== '') { // A token saved here earlier still wins over the Connectors screen. echo '

' . esc_html__('A token saved in this plugin earlier is still used instead of the one on the Connectors screen.', 'aipcf-ai-provider-for-cloudflare') . ' '; printf('

', esc_attr(AIPCF_SETTINGS_OPTION), esc_html__('Remove the saved value', 'aipcf-ai-provider-for-cloudflare')); } return; } echo '

' . esc_html__( 'Enter your Cloudflare Account ID and API token to connect to Workers AI.', 'aipcf-ai-provider-for-cloudflare' ) . '

'; }, self::tabPage('overview'), self::panel(get_account_id() !== '' && get_api_key() !== '' ? __('Connection details', 'aipcf-ai-provider-for-cloudflare') : '') ); add_settings_field( 'account_id', __('Account ID', 'aipcf-ai-provider-for-cloudflare'), [self::class, 'renderAccountIdField'], self::tabPage('overview'), 'aipcf_credentials', ['label_for' => 'aipcf_account_id'] ); if (!core_connector_is_available()) { add_settings_field( 'api_key', __('API token', 'aipcf-ai-provider-for-cloudflare'), [self::class, 'renderApiKeyField'], self::tabPage('overview'), 'aipcf_credentials', ['label_for' => 'aipcf_api_key'] ); } add_settings_section( 'aipcf_model', __('Model settings', 'aipcf-ai-provider-for-cloudflare'), static function (): void { echo '

' . esc_html__('The model for writing. Short tasks can go to a light model, and anything with an image goes to the vision or image model; the table shows the result.', 'aipcf-ai-provider-for-cloudflare') . '

'; if (get_account_id() !== '' && get_api_key() !== '') { FreeTierSettings::renderModelMap(); } echo '
' . esc_html__('How to read the list', 'aipcf-ai-provider-for-cloudflare') . '
' . esc_html__('Each entry shows the model\'s name, its cost in neurons per 1,000 tokens from your account\'s current price list, and its context window (how much text it reads at once). It also says what else it can do: tools means function calling, vision means it reads images. The list is read from your Cloudflare account, cached for 12 hours, and re-read when you save this tab, test the connection or update the plugin.', 'aipcf-ai-provider-for-cloudflare') . '
'; }, self::tabPage('models'), self::panel() ); add_settings_field( 'preferred_model', __('Default text model', 'aipcf-ai-provider-for-cloudflare'), [self::class, 'renderPreferredModelField'], self::tabPage('models'), 'aipcf_model', ['label_for' => 'aipcf_preferred_model'] ); } public static function sanitizeSettings($input): array { $current = get_plugin_settings(); if (!is_array($input)) { $input = []; } // Each tab submits only its own fields; fill the rest from the saved values // so an unrelated tab's Save never blanks a checkbox it did not show. $tab = isset($input['_tab']) ? sanitize_key((string) $input['_tab']) : ''; if ($tab !== '') { // A screen name the settings do not know owns nothing: everything is kept. $owned = self::isTab($tab) ? self::tabKeys($tab) : []; foreach ($current as $key => $value) { if (!in_array($key, $owned, true) && !array_key_exists($key, $input)) { $input[$key] = $value; } } } $apiKey = array_key_exists('api_key', $input) ? sanitize_text_field(trim((string) $input['api_key'])) : ''; if (!empty($input['api_key_clear'])) { $apiKey = ''; } elseif ($apiKey === '') { // Blank keeps the saved token (the field is rendered empty). $apiKey = (string) $current['api_key']; } $accountId = sanitize_text_field(trim((string) ($input['account_id'] ?? ''))); $preferredModel = sanitize_text_field(trim((string) ($input['preferred_model'] ?? ''))); $credentialsChanged = ($accountId !== $current['account_id'] || $apiKey !== $current['api_key']); $credentialsPresent = ($accountId !== '' && $apiKey !== ''); if ($credentialsChanged) { flush_model_caches(); if ($credentialsPresent) { $test = self::testCredentials($accountId, $apiKey); if ($test === true) { add_settings_error( AIPCF_SETTINGS_OPTION, 'cfai_conn_ok', __('Connected: the credentials work.', 'aipcf-ai-provider-for-cloudflare'), 'success' ); } elseif ($test === 'pending_approval') { add_settings_error( AIPCF_SETTINGS_OPTION, 'cfai_pending', __('Credentials saved. To finish, allow this plugin on Tools > Connector Approvals.', 'aipcf-ai-provider-for-cloudflare'), // phpcs:ignore Generic.Files.LineLength.TooLong 'warning' ); } else { add_settings_error( AIPCF_SETTINGS_OPTION, 'cfai_conn_fail', sprintf( /* translators: %s: error detail from the Cloudflare API */ __('Credentials saved, but the connection test failed: %s', 'aipcf-ai-provider-for-cloudflare'), // settings_errors() prints messages as HTML; this part comes from the API. esc_html((string) $test) ), 'error' ); } } } $clean = [ 'api_key' => $apiKey, 'account_id' => $accountId, 'preferred_model' => $preferredModel, ]; if ($preferredModel !== (string) $current['preferred_model']) { flush_model_caches(); } return (array) apply_filters('aipcf_sanitize_settings', $clean, $input); } public static function testCredentials(string $accountId, string $apiKey) { if (!is_connector_approved()) { return 'pending_approval'; } $url = sprintf( 'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=1', rawurlencode($accountId) ); $response = wp_remote_get( $url, [ 'timeout' => 8, 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json', ], ] ); if (is_wp_error($response)) { return $response->get_error_message(); } $status = (int) wp_remote_retrieve_response_code($response); if ($status < 200 || $status >= 300) { $body = (string) wp_remote_retrieve_body($response); $decoded = json_decode($body, true); return is_array($decoded) && !empty($decoded['errors'][0]['message']) ? (string) $decoded['errors'][0]['message'] : sprintf('HTTP %d', $status); } return true; } public static function ajaxTestConnection(): void { check_ajax_referer(self::AJAX_NONCE); if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare')]); } // The Activity log records the test and its outcome (the button is AJAX, not admin-post). Recorder::start('connection', 'test_connection'); $fail = static function (string $message): void { Recorder::outcome('failed', $message); wp_send_json_error(['message' => $message]); }; $accountId = get_account_id(); $apiKey = get_api_key(); if ($accountId === '') { $fail(__('Save your Account ID first, then test again.', 'aipcf-ai-provider-for-cloudflare')); } if ($apiKey === '') { $fail(__('Save your API token first, then test again.', 'aipcf-ai-provider-for-cloudflare')); } $result = self::testCredentials($accountId, $apiKey); if ($result === true) { flush_model_caches(); $message = __('Connected: the credentials work.', 'aipcf-ai-provider-for-cloudflare'); Recorder::outcome('ok', $message); wp_send_json_success(['message' => $message]); } if ($result === 'pending_approval') { $fail(__( 'The connector is not approved yet. Allow this plugin on Tools > Connector Approvals, then test again.', 'aipcf-ai-provider-for-cloudflare' )); } $fail(sprintf( /* translators: %s: error detail from the Cloudflare API */ __('Connection failed: %s', 'aipcf-ai-provider-for-cloudflare'), (string) $result )); } public static function renderAccountIdField(): void { $settings = get_plugin_settings(); $value = $settings['account_id']; $option = AIPCF_SETTINGS_OPTION; printf( '', esc_attr($option), esc_attr($value) ); echo '

'; printf( /* translators: %s: link to the Cloudflare Workers AI dashboard */ esc_html__('Find your Account ID on the %s.', 'aipcf-ai-provider-for-cloudflare'), '' . esc_html__('Cloudflare dashboard', 'aipcf-ai-provider-for-cloudflare') . '' ); echo '

'; if (defined('CLOUDFLARE_WORKERS_AI_ACCOUNT_ID') && \CLOUDFLARE_WORKERS_AI_ACCOUNT_ID) { echo '

' . esc_html__( 'A value is also defined in wp-config.php as CLOUDFLARE_WORKERS_AI_ACCOUNT_ID. It is used when this field is empty.', // phpcs:ignore Generic.Files.LineLength.TooLong 'aipcf-ai-provider-for-cloudflare' ) . '

'; } } public static function renderApiKeyField(): void { $settings = get_plugin_settings(); $saved = (string) $settings['api_key'] !== ''; $option = AIPCF_SETTINGS_OPTION; // The saved token is never printed into the page; a blank field keeps it. printf( '', esc_attr($option), esc_attr($saved ? __('Saved. Leave blank to keep it.', 'aipcf-ai-provider-for-cloudflare') : '') ); if ($saved) { printf(' ', esc_attr($option), esc_html__('Remove the saved value', 'aipcf-ai-provider-for-cloudflare')); } // The show/hide toggle button is injected by adminJs() after this input. echo '

'; printf( /* translators: %s: link to the Cloudflare API tokens page */ esc_html__( 'Create an API token with the "Workers AI" template on %s.', 'aipcf-ai-provider-for-cloudflare' ), '' . esc_html__('your Cloudflare API tokens page', 'aipcf-ai-provider-for-cloudflare') . '' ); echo '

'; if (defined('CLOUDFLARE_WORKERS_AI_API_TOKEN') && \CLOUDFLARE_WORKERS_AI_API_TOKEN) { echo '

' . esc_html__( 'A value is also defined in wp-config.php as CLOUDFLARE_WORKERS_AI_API_TOKEN. It is used when this field is empty.', // phpcs:ignore Generic.Files.LineLength.TooLong 'aipcf-ai-provider-for-cloudflare' ) . '

'; } } public static function renderPreferredModelField(): void { $settings = get_plugin_settings(); $current = $settings['preferred_model']; $option = AIPCF_SETTINGS_OPTION; $accountId = get_account_id(); $apiKey = get_api_key(); $approved = is_connector_approved(); if ($accountId === '' || $apiKey === '') { echo '

' . esc_html__( 'Save your Account ID and API token first, then come back to choose a model.', 'aipcf-ai-provider-for-cloudflare' ) . '

'; return; } $models = fetch_available_models(); $isLive = !empty($models); if (!$isLive) { $models = static_model_catalog(); } // If the saved model is not in the fetched list (e.g. set via wp-config.php), prepend it. if ($current !== '') { $inList = false; foreach ($models as $m) { if ($m['id'] === $current) { $inList = true; break; } } if (!$inList) { array_unshift($models, ['id' => $current, 'name' => $current]); } } // Group models by vendor family. $grouped = []; foreach ($models as $model) { $group = self::modelGroupLabel($model['id']); $grouped[$group][] = $model; } ksort($grouped); echo ''; $byId = array_column($models, null, 'id'); $pickN = isset($byId[$pick]) ? UsageTracker::neuronsPer1k((float) ($byId[$pick]['price_in'] ?? 0)) : 0.0; $fbN = isset($byId[$fallbackDefault]) ? UsageTracker::neuronsPer1k((float) ($byId[$fallbackDefault]['price_in'] ?? 0)) : 0.0; $why = $pickN > 0 && $fbN > $pickN ? sprintf( /* translators: 1: neurons per 1K tokens of the pick, 2: neurons per 1K tokens of the fallback, 3: fallback model */ __('about %1$s neurons per 1,000 tokens against %2$s for %3$s on your account\'s current list, with more context and tool calling, and it wrote comparably well in our tests.', 'aipcf-ai-provider-for-cloudflare'), number_format($pickN, 1), number_format($fbN, 1), pretty_model_name($fallbackDefault) ) : sprintf( /* translators: %s: fallback model */ __('fewer neurons per token than %s, with more context and tool calling, and it wrote comparably well in our tests.', 'aipcf-ai-provider-for-cloudflare'), pretty_model_name($fallbackDefault) ); if ($current === $pick) { $help = FreeTierSettings::help( __('Recommended: tested on live sites.', 'aipcf-ai-provider-for-cloudflare'), __('Why?', 'aipcf-ai-provider-for-cloudflare'), sprintf( /* translators: 1: recommended model, 2: why */ __('%1$s is the model "Apply recommended defaults" chooses: %2$s', 'aipcf-ai-provider-for-cloudflare'), pretty_model_name($pick), $why ) ); } elseif ($current === '') { $help = FreeTierSettings::help(sprintf( /* translators: %s: fallback model */ __('Nothing chosen: %s, Cloudflare\'s general-purpose default, is used.', 'aipcf-ai-provider-for-cloudflare'), pretty_model_name($fallbackDefault) )); } else { $help = FreeTierSettings::help(__('The list marks the recommended model.', 'aipcf-ai-provider-for-cloudflare')); } echo $help; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes if (!$isLive && $approved) { echo '

' . esc_html__( 'Showing built-in model list. Test the connection to load the live list from your account.', // phpcs:ignore Generic.Files.LineLength.TooLong 'aipcf-ai-provider-for-cloudflare' ) . '

'; } elseif (!$approved) { $approvalUrl = self::connectorApprovalUrl(); echo '

'; if ($approvalUrl !== '') { printf( /* translators: %s: link to the connector approval page */ esc_html__( 'Showing built-in model list. %s to load the full live list.', 'aipcf-ai-provider-for-cloudflare' ), '' . esc_html__('Approve this plugin', 'aipcf-ai-provider-for-cloudflare') . '' ); } else { esc_html_e( 'Showing built-in model list. Approve this plugin to load the full live list.', 'aipcf-ai-provider-for-cloudflare' ); } echo '

'; } else { $at = catalog_fetched_at(); $refresh = wp_nonce_url(admin_url('admin-post.php?action=aipcf_refresh_models'), 'aipcf_refresh_models'); echo '

'; echo esc_html($at > 0 ? sprintf( /* translators: 1: number of models, 2: human time difference */ _n('%1$d text model from your account, %2$s ago.', '%1$d text models from your account, %2$s ago.', count($models), 'aipcf-ai-provider-for-cloudflare'), count($models), human_time_diff($at) ) : sprintf( /* translators: %d: number of models */ _n('%d text model from your account.', '%d text models from your account.', count($models), 'aipcf-ai-provider-for-cloudflare'), count($models) )); echo ' ' . esc_html__('Refresh now', 'aipcf-ai-provider-for-cloudflare') . '

'; } } public static function modelGroupLabel(string $id): string { static $map = [ 'meta' => 'Meta Llama', 'mistral' => 'Mistral', 'google' => 'Google', 'deepseek-ai' => 'DeepSeek', 'qwen' => 'Qwen', 'mistralai' => 'Mistral', 'openai' => 'OpenAI (open-weight)', 'ibm-granite' => 'IBM Granite', 'zai-org' => 'Z.ai GLM', 'moonshotai' => 'Moonshot Kimi', 'nvidia' => 'NVIDIA', 'aisingapore' => 'AI Singapore', 'microsoft' => 'Microsoft', 'stabilityai' => 'Stability AI', 'openchat' => 'OpenChat', ]; if (preg_match('#^@cf/([^/]+)/#', $id, $matches)) { $vendor = $matches[1]; return $map[$vendor] ?? (string) ucwords(str_replace(['-', '_'], ' ', $vendor)); } return __('Other', 'aipcf-ai-provider-for-cloudflare'); } /** * Home: is it working, what is on, today's use, anything that needs you. Nothing to save. */ public static function renderPage(): void { if (!current_user_can('manage_options')) { return; } echo '
'; echo Ui::header(__('Home', 'aipcf-ai-provider-for-cloudflare'), __('Is it working, what is on, and anything that needs you.', 'aipcf-ai-provider-for-cloudflare'), self::connectionPillUi()); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ui escapes echo '
'; echo '
'; echo Screens\HomeScreen::render(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ui escapes echo '
'; } /** * Settings: Connection, Models, Budget, Content index, Prompts. */ public static function renderSettingsPage(): void { if (!current_user_can('manage_options')) { return; } $section = self::currentSection(); $labels = [ 'connection' => __('Connection', 'aipcf-ai-provider-for-cloudflare'), 'models' => __('Models', 'aipcf-ai-provider-for-cloudflare'), 'budget' => __('Budget', 'aipcf-ai-provider-for-cloudflare'), 'content-index' => __('Content index', 'aipcf-ai-provider-for-cloudflare'), 'prompts' => __('Prompts', 'aipcf-ai-provider-for-cloudflare'), ]; $links = []; foreach ($labels as $slug => $label) { $links[$slug] = [$label, self::settingsUrl($slug)]; } [$tab, $legacy] = self::SECTIONS[$section]; echo '
'; echo Ui::header(__('Settings', 'aipcf-ai-provider-for-cloudflare'), __('How AIPCF connects to Cloudflare, which models it uses, and what it may spend.', 'aipcf-ai-provider-for-cloudflare'), self::connectionPillUi()); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ui escapes echo '
'; echo Ui::tabs($links, $section, __('Settings sections', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ui escapes $screen = self::screen($tab); self::form($tab, $screen !== null ? (string) $screen() : self::legacy($legacy)); echo '
'; } /** * A settings form for one screen: saved through options.php as that screen's tab, so it * only changes the settings that screen owns. * * @param string $html The screen, escaped. */ public static function form(string $tab, string $html): void { // A screen with no setting fields (Models before connecting, say) gets no form and no // Save: saving it as its tab would blank every setting that tab owns. if (!str_contains($html, 'name="' . AIPCF_SETTINGS_OPTION . '[')) { echo '
'; echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- screens and Settings API sections escape their parts echo '
'; return; } echo '
'; // Pressing Enter in a field saves: this hidden button is the form's first submit button, // ahead of any action button inside a card. printf('', esc_html__('Save changes', 'aipcf-ai-provider-for-cloudflare')); settings_fields(self::SETTINGS_GROUP); printf('', esc_attr(AIPCF_SETTINGS_OPTION), esc_attr($tab)); echo '
'; echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- screens and Settings API sections escape their parts echo Ui::saveBar(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ui escapes echo '
'; } /** * Older Settings API pages, shown inside a 2.0 page until their screen moves. Their * "advanced" rows keep the Show advanced settings link. * * @param list $tabs */ public static function legacy(array $tabs): string { ob_start(); printf( '

', esc_attr(implode('-', $tabs)), esc_attr__('Show advanced settings', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Hide advanced settings', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Show advanced settings', 'aipcf-ai-provider-for-cloudflare') ); foreach ($tabs as $t) { do_settings_sections(self::tabPage($t)); } return (string) ob_get_clean(); } /** * Some sections of an older Settings API page (the content tools share one page). * * @param list $sectionIds */ public static function legacySections(string $page, array $sectionIds): string { global $wp_settings_sections, $wp_settings_fields; ob_start(); foreach ($sectionIds as $id) { $section = $wp_settings_sections[$page][$id] ?? null; if (!is_array($section)) { continue; } echo wp_kses_post((string) ($section['before_section'] ?? '')); if (!empty($section['title'])) { echo '

' . esc_html((string) $section['title']) . '

'; } if (is_callable($section['callback'] ?? null)) { call_user_func($section['callback'], $section); } if (isset($wp_settings_fields[$page][$id])) { echo ''; do_settings_fields($page, $id); echo ''; } echo wp_kses_post((string) ($section['after_section'] ?? '')); } return (string) ob_get_clean(); } /** * Older Settings API sections as 2.0 cards: the section's own intro, then each field's * existing control in a 2.0 row (so its scripts and tests keep working), "advanced" rows * under More settings. For screens whose controls are too involved to rebuild at once. * * @param array, id?: string}> $cards section id => card */ public static function legacyCards(string $page, array $cards): string { global $wp_settings_sections, $wp_settings_fields; $out = ''; foreach ($cards as $sectionId => $card) { $section = $wp_settings_sections[$page][$sectionId] ?? null; if (!is_array($section)) { continue; } $body = ''; if (($card['intro'] ?? true) && is_callable($section['callback'] ?? null)) { ob_start(); call_user_func($section['callback'], $section); $intro = (string) ob_get_clean(); $body .= $intro !== '' ? '
' . $intro . '
' : ''; } $rows = ''; $more = ''; foreach ((array) ($wp_settings_fields[$page][$sectionId] ?? []) as $id => $field) { $args = (array) ($field['args'] ?? []); ob_start(); call_user_func($field['callback'], $args); $control = (string) ob_get_clean(); $class = (string) ($args['class'] ?? ''); $advanced = strpos(' ' . $class . ' ', ' aipcf-advanced ') !== false; $row = Ui::row( (string) ($card['labels'][$id] ?? $field['title']), $control, array_filter([ 'for' => (string) ($args['label_for'] ?? ''), 'class' => trim(str_replace('aipcf-advanced', '', $class)), ]) ); if ($advanced) { $more .= $row; } else { $rows .= $row; } } $body .= $rows . ($more !== '' ? Ui::moreSettings($more) : ''); $out .= Ui::card($card['title'], $body, array_filter(['text' => $card['text'] ?? '', 'id' => $card['id'] ?? ''])); } return $out; } /** * The connection pill for a 2.0 header: [state, label]. * * @return array{0: string, 1: string} */ public static function connectionPillUi(): array { [$class, $label] = self::connectionPill(); return [str_replace('is-', '', $class), $label]; } /** * The 2.0 screen for a tab, while the tabs move over one by one; null for a tab that still * uses the Settings API sections. */ public static function screen(string $tab): ?callable { $screens = [ 'backfill' => [Screens\BackfillScreen::class, 'render'], 'connection' => [Screens\ConnectionScreen::class, 'render'], 'models' => [Screens\ModelsScreen::class, 'render'], 'budget' => [Screens\BudgetScreen::class, 'render'], 'prompts' => [Screens\PromptsScreen::class, 'render'], 'embeddings' => [Screens\ContentIndexScreen::class, 'render'], ]; return $screens[$tab] ?? null; } /** * The connection pill: [class, label]. * * @return array{0: string, 1: string} */ public static function connectionPill(): array { $configured = get_account_id() !== '' && get_api_key() !== ''; $gate = class_exists('\\WordPress\\AI\\Connector_Approval\\Approvals_Store'); if (!$configured) { return ['', __('Not connected', 'aipcf-ai-provider-for-cloudflare')]; } if ($gate && !is_connector_approved()) { return ['is-warn', __('Needs approval', 'aipcf-ai-provider-for-cloudflare')]; } if (get_transient(Gateway::DOWN_TRANSIENT)) { return ['is-warn', __('Connected, AI Gateway bypassed', 'aipcf-ai-provider-for-cloudflare')]; } return ['is-ok', Gateway::mode() === 'direct' ? __('Connected', 'aipcf-ai-provider-for-cloudflare') : __('Connected via AI Gateway', 'aipcf-ai-provider-for-cloudflare')]; } /** * The header every plugin screen shares: "Cloudflare AI" above the screen's name, a line on * what it is for, and on the right a status pill and optional links. * * @param array{0: string, 1: string}|null $pill [class, label]; null for the connection pill. * @param string $side Extra escaped HTML for the right-hand side. */ public static function header(string $icon, string $title, string $sub, ?array $pill = null, string $side = ''): void { unset($icon); // 2.0: every page carries the AIPCF mark. $pill = $pill ?? self::connectionPill(); echo Ui::header($title, $sub, [str_replace('is-', '', $pill[0]), $pill[1]], $side); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ui escapes; callers pass escaped HTML in $side // Tool pages opened from a card lead back to it. $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $back = [ AssistantPage::SLUG => [__('Features', 'aipcf-ai-provider-for-cloudflare'), FeaturesPage::url()], LinkAssistant::SLUG => [__('Features', 'aipcf-ai-provider-for-cloudflare'), FeaturesPage::url()], ]; if (isset($back[$page]) && current_user_can('manage_options')) { echo Ui::back($back[$page][0], $back[$page][1]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ui escapes } } /** * The Tools > Connector Approvals screen, or '' when the AI plugin that adds it is not active. */ public static function connectorApprovalUrl(): string { if (!class_exists('\WordPress\AI\Connector_Approval\Approvals_Store')) { return ''; } return admin_url('tools.php?page=ai-connector-approval'); } public static function maybeHandleAdminPost(): void { add_action( 'admin_post_aipcf_test_connection', [self::class, 'handleTestConnection'] ); } public static function handleTestConnection(): void { self::ensureAdminPostCapability(); $accountId = get_account_id(); $apiKey = get_api_key(); if ($accountId === '') { self::queueNotice( 'error', __('Save your Account ID first, then test again.', 'aipcf-ai-provider-for-cloudflare') ); self::redirectBack(); } if ($apiKey === '') { self::queueNotice( 'error', __('Save your API token first, then test again.', 'aipcf-ai-provider-for-cloudflare') ); self::redirectBack(); } $result = self::testCredentials($accountId, $apiKey); if ($result === true) { flush_model_caches(); self::queueNotice( 'success', __('Connected: the credentials work.', 'aipcf-ai-provider-for-cloudflare') ); self::redirectBack(); } if ($result === 'pending_approval') { self::queueNotice( 'warning', __('The connector is not approved yet. Allow this plugin on Tools > Connector Approvals, then test again.', 'aipcf-ai-provider-for-cloudflare') // phpcs:ignore Generic.Files.LineLength.TooLong ); self::redirectBack(); } self::queueNotice( 'error', sprintf( /* translators: %s: error detail from the Cloudflare API */ __('Connection failed: %s', 'aipcf-ai-provider-for-cloudflare'), (string) $result ) ); self::redirectBack(); } /** * Marks the plugin's screens, for the shared page background. */ public static function bodyClass($classes): string { $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return (string) $classes . (self::isPluginScreen() ? ' aipcf-screen' : '') . ($page === Wizard::SLUG ? ' aipcf-wizard-screen' : ''); } public static function maybeRenderNotice(): void { $userId = get_current_user_id(); if ($userId === 0) { return; } // WordPress shows the messages of a settings save ("Settings saved.", the credentials // test, budget warnings) only on pages under Settings; this is a top-level page. if (self::isPluginScreen()) { settings_errors(); } $notice = get_transient(self::noticeTransientKey($userId)); if (!is_array($notice) || empty($notice['message'])) { return; } delete_transient(self::noticeTransientKey($userId)); $type = (string) ($notice['type'] ?? 'info'); $class = in_array($type, ['error', 'warning', 'success', 'info'], true) ? 'notice-' . $type : 'notice-info'; printf( '

%2$s

', esc_attr($class), esc_html((string) $notice['message']) ); } private static function queueNotice(string $type, string $message): void { $userId = get_current_user_id(); if ($userId === 0) { return; } set_transient( self::noticeTransientKey($userId), ['type' => $type, 'message' => $message], 60 ); } private static function noticeTransientKey(int $userId): string { return 'aipcf_notice_' . $userId; } private static function ensureAdminPostCapability(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer(self::NONCE_ACTION); } private static function redirectBack(): void { wp_safe_redirect(self::settingsUrl('connection')); exit; } }