'', 'account_id' => '', 'preferred_model' => '', ]; $stored = get_option(AIPCF_SETTINGS_OPTION, []); if (!is_array($stored)) { $stored = []; } return array_merge($defaults, $stored); } /** * Returns the configured Cloudflare API Token. * * Resolution order (first non-empty value wins): * 1. Plugin's own settings option. * 2. CLOUDFLARE_WORKERS_AI_API_TOKEN PHP constant from wp-config.php. * 3. CLOUDFLARE_WORKERS_AI_API_TOKEN environment variable. * * When the WordPress core Connectors screen manages the API key the AI Client * registry handles authentication directly; this function intentionally does * not read that option. * * @since 1.0.0 * * @return string */ function get_api_key(): string { $settings = get_plugin_settings(); if (!empty($settings['api_key'])) { return (string) $settings['api_key']; } if (defined('CLOUDFLARE_WORKERS_AI_API_TOKEN') && is_string(\CLOUDFLARE_WORKERS_AI_API_TOKEN)) { return \CLOUDFLARE_WORKERS_AI_API_TOKEN; } $envKey = getenv('CLOUDFLARE_WORKERS_AI_API_TOKEN'); if (is_string($envKey) && $envKey !== '') { return $envKey; } return ''; } /** * Returns the configured Cloudflare Account ID. * * Resolution order (first non-empty value wins): * 1. Plugin's own settings option. * 2. CLOUDFLARE_WORKERS_AI_ACCOUNT_ID PHP constant from wp-config.php. * 3. CLOUDFLARE_WORKERS_AI_ACCOUNT_ID environment variable. * * @since 1.0.0 * * @return string */ function get_account_id(): string { $settings = get_plugin_settings(); if (!empty($settings['account_id'])) { return (string) $settings['account_id']; } if (defined('CLOUDFLARE_WORKERS_AI_ACCOUNT_ID') && is_string(\CLOUDFLARE_WORKERS_AI_ACCOUNT_ID)) { return \CLOUDFLARE_WORKERS_AI_ACCOUNT_ID; } $envId = getenv('CLOUDFLARE_WORKERS_AI_ACCOUNT_ID'); if (is_string($envId) && $envId !== '') { return $envId; } return ''; } /** * Returns the model ID that should be used as the default for auto-discovery. * * Resolution order (first non-empty value wins): * 1. "Default model" setting saved on the Settings → Cloudflare Workers AI page. * 2. CLOUDFLARE_WORKERS_AI_DEFAULT_MODEL PHP constant from wp-config.php. * 3. CLOUDFLARE_WORKERS_AI_DEFAULT_MODEL environment variable. * 4. Built-in default: @cf/meta/llama-4-scout-17b-16e-instruct. * * @since 1.0.0 * * @return string */ function get_default_model_id(): string { $settings = get_plugin_settings(); if (!empty($settings['preferred_model'])) { return (string) $settings['preferred_model']; } if (defined('CLOUDFLARE_WORKERS_AI_DEFAULT_MODEL') && is_string(\CLOUDFLARE_WORKERS_AI_DEFAULT_MODEL)) { return \CLOUDFLARE_WORKERS_AI_DEFAULT_MODEL; } $envModel = getenv('CLOUDFLARE_WORKERS_AI_DEFAULT_MODEL'); if (is_string($envModel) && $envModel !== '') { return $envModel; } return '@cf/meta/llama-4-scout-17b-16e-instruct'; } /** * Fetches the list of available Cloudflare Workers AI text generation models. * * Results are cached in a transient for 12 hours. Returns an empty array when * credentials are not configured or the API call fails — callers should fall * back to a static list in that case. * * @since 1.0.0 * * @return list */ function fetch_available_models(): array { $cached = get_transient(AIPCF_MODELS_TRANSIENT); if (is_array($cached)) { /** @var list $cached */ return $cached; } $accountId = get_account_id(); $apiKey = get_api_key(); if ($accountId === '' || $apiKey === '') { return []; } $url = sprintf( 'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=100', rawurlencode($accountId) ); $response = wp_remote_get( $url, [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json', ], ] ); if (is_wp_error($response)) { return []; } $status = (int) wp_remote_retrieve_response_code($response); if ($status < 200 || $status >= 300) { return []; } $body = json_decode((string) wp_remote_retrieve_body($response), true); if (!is_array($body) || !isset($body['result']) || !is_array($body['result'])) { return []; } $models = []; foreach ($body['result'] as $item) { if (!is_array($item) || empty($item['name']) || !is_string($item['name'])) { continue; } // The API returns a UUID in `id` and the @cf/... path identifier in `name`. // We need the path identifier for API calls, not the UUID. $modelId = $item['name']; // Skip non-text-generation models (embeddings, image gen, TTS, ASR, etc.) // so the dropdown only shows models that can handle chat/completion tasks. $taskName = isset($item['task']['name']) && is_string($item['task']['name']) ? $item['task']['name'] : ''; if ($taskName !== 'Text Generation') { continue; } $models[] = [ 'id' => $modelId, 'name' => $modelId, ]; } if (!empty($models)) { set_transient(AIPCF_MODELS_TRANSIENT, $models, 12 * HOUR_IN_SECONDS); } return $models; } /** * Returns whether this plugin has been approved to use the Cloudflare Workers AI connector. * * The "AI" plugin can gate AI connector usage behind per-plugin administrator * approval via its Connector_Approval experiment. When that gate is not active * (the class doesn't exist), every caller is treated as approved. * * @since 1.0.0 * * @return bool */ function is_connector_approved(): bool { if (!class_exists('\WordPress\AI\Connector_Approval\Approvals_Store')) { return true; } if (!defined('AIPCF_FILE')) { return false; } $approvals = get_option('wpai_connector_approvals', []); if (!is_array($approvals)) { return false; } $basename = plugin_basename(\AIPCF_FILE); return !empty($approvals[$basename]['cloudflare-workers-ai']); } /** * Whether the WordPress core "Connectors" admin screen is managing this provider's API key. * * When true, the plugin's own settings page should defer the API key field to that screen * to avoid a confusing duplicate input. * * @since 1.0.0 * * @return bool */ function core_connector_is_available(): bool { return function_exists('wp_is_connector_registered') && wp_is_connector_registered('cloudflare-workers-ai'); } /** * Registers the Cloudflare Workers AI provider with the WordPress AI Client. * * @since 1.0.0 * * @return void */ function register_provider(): void { if (!class_exists(AiClient::class)) { return; } $registry = AiClient::defaultRegistry(); if (!$registry->hasProvider(CloudflareWorkersAiProvider::class)) { $registry->registerProvider(CloudflareWorkersAiProvider::class); } $apiKey = get_api_key(); if ($apiKey !== '') { $registry->setProviderRequestAuthentication( CloudflareWorkersAiProvider::class, new ApiKeyRequestAuthentication($apiKey) ); } } add_action('init', __NAMESPACE__ . '\\register_provider', 5); /** * Boots the admin settings page in WP Admin only. * * @since 1.0.0 * * @return void */ function boot_admin(): void { if (!is_admin()) { return; } SettingsPage::boot(); } add_action('plugins_loaded', __NAMESPACE__ . '\\boot_admin'); /** * Prepends the configured Cloudflare Workers AI model to the WordPress AI * plugin's text-model preference list. * * The `wpai_preferred_text_models` filter is provided by the WordPress "AI" * plugin and consumed by every ability that calls * `get_preferred_models_for_text_generation()` (excerpt generation, comment * moderation, etc.). Each entry is a `[provider_id, model_id]` tuple tried in * order; the first available provider wins. Prepending ensures Cloudflare is * attempted before the built-in Anthropic / Google / OpenAI fallbacks, so a * site that only has Cloudflare credentials still gets AI features. * * We skip the prepend when credentials are not configured so as not to queue * a provider that would fail immediately and degrade the fallback chain. * * @since 1.0.0 * * @param mixed $models Existing preference list (expected list). * @return array */ function filter_preferred_text_models($models): array { if (!is_array($models)) { $models = []; } if (get_api_key() === '' || get_account_id() === '') { return $models; } $entry = [CloudflareWorkersAiProvider::ID, get_default_model_id()]; foreach ($models as $candidate) { if ( is_array($candidate) && isset($candidate[0], $candidate[1]) && $candidate[0] === $entry[0] && $candidate[1] === $entry[1] ) { return $models; } } array_unshift($models, $entry); return $models; } add_filter('wpai_preferred_text_models', __NAMESPACE__ . '\\filter_preferred_text_models', 5);