id(), is_wp_error($store) ? '' : $store->indexName(), Models::current(), $c['indexed'], $c['chunks'], $c['pending'] + $c['stale'] + $c['deleting'], $c['failed'], $state['generation'], UsageTracker::todayEmbedNeurons() ); if ($state['paused'] !== '') { /* translators: %s: why embedding is paused */ $line .= ' ' . sprintf(__('Paused: %s', 'aipcf-ai-provider-for-cloudflare'), $state['paused']); } if ($state['move'] !== null) { /* translators: 1: old store and index, 2: new store and index, 3: vectors copied, 4: all vectors */ $line .= ' ' . sprintf(__('Moving from %1$s to %2$s: %3$d of %4$d vectors copied.', 'aipcf-ai-provider-for-cloudflare'), $state['move']['from'] . ' ' . $state['move']['from_index'], $state['move']['to'] . ' ' . $state['move']['to_index'], (int) $state['move']['copied'], (int) $state['move']['vectors']); } elseif ($state['moved_from'] !== null) { /* translators: 1: store and index */ $line .= ' ' . sprintf(__('Old vectors left in %1$s after a move.', 'aipcf-ai-provider-for-cloudflare'), $state['moved_from']['store'] . ' ' . $state['moved_from']['index']); } return $line; } public static function boot(): void { add_filter('site_status_tests', [self::class, 'tests']); add_filter('debug_information', [self::class, 'debugInfo']); } public static function tests($tests): array { $tests = is_array($tests) ? $tests : []; $tests['direct']['aipcf'] = [ 'label' => __('AIPCF - AI Provider for Cloudflare', 'aipcf-ai-provider-for-cloudflare'), 'test' => [self::class, 'runTest'], ]; if (EmbeddingSettings::enabled()) { $tests['direct']['aipcf_embeddings'] = [ 'label' => __('AIPCF: Content index', 'aipcf-ai-provider-for-cloudflare'), 'test' => [self::class, 'runEmbeddingsTest'], ]; } return $tests; } public static function runTest(): array { $settings = get_plugin_settings(); $link = '
' . esc_html__('AIPCF settings', 'aipcf-ai-provider-for-cloudflare') . '
'; $base = [ 'label' => UsageTracker::budgetEnabled() ? __('Cloudflare Workers AI is connected and inside today\'s daily budget', 'aipcf-ai-provider-for-cloudflare') : __('Cloudflare Workers AI is connected and inside the free daily allowance', 'aipcf-ai-provider-for-cloudflare'), 'status' => 'good', 'badge' => ['label' => __('AI', 'aipcf-ai-provider-for-cloudflare'), 'color' => 'blue'], 'description' => '', 'actions' => $link, 'test' => 'aipcf', ]; if (get_account_id() === '' || get_api_key() === '') { $base['label'] = __('Cloudflare Workers AI is not connected', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'recommended'; $base['description'] = '' . esc_html__('Add your Cloudflare API token and Account ID to turn on the AI features.', 'aipcf-ai-provider-for-cloudflare') . '
'; return $base; } // Before the model list: the AI plugin blocks an unapproved plugin's requests, so the // list would fail too and the token would wrongly look revoked. if (!is_connector_approved()) { $base['label'] = __('Cloudflare Workers AI needs connector approval', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'critical'; $base['description'] = '' . esc_html__('The AI plugin blocks this plugin\'s requests to Cloudflare until you allow it on Tools > Connector Approvals. Until then every AI feature fails: the Site assistant, Embeddings, Backfill and the editor tools.', 'aipcf-ai-provider-for-cloudflare') . '
'; return $base; } // A token and account ID being saved is not the same as Cloudflare accepting them. // The model list is cached for hours after one success and costs no neurons, so this // only reaches Cloudflare when the cache is cold, and a failure is remembered briefly. if (fetch_catalog() === []) { $base['label'] = __('Cloudflare Workers AI is not answering with the saved token and Account ID', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'critical'; $base['description'] = '' . esc_html__('Listing the models failed, so AI requests will fail too. The token may be revoked or expired, the Account ID may be wrong, or Cloudflare may be unreachable from this server. Use Test connection on the settings page to see the exact error.', 'aipcf-ai-provider-for-cloudflare') . '
'; return $base; } $gates = get_option(Gateway::LICENCE_OPTION, []); if (is_array($gates) && $gates !== []) { $first = (string) array_key_first($gates); $owner = Gateway::licenceOwner($first); /* translators: %s: license owner, e.g. Meta */ $base['label'] = sprintf(__('%s\'s license for a model has not been accepted on your Cloudflare account', 'aipcf-ai-provider-for-cloudflare'), $owner); $base['status'] = 'recommended'; $base['description'] = '' . esc_html(sprintf( /* translators: 1: model ids, 2: license owner */ __('Cloudflare blocks %1$s until %2$s\'s license for it (not this plugin\'s) is accepted once on your Cloudflare account. The notice on any admin page has an accept button.', 'aipcf-ai-provider-for-cloudflare'), implode(', ', array_keys($gates)), $owner )) . '
'; return $base; } if (Gateway::id() !== '' && get_transient(Gateway::DOWN_TRANSIENT)) { $base['label'] = __('Cloudflare AI Gateway is being bypassed', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'recommended'; $last = get_option(Gateway::LAST_ERROR_OPTION); $base['description'] = '' . esc_html(is_array($last) ? (string) ($last['reason'] ?? '') : '') . '
'; return $base; } $used = UsageTracker::todayNeurons(); $budget = UsageTracker::budgetEnabled() ? UsageTracker::budget() : UsageTracker::FREE_TIER; if (!UsageTracker::budgetEnabled() && $used > $budget) { // No daily budget: nothing is paused, the neurons past the free daily allowance are billed. $base['label'] = __('Cloudflare Workers AI is past the free daily allowance', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'recommended'; $base['description'] = '' . esc_html(sprintf( /* translators: 1: neurons used today, 2: free daily allowance in neurons */ __('%1$s neurons used today, past the free daily allowance of %2$s. The neurons over it are billed. No daily budget is set, so nothing stops the spending. Switch on Daily budget under AIPCF > Settings > Budget.', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($used), number_format_i18n($budget) )) . '
'; return $base; } if (UsageTracker::budgetEnabled() && $used >= $budget) { $base['label'] = __('Cloudflare Workers AI daily budget reached', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'recommended'; $base['description'] = '' . esc_html(UsageTracker::budgetAction() === 'cheapest' ? sprintf( /* translators: 1: neurons used, 2: daily budget */ __('%1$s of %2$s neurons used today. Text requests switch to the cheapest model; Backfill, the content index, Link assistant chat, the Site assistant, Search, Search insights, image generation, image reading and translation pause until midnight UTC.', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($used), number_format_i18n($budget) ) : sprintf( /* translators: 1: neurons used, 2: daily budget */ __('%1$s of %2$s neurons used today. AI requests are paused until midnight UTC.', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($used), number_format_i18n($budget) )) . '
'; return $base; } $left = Backfill::remaining(); $base['description'] = '' . esc_html(sprintf( /* translators: 1: neurons used, 2: daily budget or free daily allowance, 3: model name */ __('%1$s of %2$s neurons used today. Default model: %3$s.', 'aipcf-ai-provider-for-cloudflare'), number_format($used), number_format($budget), pretty_model_name(get_default_model_id()) )) . ' ' . esc_html(Gateway::id() !== '' /* translators: %s: gateway name */ ? sprintf(__('AI Gateway: %s.', 'aipcf-ai-provider-for-cloudflare'), Gateway::id()) : __('AI Gateway: not used.', 'aipcf-ai-provider-for-cloudflare')) . ' ' . esc_html(sprintf( /* translators: %d: number of posts */ _n('%d post has no meta description.', '%d posts have no meta description.', (int) $left['meta_description'], 'aipcf-ai-provider-for-cloudflare'), (int) $left['meta_description'] )) . ' ' . esc_html(sprintf( /* translators: %d: number of images */ _n('%d image has no alt text.', '%d images have no alt text.', (int) $left['alt_text'], 'aipcf-ai-provider-for-cloudflare'), (int) $left['alt_text'] )) . '
'; $ai = \WordPress\CloudflareWorkersAiProvider\ai_plugin_status(); if ($ai['status'] !== 'active') { $base['description'] .= '' . esc_html__('Recommended: the WordPress AI plugin brings these models into the block editor.', 'aipcf-ai-provider-for-cloudflare') . ' ' . esc_html($ai['action_label']) . '
'; } if (empty($settings['budget_enabled'])) { $base['description'] .= '' . esc_html__('The daily budget is off. Turn it on under AIPCF > Settings > Budget to stay inside the free daily allowance automatically.', 'aipcf-ai-provider-for-cloudflare') . '
'; } return $base; } /** * Only registered while embeddings are on. */ public static function runEmbeddingsTest(): array { $link = '' . esc_html__('Content index settings', 'aipcf-ai-provider-for-cloudflare') . '
'; $base = [ 'label' => __('The content index is up to date', 'aipcf-ai-provider-for-cloudflare'), 'status' => 'good', 'badge' => ['label' => __('AI', 'aipcf-ai-provider-for-cloudflare'), 'color' => 'blue'], 'description' => '', 'actions' => $link, 'test' => 'aipcf_embeddings', ]; $store = StoreFactory::current(); if (is_wp_error($store)) { return ['label' => __('The content index is not set up', 'aipcf-ai-provider-for-cloudflare'), 'status' => 'critical', 'description' => '' . esc_html($store->get_error_message()) . '
'] + $base; } $state = State::get(); if ($state['paused'] !== '') { return ['label' => __('The content index is paused', 'aipcf-ai-provider-for-cloudflare'), 'status' => 'critical', 'description' => '' . esc_html($state['paused']) . '
' . esc_html__('Fix the cause, then click "Run one batch now" on AIPCF > Settings > Content index to resume.', 'aipcf-ai-provider-for-cloudflare') . '
'] + $base; } $status = EmbeddingsStatus::storeStatus($store); if (isset($status['error'])) { return ['label' => __('The content index cannot be reached', 'aipcf-ai-provider-for-cloudflare'), 'status' => 'recommended', 'description' => '' . esc_html((string) $status['error']) . '
'] + $base; } $dims = (int) (Models::get(Models::current())['dims'] ?? 0); if (!empty($status['exists']) && (int) $status['dims'] !== $dims) { return ['label' => __('The content index does not match its model', 'aipcf-ai-provider-for-cloudflare'), 'status' => 'critical', 'description' => '' . esc_html(sprintf( /* translators: 1: index name, 2: numbers in each stored vector, 3: numbers in each vector the selected model makes */ __('Index %1$s holds vectors of %2$s numbers, but the selected model makes vectors of %3$s. Rebuild, choose another index name, or pick the matching model.', 'aipcf-ai-provider-for-cloudflare'), $store->indexName(), number_format_i18n((int) $status['dims']), number_format_i18n($dims) )) . '
'] + $base; } $c = IndexTable::counts(EmbeddingSettings::signature()); $left = $c['pending'] + $c['stale'] + $c['deleting']; // Items and passages are counted separately, so each gets its own singular or plural. $passages = sprintf( /* translators: %s: number of passages (parts of posts) */ _n('%s passage', '%s passages', (int) $c['chunks'], 'aipcf-ai-provider-for-cloudflare'), number_format_i18n((int) $c['chunks']) ); $base['description'] = '' . esc_html(sprintf( /* translators: 1: number of items, 2: "N passages", 3: store, 4: index, 5: number waiting */ _n('%1$s item (%2$s) in %3$s %4$s; %5$s waiting.', '%1$s items (%2$s) in %3$s %4$s; %5$s waiting.', (int) $c['indexed'], 'aipcf-ai-provider-for-cloudflare'), number_format_i18n((int) $c['indexed']), $passages, StoreFactory::available()[$store->id()] ?? $store->id(), $store->indexName(), number_format_i18n($left) )) . '
'; if ($c['failed'] > 0) { $base['label'] = __('Some posts could not be added to the content index', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'recommended'; $base['description'] .= '' . esc_html(sprintf( /* translators: %d: failed items */ _n('%d item failed; Settings > Content index lists the reason and can retry it.', '%d items failed; Settings > Content index lists the reasons and can retry them.', $c['failed'], 'aipcf-ai-provider-for-cloudflare'), $c['failed'] )) . '
'; } elseif ($left > 0) { $base['label'] = __('The content index is catching up', 'aipcf-ai-provider-for-cloudflare'); $oldest = IndexTable::oldestWaiting(); $waited = $oldest !== '' ? time() - (int) strtotime($oldest . ' UTC') : 0; if ($waited > 2 * HOUR_IN_SECONDS) { // The worker runs on WP-Cron; nothing moving for hours usually means cron is not running. $base['label'] = __('The content index has not moved for hours', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'recommended'; $base['description'] .= '' . esc_html(sprintf( /* translators: %s: human-readable time */ __('Items have been waiting for %s. The worker runs on WP-Cron: if DISABLE_WP_CRON is set, make sure a server cron job calls wp-cron.php, or run "wp aipcf embed run". "Keep going until done" on Settings > Content index also works.', 'aipcf-ai-provider-for-cloudflare'), human_time_diff(time() - $waited) )) . '
'; if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) { $base['description'] .= '' . esc_html__('DISABLE_WP_CRON is set on this site.', 'aipcf-ai-provider-for-cloudflare') . '
'; } } } if ($base['status'] === 'good' && $store->id() === 'vectorize') { $projected = self::projectedQueriedDimensions($c['chunks'], $dims); if ($projected > 0.8 * self::VECTORIZE_FREE_QUERIED) { $base['label'] = __('Searches are heading past the Vectorize free plan', 'aipcf-ai-provider-for-cloudflare'); $base['status'] = 'recommended'; $base['description'] .= '' . esc_html(sprintf( /* translators: 1: projected millions of dimensions, 2: free millions */ __('At this month\'s pace, Related posts, Search and Search insights will query about %1$sM vector dimensions. The Vectorize free plan includes %2$sM a month (Workers Paid includes 50M, then $0.01 per million). Lower the daily limits under AIPCF > Features if you are on the free plan.', 'aipcf-ai-provider-for-cloudflare'), number_format($projected / 1000000, 1), number_format(self::VECTORIZE_FREE_QUERIED / 1000000) )) . '
'; } } return $base; } /** Queried vector dimensions a month included in the Vectorize free plan. */ private const VECTORIZE_FREE_QUERIED = 30000000; /** * This month's queried dimensions projected to the month's end: (queries + stored vectors) x dimensions. */ public static function projectedQueriedDimensions(int $storedVectors, int $dims): float { $day = max(1, (int) gmdate('j')); $total = (int) gmdate('t'); return (UsageTracker::monthQueries() / $day * $total + $storedVectors) * $dims; } /** * How many requests failed lately, and the last one (redacted, with its request ID). */ private static function errorsDebug(): string { $all = \WordPress\CloudflareWorkersAiProvider\Diagnostics\ErrorLog::all(); if ($all === []) { return '0'; } $e = $all[0]; return sprintf('%d; last: %s %s HTTP %s %s, ray %s', count($all), $e['service'], $e['path'], $e['status'] > 0 ? (string) $e['status'] : '-', $e['code'], $e['ray'] !== '' ? $e['ray'] : '-'); } public static function debugInfo($info): array { $info = is_array($info) ? $info : []; $s = get_plugin_settings(); $t = UsageTracker::todayTotals(); $left = Backfill::remaining(); $info['aipcf'] = [ 'label' => __('AIPCF - AI Provider for Cloudflare', 'aipcf-ai-provider-for-cloudflare'), 'fields' => [ 'version' => ['label' => __('Plugin version', 'aipcf-ai-provider-for-cloudflare'), 'value' => AIPCF_VERSION], 'connected' => ['label' => __('Connected', 'aipcf-ai-provider-for-cloudflare'), 'value' => (get_account_id() !== '' && get_api_key() !== '') ? __('Yes', 'aipcf-ai-provider-for-cloudflare') : __('No', 'aipcf-ai-provider-for-cloudflare')], 'model' => ['label' => __('Default model', 'aipcf-ai-provider-for-cloudflare'), 'value' => get_default_model_id()], 'light' => ['label' => __('Light model', 'aipcf-ai-provider-for-cloudflare'), 'value' => (string) ($s['light_model'] ?: '-')], 'gateway' => ['label' => __('AI Gateway', 'aipcf-ai-provider-for-cloudflare'), 'value' => Gateway::id() !== '' ? Gateway::id() . ' (' . Gateway::mode() . ')' : '-'], 'budget' => ['label' => __('Daily budget', 'aipcf-ai-provider-for-cloudflare'), 'value' => self::budgetDebug($s)], 'today' => ['label' => __('Neurons today', 'aipcf-ai-provider-for-cloudflare'), 'value' => sprintf( /* translators: 1: neurons used today, 2: number of requests */ _n('%1$s (%2$d request)', '%1$s (%2$d requests)', (int) $t['requests'], 'aipcf-ai-provider-for-cloudflare'), number_format((float) $t['neurons']), (int) $t['requests'] )], 'cloudflare' => ['label' => __('Never fall back to paid providers', 'aipcf-ai-provider-for-cloudflare'), 'value' => !empty($s['cloudflare_only']) ? __('Yes', 'aipcf-ai-provider-for-cloudflare') : __('No', 'aipcf-ai-provider-for-cloudflare')], 'errors' => ['label' => __('Failed requests (7 days)', 'aipcf-ai-provider-for-cloudflare'), 'value' => self::errorsDebug()], 'backfill' => ['label' => __('Backfill', 'aipcf-ai-provider-for-cloudflare'), 'value' => sprintf( /* translators: 1: on or off, 2: e.g. "3 posts without a meta description", 3: e.g. "2 images without alt text" */ __('%1$s. %2$s, %3$s.', 'aipcf-ai-provider-for-cloudflare'), Backfill::enabled() ? __('On', 'aipcf-ai-provider-for-cloudflare') : __('Off', 'aipcf-ai-provider-for-cloudflare'), /* translators: %d: number of posts */ sprintf(_n('%d post without a meta description', '%d posts without a meta description', (int) $left['meta_description'], 'aipcf-ai-provider-for-cloudflare'), (int) $left['meta_description']), /* translators: %d: number of images */ sprintf(_n('%d image without alt text', '%d images without alt text', (int) $left['alt_text'], 'aipcf-ai-provider-for-cloudflare'), (int) $left['alt_text']) )], 'embeddings' => ['label' => __('Content index', 'aipcf-ai-provider-for-cloudflare'), 'value' => self::embeddingsDebug()], ], ]; return $info; } /** * The daily budget for the debug information, as one translated string per case. * * @param array