' . esc_html__('Short tasks such as titles, excerpts and meta descriptions go to a smaller model that costs far fewer neurons.', 'aipcf-ai-provider-for-cloudflare') . '
';
}, $page, SettingsPage::panel());
add_settings_field('light_model', __('Light model', 'aipcf-ai-provider-for-cloudflare'), static function () use ($opt): void {
$v = (string) (get_plugin_settings()['light_model'] ?? '');
printf('';
if ($v === (string) Setup::DEFAULTS['light_model']) {
$help = self::help(
__('Recommended: tested on live sites.', 'aipcf-ai-provider-for-cloudflare'),
__('Why?', 'aipcf-ai-provider-for-cloudflare'),
sprintf(
/* translators: %s: model name */
__('%s costs the fewest neurons per token of the models that handle one-line outputs well. The neurons per 1,000 tokens next to each name are read from your account. Llama 3.2 3B is the closest alternative.', 'aipcf-ai-provider-for-cloudflare'),
pretty_model_name((string) Setup::DEFAULTS['light_model'])
)
);
} elseif ($v === '') {
$help = self::help(__('Off: every task uses the default model.', 'aipcf-ai-provider-for-cloudflare'));
} else {
$help = self::help(__('The list marks the recommended model. The neurons per 1,000 tokens next to each name are read from your account.', 'aipcf-ai-provider-for-cloudflare'));
}
echo $help; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes
}, $page, 'aipcf_routing', ['label_for' => 'aipcf-light-model']);
add_settings_field('light_tasks', __('Tasks that use it', 'aipcf-ai-provider-for-cloudflare'), static function () use ($opt): void {
$saved = get_plugin_settings()['light_tasks'] ?? TaskContext::DEFAULT_LIGHT_TASKS;
$saved = is_array($saved) ? $saved : TaskContext::DEFAULT_LIGHT_TASKS;
$all = array_unique(array_merge(TaskContext::DEFAULT_LIGHT_TASKS, ['summarization', 'content-resizing', 'suggest-reply', 'editorial-notes', 'content-translation']));
echo '' . esc_html(sprintf(
/* translators: %d: number of tasks */
_n('%d task selected', '%d tasks selected', count($saved), 'aipcf-ai-provider-for-cloudflare'),
count($saved)
)) . '';
printf('';
}, $page, 'aipcf_routing', self::fieldArgs(true));
add_settings_section('aipcf_image', __('Images and translation', 'aipcf-ai-provider-for-cloudflare'), static function (): void {
echo '
' . esc_html__('Requests that involve an image never go to the default text model: reading an image (alt text) uses the vision model, generating one uses the image model. Translation goes through M2M100 or IndicTrans2.', 'aipcf-ai-provider-for-cloudflare') . '
';
echo '' . esc_html__('How image generation works and what it costs', 'aipcf-ai-provider-for-cloudflare') . '
'
. esc_html__('The AI plugin first asks your text model to write an image prompt from the post, then sends that prompt to the image model. Alt text is written by the vision model. One 1024x1024 FLUX image costs about 170 neurons, so the free daily allowance covers about 55 images. Prompts are visible under Tools > AI Request Logs when request logging is on in the AI plugin.', 'aipcf-ai-provider-for-cloudflare')
. '
' . esc_html(sprintf(
/* translators: %s: language list */
__('IndicTrans2 targets: %s. Any other language goes through M2M100.', 'aipcf-ai-provider-for-cloudflare'),
implode(', ', array_map('ucfirst', CloudflareWorkersAiTranslationModel::indicLanguages()))
)) . '
';
}, $page, SettingsPage::panel());
add_settings_field('image_model', __('Image model', 'aipcf-ai-provider-for-cloudflare'), static function () use ($opt): void {
$v = (string) (get_plugin_settings()['image_model'] ?? '@cf/black-forest-labs/flux-1-schnell');
printf('';
echo self::help(__('FLUX.1 schnell is the fast default.', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes
}, $page, 'aipcf_image', ['label_for' => 'aipcf-image-model']);
add_settings_field('vision_model', __('Vision model', 'aipcf-ai-provider-for-cloudflare'), static function () use ($opt): void {
$v = (string) (get_plugin_settings()['vision_model'] ?? '');
$vision = array_values(array_filter(self::textModels(), static fn($m): bool => !empty($m['vision'])));
printf('';
$help = self::help(
__('Reads images for alt text and image descriptions, whatever the default model is.', 'aipcf-ai-provider-for-cloudflare'),
__('Why not my default model?', 'aipcf-ai-provider-for-cloudflare'),
__('Only models Cloudflare lists as vision-capable can read an image; the list shows the ones on your account. Llama 3.2 Vision is covered by Meta\'s license (not this plugin\'s), which Cloudflare asks you to accept once; the plugin shows an accept button when that happens.', 'aipcf-ai-provider-for-cloudflare')
);
echo $help; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes
}, $page, 'aipcf_image', ['label_for' => 'aipcf-vision-model']);
// ---- Budget & cache ------------------------------------------------------
$page = SettingsPage::tabPage('budget');
add_settings_section('aipcf_budget', __('Daily budget', 'aipcf-ai-provider-for-cloudflare'), static function (): void {
echo '
' . esc_html__('The daily budget covers every AI request on the site; when it is reached it applies to all of them. Cloudflare gives a free daily allowance of 10,000 neurons, reset at midnight UTC.', 'aipcf-ai-provider-for-cloudflare') . '
';
}, $page, SettingsPage::panel());
self::checkbox('budget_enabled', __('Daily budget', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_budget', __('Stop at a daily budget', 'aipcf-ai-provider-for-cloudflare'));
self::number('budget_neurons', __('Daily budget', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_budget', __('10,000 is the free daily allowance.', 'aipcf-ai-provider-for-cloudflare'), 100, 10000000, false, __('neurons a day', 'aipcf-ai-provider-for-cloudflare'));
add_settings_field('budget_action', __('When it is reached', 'aipcf-ai-provider-for-cloudflare'), static function () use ($opt): void {
$v = (string) (get_plugin_settings()['budget_action'] ?? 'stop');
printf('';
echo self::help(__('Only text requests have a cheaper model. Backfill, the content index, Link assistant chat, the Site assistant, Search, Search insights, image generation, image reading (alt text) and translation pause either way.', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes
}, $page, 'aipcf_budget', ['label_for' => 'aipcf-budget-action']);
self::checkbox('budget_email', __('Email alerts', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_budget', __('Email the site admin when 80% and 100% of the daily budget is used, and send a monthly usage summary', 'aipcf-ai-provider-for-cloudflare'));
self::checkbox('cloudflare_only', __('Never fall back to paid providers', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_budget', __('Cloudflare only, no paid fallbacks', 'aipcf-ai-provider-for-cloudflare'));
add_settings_section('aipcf_budget_share', __('How the daily budget is shared', 'aipcf-ai-provider-for-cloudflare'), [self::class, 'renderBudgetShare'], $page, SettingsPage::panel());
add_settings_section('aipcf_cache', __('Caching', 'aipcf-ai-provider-for-cloudflare'), static function (): void {
echo '
' . esc_html__('Repeated identical prompts are answered from cache for zero neurons.', 'aipcf-ai-provider-for-cloudflare') . '
' . esc_html__('Only requests without a creativity setting (temperature) are cached, so creative requests always get a fresh answer. With an AI Gateway the answer is kept at Cloudflare\'s edge; without one it is kept in this site\'s transients. A "Regenerate" click within ten minutes bypasses both.', 'aipcf-ai-provider-for-cloudflare') . '
';
}, $page, SettingsPage::panel());
self::number('gateway_cache_ttl', __('Edge cache', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_cache', __('How long answers are kept at the edge. Needs an AI Gateway. 0 means off; 3600 (an hour) is a good default.', 'aipcf-ai-provider-for-cloudflare'), 0, 2592000, false, __('seconds', 'aipcf-ai-provider-for-cloudflare'));
self::number('local_cache_ttl', __('Local cache', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_cache', __('How long answers are kept on this site, for sites without a gateway. 0 means off.', 'aipcf-ai-provider-for-cloudflare'), 0, 2592000, true, __('seconds', 'aipcf-ai-provider-for-cloudflare'));
// ---- Gateway ------------------------------------------------------------------
$page = SettingsPage::tabPage('gateway');
add_settings_section('aipcf_gateway', __('Cloudflare AI Gateway', 'aipcf-ai-provider-for-cloudflare'), [self::class, 'renderGatewayIntro'], $page, SettingsPage::panel());
self::text('gateway_id', __('Gateway name', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_gateway', __('From AI > AI Gateway in the Cloudflare dashboard. Leave empty to call Workers AI directly.', 'aipcf-ai-provider-for-cloudflare'));
self::checkbox('gateway_log_payload', __('Log prompts', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_gateway', __('Keep prompt and response text in the gateway logs', 'aipcf-ai-provider-for-cloudflare'), true);
add_settings_field('gateway_token', __('Gateway auth token', 'aipcf-ai-provider-for-cloudflare'), static function () use ($opt): void {
$saved = (string) (get_plugin_settings()['gateway_token'] ?? '') !== '';
// The saved token is never printed into the page; blank keeps it.
printf(
'',
esc_attr($opt),
esc_attr($saved ? __('Saved. Leave blank to keep it.', 'aipcf-ai-provider-for-cloudflare') : '')
);
if ($saved) {
printf(' ', esc_attr($opt), esc_html__('Remove the saved value', 'aipcf-ai-provider-for-cloudflare'));
}
echo self::help(__('Only if the gateway has Authentication switched on. You can also define CLOUDFLARE_AI_GATEWAY_TOKEN in wp-config.php.', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes
}, $page, 'aipcf_gateway', self::fieldArgs(true) + ['label_for' => 'aipcf-gateway-token']);
self::text('dynamic_route', __('Dynamic route', 'aipcf-ai-provider-for-cloudflare'), $page, 'aipcf_gateway', __('A dynamic route is a set of rules, built in the AI Gateway dashboard, that decides which model answers each request. Enter its name to use it: the gateway then picks the model and applies its own budget and rate-limit fallbacks.', 'aipcf-ai-provider-for-cloudflare'), true);
// ---- Backfill: a 2.0 screen (Screens\BackfillScreen); the keys are cleaned in sanitize() as before.
// ---- Prompts ------------------------------------------------------------------
register_setting(SettingsPage::SETTINGS_GROUP, Prompts::OPTION, [
'type' => 'array',
'sanitize_callback' => [self::class, 'sanitizePrompts'],
'default' => [],
]);
$page = SettingsPage::tabPage('prompts');
add_settings_section('aipcf_prompts', __('Prompts', 'aipcf-ai-provider-for-cloudflare'), static function (): void {
echo '
' . esc_html__('The prompts this plugin sends on its own behalf. Edit freely; "Reset" restores the default.', 'aipcf-ai-provider-for-cloudflare') . '
';
echo '' . esc_html__('Placeholders and overrides', 'aipcf-ai-provider-for-cloudflare') . '
' . esc_html__('Placeholders in braces are filled in at run time. Developers can override with the aipcf_prompt filter, which runs after your text. The AI plugin\'s own editor features (excerpt, title, summarize and others) use its prompts, not these.', 'aipcf-ai-provider-for-cloudflare') . '
';
if ($custom) {
printf('%s ', esc_html__('Customized', 'aipcf-ai-provider-for-cloudflare'));
printf(
'%s ',
esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_prompt_test&reset=1&key=' . rawurlencode($key)), 'aipcf_prompt_test')),
esc_attr__('Reset this prompt to the default? Your edits to it are lost.', 'aipcf-ai-provider-for-cloudflare'),
esc_html__('Reset to default', 'aipcf-ai-provider-for-cloudflare')
);
}
if (in_array($key, ['meta_description', 'alt_text'], true)) {
printf(
'%s',
esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_prompt_test&key=' . rawurlencode($key)), 'aipcf_prompt_test')),
esc_attr__('Testing...', 'aipcf-ai-provider-for-cloudflare'),
esc_html__('Test on the latest item (does not save the result)', 'aipcf-ai-provider-for-cloudflare')
);
}
echo '
';
}, $page, 'aipcf_prompts', self::fieldArgs(in_array($key, ['system_suffix', 'vision_system'], true)) + ['label_for' => self::fieldId('prompt_' . $key)]);
}
}
public static function sanitizePrompts($input): array
{
if (!is_array($input)) {
return Prompts::overrides();
}
// Must not write the option here: update_option() re-runs this callback.
return Prompts::normalize($input);
}
public static function handlePromptTest(): 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_prompt_test');
$key = isset($_GET['key']) ? sanitize_key(wp_unslash((string) $_GET['key'])) : '';
if (!isset(Prompts::definitions()[$key])) {
wp_safe_redirect(SettingsPage::tabUrl('prompts'));
exit;
}
if (!empty($_GET['reset'])) {
$o = Prompts::overrides();
unset($o[$key]);
$o === [] ? delete_option(Prompts::OPTION) : update_option(Prompts::OPTION, $o, false);
set_transient('aipcf_notice_' . get_current_user_id(), ['type' => 'success', 'message' => __('Prompt reset to default.', 'aipcf-ai-provider-for-cloudflare')], 60);
wp_safe_redirect(SettingsPage::tabUrl('prompts') . '#' . self::fieldId('prompt_' . $key));
exit;
}
$before = UsageTracker::todayNeurons();
$out = '';
// Same task context as the backfill so the test uses the same (light) model and cost.
TaskContext::pin($key === 'alt_text' ? 'backfill-alt-text' : 'backfill-meta-description');
try {
if ($key === 'meta_description') {
$ids = get_posts(['numberposts' => 1, 'post_status' => 'publish', 'fields' => 'ids']);
if ($ids === []) {
throw new \RuntimeException(__('No published post to test on.', 'aipcf-ai-provider-for-cloudflare'));
}
$post = get_post((int) $ids[0]);
$content = mb_substr(trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags(strip_shortcodes((string) $post->post_content)))), 0, 6000);
$r = \WordPress\AiClient\AiClient::prompt("Title: " . get_the_title($post) . "\n\nContent:\n" . $content)
->usingProvider('cloudflare-workers-ai')->usingSystemInstruction(Prompts::render('meta_description'))->usingMaxTokens(120)->generateTextResult();
$out = sprintf(
/* translators: 1: generated text, 2: number of characters, 3: post title */
__('"%1$s" (%2$d characters, from the post "%3$s")', 'aipcf-ai-provider-for-cloudflare'),
trim($r->toText()),
mb_strlen(trim($r->toText())),
get_the_title($post)
);
} else {
$ids = get_posts(['numberposts' => 1, 'post_type' => 'attachment', 'post_mime_type' => 'image', 'post_status' => 'inherit', 'fields' => 'ids']);
if ($ids === []) {
throw new \RuntimeException(__('No image in the media library to test on.', 'aipcf-ai-provider-for-cloudflare'));
}
$aid = (int) $ids[0];
$path = (string) (get_attached_file($aid) ?: '');
$src = $path !== '' && file_exists($path) ? $path : (string) wp_get_attachment_url($aid);
$vm = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel('cloudflare-workers-ai', vision_model_id());
$msg = new \WordPress\AiClient\Messages\DTO\Message(\WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(), [
new \WordPress\AiClient\Messages\DTO\MessagePart(Prompts::render('alt_text')),
new \WordPress\AiClient\Messages\DTO\MessagePart(new \WordPress\AiClient\Files\DTO\File($src)),
]);
$r = \WordPress\AiClient\AiClient::prompt([$msg])->usingModel($vm)->usingMaxTokens(80)->generateTextResult();
/* translators: 1: generated alt text, 2: image file name */
$out = sprintf(__('"%1$s" (for %2$s)', 'aipcf-ai-provider-for-cloudflare'), trim($r->toText()), basename($src));
}
$cost = UsageTracker::todayNeurons() - $before;
set_transient('aipcf_notice_' . get_current_user_id(), ['type' => 'success', 'message' => sprintf(
/* translators: 1: result, 2: neurons */
__('Result: %1$s. Cost: %2$s neurons.', 'aipcf-ai-provider-for-cloudflare'),
$out,
number_format($cost, 2)
)], 60);
} catch (\Throwable $e) {
set_transient('aipcf_notice_' . get_current_user_id(), ['type' => 'error', 'message' => $e->getMessage()], 60);
}
TaskContext::clear();
wp_safe_redirect(SettingsPage::tabUrl('prompts') . '#' . self::fieldId('prompt_' . $key));
exit;
}
// ================================================================ overview
public static function tryItHint(): array
{
$on = static function (string $feature): bool {
return (bool) get_option('wpai_features_enabled') && (bool) get_option('wpai_feature_' . $feature . '_enabled', false);
};
if ($on('meta-description')) {
return [
'text' => __('Open a post, expand "Meta description" in the sidebar and click "Generate meta description". About 2 neurons.', 'aipcf-ai-provider-for-cloudflare'),
'url' => admin_url('edit.php'),
'label' => __('Posts', 'aipcf-ai-provider-for-cloudflare'),
];
}
if ($on('excerpt')) {
return [
'text' => __('Open a post and click "Generate excerpt" in the excerpt panel. About 2 neurons.', 'aipcf-ai-provider-for-cloudflare'),
'url' => admin_url('edit.php'),
'label' => __('Posts', 'aipcf-ai-provider-for-cloudflare'),
];
}
if ($on('alt-text-generation')) {
return [
'text' => __('Open an image in the Media Library and click "Generate alt text". About 17 neurons.', 'aipcf-ai-provider-for-cloudflare'),
'url' => admin_url('upload.php'),
'label' => __('Media Library', 'aipcf-ai-provider-for-cloudflare'),
];
}
return [
'text' => __('Turn on a feature in the AI plugin (meta descriptions, excerpts, alt text) and it will use this provider.', 'aipcf-ai-provider-for-cloudflare'),
'url' => admin_url('options-general.php?page=ai-wp-admin'),
'label' => __('AI settings', 'aipcf-ai-provider-for-cloudflare'),
];
}
public static function renderModelMap(): void
{
echo '
' . esc_html__('Current model setup', 'aipcf-ai-provider-for-cloudflare') . '
';
foreach (ModelMap::rows() as [$what, $model, $note]) {
printf('
';
}
public static function renderGatewayIntro(): void
{
echo '
' . esc_html__('Routes requests through Cloudflare AI Gateway for edge caching, logs, rate limits and analytics.', 'aipcf-ai-provider-for-cloudflare') . '
' . esc_html__('Spend limit (optional): the dollar equivalent of 10,000 neurons a day (the dashboard shows the rate). Keep the plugin\'s daily budget on as the exact stop.', 'aipcf-ai-provider-for-cloudflare') . '
';
echo '
' . esc_html__('Cache: leave it off in the dashboard; set how long answers are kept at the edge on Settings > Budget instead.', 'aipcf-ai-provider-for-cloudflare') . '
';
echo '';
}
// ================================================================ notices
public static function gatewayNotice(): void
{
if (!current_user_can('manage_options') || Gateway::id() === '' || !get_transient(Gateway::DOWN_TRANSIENT)) {
return;
}
$last = get_option(Gateway::LAST_ERROR_OPTION);
printf(
'
',
esc_html__('Cloudflare AI Gateway bypassed.', 'aipcf-ai-provider-for-cloudflare'),
esc_html(is_array($last) && !empty($last['reason']) ? (string) $last['reason'] : ''),
esc_html__('Requests go directly to Workers AI for up to an hour.', 'aipcf-ai-provider-for-cloudflare'),
esc_url(SettingsPage::tabUrl('gateway')),
esc_html__('Check the gateway settings', 'aipcf-ai-provider-for-cloudflare')
);
}
public static function licenceNotice(): void
{
if (!current_user_can('manage_options')) {
return;
}
$gates = get_option(Gateway::LICENCE_OPTION, []);
if (!is_array($gates) || $gates === []) {
return;
}
foreach (array_keys($gates) as $modelId) {
$model = (string) $modelId;
$owner = Gateway::licenceOwner($model);
echo '
' . esc_html(sprintf(
/* translators: 1: license owner, e.g. Meta, 2: model name */
__('%1$s\'s license for %2$s needs to be accepted once.', 'aipcf-ai-provider-for-cloudflare'),
$owner,
pretty_model_name($model)
)) . ' ';
echo esc_html(sprintf(
/* translators: 1: license owner, e.g. Meta */
__('This is %1$s\'s license, not AIPCF\'s. Cloudflare will not run this model on your account until you accept it, and Cloudflare keeps the record of your acceptance. AIPCF has no license of its own: the button below only sends your acceptance ("agree") to Cloudflare with your API token, exactly as the manual command does. Alt text and image reading use this model.', 'aipcf-ai-provider-for-cloudflare'),
$owner
)) . '
';
$terms = sprintf(
/* translators: 1: license owner, 2: link to the model page */
__('By accepting, you agree to %1$s\'s license terms and acceptable use policy, linked on %2$s.', 'aipcf-ai-provider-for-cloudflare'),
esc_html($owner),
'' . esc_html__('Cloudflare\'s page for this model', 'aipcf-ai-provider-for-cloudflare') . ''
);
if (Gateway::licenceExcludesEu($model)) {
/* translators: %s: license owner */
$terms .= ' ' . esc_html(sprintf(__('You also confirm you are not based in the EU: %s\'s license excludes EU residents for this model.', 'aipcf-ai-provider-for-cloudflare'), $owner));
}
$terms .= ' ' . esc_html__('It is done once per Cloudflare account.', 'aipcf-ai-provider-for-cloudflare');
echo '
' . wp_kses_post($terms) . '
';
// This notice shows on every admin screen, where the plugin's stylesheet is not loaded,
// so it uses only WordPress notice markup: the
elements give the spacing. The
// sits between two paragraphs so their margins space it, open or closed.
echo '' . esc_html__('Prefer to send it yourself?', 'aipcf-ai-provider-for-cloudflare') . '
' . esc_html__('Run this once from any machine, with your Workers AI token in place of TOKEN:', 'aipcf-ai-provider-for-cloudflare') . ' curl -X POST https://api.cloudflare.com/client/v4/accounts/' . esc_html(get_account_id()) . '/ai/run/' . esc_html($model) . ' -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d \'{"prompt":"agree"}\'
';
echo '
';
}
}
// ================================================================ handlers
public static function handleRefreshModels(): 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_refresh_models');
flush_model_caches();
$n = count(fetch_catalog());
$t = count(fetch_available_models());
set_transient('aipcf_notice_' . get_current_user_id(), [
'type' => $n > 0 ? 'success' : 'error',
'message' => $n > 0
/* translators: 1: number of text models, 2: number of models in total */
? sprintf(_n('Model list refreshed: %1$d text model (%2$d models in total) on your account.', 'Model list refreshed: %1$d text models (%2$d models in total) on your account.', $t, 'aipcf-ai-provider-for-cloudflare'), $t, $n)
: (core_connector_is_available() && (string) (get_plugin_settings()['api_key'] ?? '') === ''
? __('Could not read the model list from Cloudflare. Check the API token on Settings > Connectors and the Account ID on Settings > Connection.', 'aipcf-ai-provider-for-cloudflare')
: __('Could not read the model list from Cloudflare. Check the credentials on Settings > Connection.', 'aipcf-ai-provider-for-cloudflare')),
], 60);
wp_safe_redirect(SettingsPage::tabUrl('models') . '#aipcf_preferred_model');
exit;
}
public static function handleLicenceAccept(): 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_licence_accept');
$model = isset($_POST['model']) ? sanitize_text_field(wp_unslash((string) $_POST['model'])) : '';
$result = Gateway::acceptLicence($model);
set_transient('aipcf_notice_' . get_current_user_id(), [
'type' => $result === true ? 'success' : 'error',
'message' => $result === true
/* translators: 1: license owner, 2: model id */
? sprintf(__('Cloudflare has recorded your acceptance of %1$s\'s license for %2$s. Alt text and image reading work now.', 'aipcf-ai-provider-for-cloudflare'), Gateway::licenceOwner($model), $model)
/* translators: %s: error */
: sprintf(__('Cloudflare did not record the acceptance: %s', 'aipcf-ai-provider-for-cloudflare'), $result),
], 60);
wp_safe_redirect(SettingsPage::tabUrl('overview'));
exit;
}
public static function handleLicenceRecheck(): 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_licence_recheck');
$model = isset($_GET['model']) ? sanitize_text_field(wp_unslash((string) $_GET['model'])) : '';
$ok = false;
$msg = '';
if ($model !== '' && class_exists('\\WordPress\\AiClient\\AiClient')) {
try {
TaskContext::set('licence-recheck');
$m = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel('cloudflare-workers-ai', $model);
\WordPress\AiClient\AiClient::prompt('Reply with OK')->usingModel($m)->usingMaxTokens(5)->generateTextResult();
$ok = true;
Gateway::clearLicenceGate($model);
} catch (\Throwable $e) {
$msg = $e->getMessage();
}
TaskContext::clear();
}
set_transient('aipcf_notice_' . get_current_user_id(), [
'type' => $ok ? 'success' : 'error',
'message' => $ok
? sprintf(
/* translators: 1: model id, 2: license owner */
__('%1$s answered: Cloudflare has your acceptance of %2$s\'s license on record.', 'aipcf-ai-provider-for-cloudflare'),
$model,
Gateway::licenceOwner($model)
)
: sprintf(
/* translators: %s: error */
__('Still blocked: %s', 'aipcf-ai-provider-for-cloudflare'),
$msg
),
], 60);
wp_safe_redirect(SettingsPage::tabUrl('overview'));
exit;
}
public static function handleGatewayTest(): 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_gateway_test');
Gateway::markUp();
// The site's own default model, so the test shows up in the gateway logs as
// the model the site actually uses.
$model = get_default_model_id();
$url = Gateway::chatUrl();
$body = [
'model' => Gateway::modelParam($model),
'messages' => [['role' => 'user', 'content' => 'Reply with OK']],
'max_tokens' => 5,
];
$headers = ['Authorization' => 'Bearer ' . get_api_key(), 'Content-Type' => 'application/json']
+ Gateway::headers('gateway-test', false, $model);
$resp = wp_remote_post($url, ['timeout' => 30, 'headers' => $headers, 'body' => wp_json_encode($body)]);
if (is_wp_error($resp)) {
$msg = $resp->get_error_message();
$ok = false;
} else {
$code = (int) wp_remote_retrieve_response_code($resp);
$ok = $code >= 200 && $code < 300;
$data = json_decode((string) wp_remote_retrieve_body($resp), true);
if ($ok) {
$msg = Gateway::isGatewayUrl($url)
/* translators: %d: HTTP status code */
? sprintf(__('HTTP %d, through the gateway', 'aipcf-ai-provider-for-cloudflare'), $code)
/* translators: %d: HTTP status code */
: sprintf(__('HTTP %d, directly (the gateway is bypassed)', 'aipcf-ai-provider-for-cloudflare'), $code);
} else {
$msg = sprintf('HTTP %d: %s', $code, self::errorText($data, (string) wp_remote_retrieve_body($resp)));
}
if ($ok && is_array($data)) {
UsageTracker::recordFromResponse($model, $data, 'gateway-test');
}
}
set_transient('aipcf_notice_' . get_current_user_id(), [
'type' => $ok ? 'success' : 'error',
'message' => $ok
/* translators: %s: detail, e.g. "HTTP 200, through the gateway" */
? sprintf(__('Gateway test passed: %s', 'aipcf-ai-provider-for-cloudflare'), $msg)
/* translators: %s: error detail */
: sprintf(__('Gateway test failed: %s', 'aipcf-ai-provider-for-cloudflare'), $msg),
], 60);
wp_safe_redirect(SettingsPage::tabUrl('gateway'));
exit;
}
private static function errorText($data, string $fallback): string
{
$m = is_array($data) ? ($data['errors'][0]['message'] ?? $data['error'][0]['message'] ?? $data['error']['message'] ?? $data['error'] ?? null) : null;
return is_scalar($m) && (string) $m !== '' ? (string) $m : $fallback;
}
// ================================================================ sanitize
public static function sanitize(array $clean, array $input): array
{
$prev = get_plugin_settings();
$tab = isset($input['_tab']) ? sanitize_key((string) $input['_tab']) : '';
$own = static function (string $key) use ($tab): bool {
return $tab === '' || in_array($key, SettingsPage::tabKeys($tab), true);
};
$keep = static function (string $key) use ($prev, $input) {
return array_key_exists($key, $input) ? $input[$key] : ($prev[$key] ?? null);
};
$flag = static function (string $key, int $default) use ($own, $input, $prev): int {
return $own($key) ? (empty($input[$key]) ? 0 : 1) : (int) ($prev[$key] ?? $default);
};
$clean['gateway_id'] = sanitize_text_field((string) $keep('gateway_id'));
$token = sanitize_text_field(trim((string) ($input['gateway_token'] ?? '')));
if (!empty($input['gateway_token_clear'])) {
$token = '';
} elseif ($token === '') {
$token = (string) ($prev['gateway_token'] ?? '');
}
$clean['gateway_token'] = $token;
$clean['dynamic_route'] = sanitize_text_field((string) $keep('dynamic_route'));
$clean['gateway_log_payload'] = $flag('gateway_log_payload', 1);
$clean['gateway_cache_ttl'] = self::int($keep('gateway_cache_ttl') ?? 0, 0, 2592000);
$clean['local_cache_ttl'] = self::int($keep('local_cache_ttl') ?? 0, 0, 2592000);
$clean['budget_enabled'] = $flag('budget_enabled', 0);
$clean['budget_neurons'] = self::int($keep('budget_neurons') ?? UsageTracker::FREE_TIER, 100, 10000000);
$clean['budget_action'] = ($keep('budget_action') ?? 'stop') === 'cheapest' ? 'cheapest' : 'stop';
$clean['budget_email'] = $flag('budget_email', 1);
$clean['budget_account'] = $flag('budget_account', 1);
$clean['cloudflare_only'] = $flag('cloudflare_only', 0);
$clean['light_model'] = sanitize_text_field((string) $keep('light_model'));
if ($own('light_tasks')) {
$tasks = isset($input['light_tasks']) && is_array($input['light_tasks']) ? array_map('sanitize_key', $input['light_tasks']) : [];
$clean['light_tasks'] = array_values(array_filter($tasks));
} else {
$clean['light_tasks'] = is_array($prev['light_tasks'] ?? null) ? $prev['light_tasks'] : TaskContext::DEFAULT_LIGHT_TASKS;
}
$clean['image_model'] = sanitize_text_field((string) ($keep('image_model') ?: '@cf/black-forest-labs/flux-1-schnell'));
$clean['vision_model'] = sanitize_text_field((string) $keep('vision_model'));
$clean['backfill_enabled'] = $flag('backfill_enabled', 0);
if ($own('backfill_tasks')) {
$bt = isset($input['backfill_tasks']) && is_array($input['backfill_tasks']) ? array_map('sanitize_key', $input['backfill_tasks']) : [];
$clean['backfill_tasks'] = array_values(array_intersect(['meta_description', 'alt_text'], $bt));
} else {
$clean['backfill_tasks'] = is_array($prev['backfill_tasks'] ?? null) ? $prev['backfill_tasks'] : ['meta_description'];
}
$clean['backfill_daily_neurons'] = self::int($keep('backfill_daily_neurons') ?? 3000, 0, 10000000);
$clean['backfill_batch'] = self::int($keep('backfill_batch') ?? 10, 1, 200);
if (($clean['gateway_id'] !== ($prev['gateway_id'] ?? '')) || ($clean['gateway_token'] !== ($prev['gateway_token'] ?? ''))) {
Gateway::markUp();
}
return $clean;
}
// ================================================================ helpers
private static function int($v, int $min, int $max): int
{
return max($min, min($max, (int) $v));
}
/** A field's HTML id: "aipcf-" and the setting key with dashes. */
private static function fieldId(string $key): string
{
return 'aipcf-' . str_replace('_', '-', $key);
}
private static function checkbox(string $key, string $label, string $page, string $section, string $desc, bool $advanced = false): void
{
$opt = AIPCF_SETTINGS_OPTION;
$id = self::fieldId($key);
add_settings_field($key, $label, static function () use ($key, $opt, $desc, $id): void {
$v = !empty(get_plugin_settings()[$key]);
printf('', esc_attr($id), esc_attr($opt), esc_attr($key), checked($v, true, false), esc_html($desc));
}, $page, $section, self::fieldArgs($advanced) + ['label_for' => $id]);
}
/**
* Each tool's own daily limit inside the daily budget, and what it used today. Problems come
* first, as notices: today's use past the daily budget or the free daily allowance, and limits
* that add up to more than the daily budget.
*/
public static function renderBudgetShare(): void
{
$s = get_plugin_settings();
$today = UsageTracker::todayTotals();
$tasks = (array) ($today['tasks'] ?? []);
$used = (float) ($today['neurons'] ?? 0);
$a = Budget::allocation($s);
$f = static fn(string $k) => FeatureSettings::get($k);
$names = [
'backfill' => __('Backfill', 'aipcf-ai-provider-for-cloudflare'),
'embed' => __('Embeddings', 'aipcf-ai-provider-for-cloudflare'),
'links' => __('Link assistant (chat)', 'aipcf-ai-provider-for-cloudflare'),
'search' => __('Search', 'aipcf-ai-provider-for-cloudflare'),
'insights' => __('Search insights', 'aipcf-ai-provider-for-cloudflare'),
'chat' => __('Site assistant', 'aipcf-ai-provider-for-cloudflare'),
'agents' => __('AI agents', 'aipcf-ai-provider-for-cloudflare'),
];
$usedBy = [
'backfill' => UsageTracker::todayBackfillNeurons(),
'embed' => UsageTracker::todayEmbedNeurons(),
'links' => (float) ($tasks['links'] ?? 0),
'search' => (float) ($tasks['search'] ?? 0),
'insights' => (float) ($tasks['insights'] ?? 0),
'chat' => (float) ($tasks['chat'] ?? 0) + (float) ($tasks['chat-public'] ?? 0),
'agents' => (float) ($tasks['agents'] ?? 0),
];
// 1. Today's use has crossed the line.
if ($a['guarded'] && $used >= $a['ceiling']) {
printf(
'
%s %s
',
esc_html__('The daily budget is used up for today.', 'aipcf-ai-provider-for-cloudflare'),
esc_html(UsageTracker::budgetAction() === 'cheapest'
? sprintf(
/* translators: 1: neurons used, 2: daily budget */
__('%1$s of %2$s neurons used. 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($a['ceiling'])
)
: sprintf(
/* translators: 1: neurons used, 2: daily budget */
__('%1$s of %2$s neurons used. AI requests are paused until midnight UTC.', 'aipcf-ai-provider-for-cloudflare'),
number_format_i18n($used),
number_format_i18n($a['ceiling'])
))
);
} elseif (!$a['guarded'] && $used > $a['ceiling']) {
$over = $used - $a['ceiling'];
printf(
'
%s %s
',
esc_html__('Today is past the free daily allowance.', 'aipcf-ai-provider-for-cloudflare'),
esc_html(sprintf(
/* translators: 1: neurons used, 2: free daily allowance in neurons, 3: neurons over, 4: US dollars */
__('%1$s neurons used; the free daily allowance is %2$s, so %3$s are billed so far (about $%4$s). No daily budget is set, so nothing stops it: switch on Daily budget above to set a limit.', 'aipcf-ai-provider-for-cloudflare'),
number_format_i18n($used),
number_format_i18n($a['ceiling']),
number_format_i18n($over),
number_format_i18n($over / 1000 * UsageTracker::USD_PER_1K_NEURONS, 2)
))
);
}
// 2. The limits do not fit.
$warning = Budget::overAllocation($s);
if ($warning !== '') {
printf(
'
%s %s
',
$a['guarded']
? esc_html__('The tools can use more than the daily budget allows.', 'aipcf-ai-provider-for-cloudflare')
: esc_html__('The tools can use more than the free daily allowance.', 'aipcf-ai-provider-for-cloudflare'),
esc_html($warning)
);
}
echo '
' . esc_html__('Each tool also has a daily limit of its own. When a tool reaches it, only that tool stops for the day; the others carry on. What the tools do not use is left for the AI features in the editor, which have no limit of their own.', 'aipcf-ai-provider-for-cloudflare') . '
',
esc_url(SettingsPage::tabUrl('content') . '#aipcf-related-daily-queries'),
esc_html__('Related posts', 'aipcf-ai-provider-for-cloudflare'),
$tag(!empty($f('related_enabled'))), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $tag
esc_html($relatedCap > 0
/* translators: %s: vector store queries a day */
? sprintf(__('No neurons; %s vector store queries a day', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($relatedCap))
: __('No neurons; no limit on vector store queries', 'aipcf-ai-provider-for-cloudflare')),
/* translators: %d: vector store queries */
esc_html(sprintf(_n('%d query', '%d queries', $relatedQueries, 'aipcf-ai-provider-for-cloudflare'), $relatedQueries))
);
printf(
'
%s
—
%s
%s
',
esc_html__('Editor AI features (excerpts, titles, alt text on upload...)', 'aipcf-ai-provider-for-cloudflare'),
esc_html__('What the tools leave', 'aipcf-ai-provider-for-cloudflare'),
esc_html(number_format_i18n(max(0.0, $used - $counted)))
);
echo '';
$share = (int) round(100 * $a['limits'] / max(1, $a['ceiling']));
$over = $a['limits'] > $a['ceiling'];
printf(
'
%s
%s
%s
%s
',
esc_attr($over ? 'aipcf-over' : ''),
esc_html($a['guarded'] ? __('Tools that are on, against the daily budget', 'aipcf-ai-provider-for-cloudflare') : __('Tools that are on, against the free daily allowance', 'aipcf-ai-provider-for-cloudflare')),
$over ? '' . esc_html__('Over', 'aipcf-ai-provider-for-cloudflare') . '' : '—', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal markup, escaped label
/* translators: 1: sum of limits, 2: daily budget or free daily allowance, 3: percentage */
esc_html(sprintf(__('%1$s of %2$s (%3$d%%)', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($a['limits']), number_format_i18n($a['ceiling']), $share)),
/* translators: 1: neurons used today, 2: daily budget or free daily allowance */
esc_html(sprintf(__('%1$s of %2$s', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($used), number_format_i18n($a['ceiling'])))
);
echo '
';
}
/**
* After a save on any tab: warn when the tools' limits no longer fit the budget, so the
* owner sees it where the limit was changed (Backfill, Embeddings, Content tools).
*
* @param array $clean
* @return array
*/
public static function warnOverAllocation(array $clean): array
{
$warning = Budget::overAllocation($clean);
if ($warning !== '' && function_exists('add_settings_error')) {
add_settings_error(\WordPress\CloudflareWorkersAiProvider\AIPCF_SETTINGS_OPTION, 'aipcf_overallocated', $warning . ' ' . __('Settings > Budget shows how the daily budget is shared.', 'aipcf-ai-provider-for-cloudflare'), 'warning');
}
return $clean;
}
public static function fieldArgs(bool $advanced): array
{
return $advanced ? ['class' => 'aipcf-advanced'] : [];
}
/**
* Help text below a field. A
, not a
, because it can hold a block.
*/
public static function help(string $line, string $moreLabel = '', string $more = ''): string
{
$html = '