From 684d5ca4768d84b5c42b9fcedb3fafc860b34d3d Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:11:32 -0500 Subject: [PATCH 01/15] Add Extended Providers experiment Adds additional AI provider integrations: - Cloudflare Workers AI - Cohere - DeepSeek - Fal.ai - HuggingFace - Ollama - OpenRouter - xAI (Grok) Includes provider credentials UI and metadata registry. --- docs/experiments/extended-providers.md | 41 ++ includes/Admin/Provider_Credentials_UI.php | 58 +++ includes/Admin/Provider_Metadata_Registry.php | 289 ++++++++++++++ includes/Experiment_Loader.php | 1 + .../Extended_Providers/Extended_Providers.php | 368 ++++++++++++++++++ ...udflareWorkersAiModelMetadataDirectory.php | 75 ++++ .../CloudflareWorkersAiProvider.php | 128 ++++++ ...CloudflareWorkersAiTextGenerationModel.php | 207 ++++++++++ .../Cohere/CohereModelMetadataDirectory.php | 109 ++++++ includes/Providers/Cohere/CohereProvider.php | 84 ++++ .../Cohere/CohereTextGenerationModel.php | 334 ++++++++++++++++ .../DeepSeekModelMetadataDirectory.php | 83 ++++ .../Providers/DeepSeek/DeepSeekProvider.php | 84 ++++ .../DeepSeek/DeepSeekTextGenerationModel.php | 33 ++ .../FalAi/FalAiImageGenerationModel.php | 211 ++++++++++ .../FalAi/FalAiModelMetadataDirectory.php | 114 ++++++ includes/Providers/FalAi/FalAiProvider.php | 97 +++++ .../Grok/GrokModelMetadataDirectory.php | 217 +++++++++++ includes/Providers/Grok/GrokProvider.php | 84 ++++ .../Grok/GrokTextGenerationModel.php | 33 ++ .../Groq/GroqModelMetadataDirectory.php | 113 ++++++ includes/Providers/Groq/GroqProvider.php | 84 ++++ .../Groq/GroqTextGenerationModel.php | 33 ++ .../HuggingFaceModelMetadataDirectory.php | 93 +++++ .../HuggingFace/HuggingFaceProvider.php | 84 ++++ .../HuggingFaceTextGenerationModel.php | 33 ++ .../Ollama/OllamaModelMetadataDirectory.php | 88 +++++ includes/Providers/Ollama/OllamaProvider.php | 100 +++++ .../Ollama/OllamaTextGenerationModel.php | 186 +++++++++ .../OpenRouterModelMetadataDirectory.php | 91 +++++ .../OpenRouter/OpenRouterProvider.php | 84 ++++ .../OpenRouterTextGenerationModel.php | 40 ++ src/admin/_common.scss | 150 +++++++ .../components/ProviderTooltipContent.tsx | 81 ++++ src/admin/components/icons/AiIcon.tsx | 26 ++ src/admin/components/icons/AnthropicIcon.tsx | 22 ++ src/admin/components/icons/CloudflareIcon.tsx | 29 ++ src/admin/components/icons/DeepSeekIcon.tsx | 22 ++ src/admin/components/icons/FalIcon.tsx | 26 ++ src/admin/components/icons/GoogleIcon.tsx | 22 ++ src/admin/components/icons/GrokIcon.tsx | 22 ++ src/admin/components/icons/GroqIcon.tsx | 22 ++ .../components/icons/HuggingFaceIcon.tsx | 45 +++ src/admin/components/icons/McpIcon.tsx | 25 ++ src/admin/components/icons/OllamaIcon.tsx | 25 ++ src/admin/components/icons/OpenAiIcon.tsx | 22 ++ src/admin/components/icons/OpenRouterIcon.tsx | 25 ++ src/admin/components/icons/XaiIcon.tsx | 22 ++ src/admin/components/icons/index.ts | 14 + src/admin/components/provider-icons.tsx | 56 +++ src/admin/provider-credentials/index.tsx | 254 ++++++++++++ src/admin/provider-credentials/style.scss | 186 +++++++++ webpack.config.js | 5 + 53 files changed, 4780 insertions(+) create mode 100644 docs/experiments/extended-providers.md create mode 100644 includes/Admin/Provider_Credentials_UI.php create mode 100644 includes/Admin/Provider_Metadata_Registry.php create mode 100644 includes/Experiments/Extended_Providers/Extended_Providers.php create mode 100644 includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php create mode 100644 includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php create mode 100644 includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php create mode 100644 includes/Providers/Cohere/CohereModelMetadataDirectory.php create mode 100644 includes/Providers/Cohere/CohereProvider.php create mode 100644 includes/Providers/Cohere/CohereTextGenerationModel.php create mode 100644 includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php create mode 100644 includes/Providers/DeepSeek/DeepSeekProvider.php create mode 100644 includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php create mode 100644 includes/Providers/FalAi/FalAiImageGenerationModel.php create mode 100644 includes/Providers/FalAi/FalAiModelMetadataDirectory.php create mode 100644 includes/Providers/FalAi/FalAiProvider.php create mode 100644 includes/Providers/Grok/GrokModelMetadataDirectory.php create mode 100644 includes/Providers/Grok/GrokProvider.php create mode 100644 includes/Providers/Grok/GrokTextGenerationModel.php create mode 100644 includes/Providers/Groq/GroqModelMetadataDirectory.php create mode 100644 includes/Providers/Groq/GroqProvider.php create mode 100644 includes/Providers/Groq/GroqTextGenerationModel.php create mode 100644 includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php create mode 100644 includes/Providers/HuggingFace/HuggingFaceProvider.php create mode 100644 includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php create mode 100644 includes/Providers/Ollama/OllamaModelMetadataDirectory.php create mode 100644 includes/Providers/Ollama/OllamaProvider.php create mode 100644 includes/Providers/Ollama/OllamaTextGenerationModel.php create mode 100644 includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php create mode 100644 includes/Providers/OpenRouter/OpenRouterProvider.php create mode 100644 includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php create mode 100644 src/admin/_common.scss create mode 100644 src/admin/components/ProviderTooltipContent.tsx create mode 100644 src/admin/components/icons/AiIcon.tsx create mode 100644 src/admin/components/icons/AnthropicIcon.tsx create mode 100644 src/admin/components/icons/CloudflareIcon.tsx create mode 100644 src/admin/components/icons/DeepSeekIcon.tsx create mode 100644 src/admin/components/icons/FalIcon.tsx create mode 100644 src/admin/components/icons/GoogleIcon.tsx create mode 100644 src/admin/components/icons/GrokIcon.tsx create mode 100644 src/admin/components/icons/GroqIcon.tsx create mode 100644 src/admin/components/icons/HuggingFaceIcon.tsx create mode 100644 src/admin/components/icons/McpIcon.tsx create mode 100644 src/admin/components/icons/OllamaIcon.tsx create mode 100644 src/admin/components/icons/OpenAiIcon.tsx create mode 100644 src/admin/components/icons/OpenRouterIcon.tsx create mode 100644 src/admin/components/icons/XaiIcon.tsx create mode 100644 src/admin/components/icons/index.ts create mode 100644 src/admin/components/provider-icons.tsx create mode 100644 src/admin/provider-credentials/index.tsx create mode 100644 src/admin/provider-credentials/style.scss diff --git a/docs/experiments/extended-providers.md b/docs/experiments/extended-providers.md new file mode 100644 index 00000000..720fe479 --- /dev/null +++ b/docs/experiments/extended-providers.md @@ -0,0 +1,41 @@ +# Extended Providers + +## Summary +Toggles registration of a custom set of AI providers with the WP AI Client. When the experiment is enabled, any provider classes you supply via filters are registered with `AiClient::defaultRegistry()` so they can participate in model discovery alongside the core providers (OpenAI, Anthropic, Google). Disable the experiment to remove those providers without touching the default stack. + +### Included Providers +- Grok (xAI) – exposes Grok’s `/v1/models` listing and chat completion models. Add your Grok API key under **Settings → AI Credentials** (`options-general.php?page=wp-ai-client`) and the registry will inject it automatically. +- Groq – exposes Groq’s `https://api.groq.com/openai/v1` chat-completions interface. Store a **Groq API key** on the credentials screen and toggle the provider inside the Extended Providers experiment. +- Fal.ai – adds curated FLUX/SDXL image generators via `https://fal.run/{model}`. Provide your Fal.ai API token on the AI Credentials page and enable the provider to unlock Fal’s image-only models. +- Cohere – connects directly to Cohere’s `/chat` and `/models` APIs at `https://api.cohere.ai/v1`. Paste your Cohere API key on the credentials screen and use the experiment settings to toggle Cohere’s chat models. +- Hugging Face – targets the OpenAI-compatible router at `https://router.huggingface.co/v1`. Add a Hugging Face access token (with `inference:all` scope) on the credentials page and enable the provider to discover router-backed chat models. +- OpenRouter – connects to `https://openrouter.ai/api/v1`, honoring their `/models` and `/chat/completions` API. Supply your OpenRouter API key under AI Credentials and optionally set Referer/Title via the registry’s custom options filter if needed. +- Ollama – calls your local `http://localhost:11434/api` daemon for chat generation. No cloud credentials required; just install/serve models via Ollama and enable the provider to expose them in the registry. Use the `ai_ollama_base_url` filter if you need a custom host. +- DeepSeek – uses the `https://api.deepseek.com/v1` OpenAI-compatible surface. Create a DeepSeek API key, paste it on the AI Credentials page, and the models listed under `/v1/models` will automatically flow into discovery. +- Cloudflare Workers AI – calls `https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/*` for model listing and inferencing. Generate a Workers AI API token plus note your Account ID (expose it via the `CLOUDFLARE_ACCOUNT_ID` environment variable or the `ai_cloudflare_account_id` filter) and provide the token through the AI Credentials screen. + +## Key Hooks & Entry Points +- `WordPress\AI\Experiments\Extended_Providers\Extended_Providers::register()` attaches to `init` (priority 20) and calls `register_providers()` only when the experiment is enabled. +- `ai_extended_provider_default_classes` – Filter the default list of provider class names bundled with the experiment (defaults to `WordPress\AI\Providers\Grok\GrokProvider`). +- `ai_extended_provider_classes` – Final filter to adjust the provider class list before registration. Receives the experiment instance so you can inspect settings if needed. + +```php +add_filter( 'ai_extended_provider_classes', function( $providers ) { + $providers[] = \MyPlugin\Providers\OpenRouterProvider::class; + $providers[] = \MyPlugin\Providers\TogetherAiProvider::class; + return $providers; +} ); +``` + +## Assets & Data Flow +No scripts or abilities are enqueued. The experiment simply calls `AiClient::defaultRegistry()->registerProvider()` for each class in the filtered list. Provider classes remain responsible for their own HTTP transport and credential handling (the WP AI Client will inject the WordPress HTTP transporter and default API-key authentication automatically). + +## Testing +1. Enable Experiments globally and toggle **Extended Providers** under `Settings → AI Experiments`. +2. Add your provider classes via the `ai_extended_provider_classes` filter. +3. Visit any screen that uses the AI Client and confirm the new provider appears in `AiClient::defaultRegistry()->getRegisteredProviderIds()`. +4. Disable the experiment and confirm the provider list reverts to the core set (OpenAI, Anthropic, Google). + +## Notes +- Only classes implementing `WordPress\AiClient\Providers\Contracts\ProviderInterface` are accepted. Missing or invalid classes trigger `_doing_it_wrong()` notices. +- The experiment does not ship provider implementations; it is simply a safe switch for loading your own provider packages or forks. diff --git a/includes/Admin/Provider_Credentials_UI.php b/includes/Admin/Provider_Credentials_UI.php new file mode 100644 index 00000000..98d3070a --- /dev/null +++ b/includes/Admin/Provider_Credentials_UI.php @@ -0,0 +1,58 @@ + Provider_Metadata_Registry::get_metadata(), + 'cloudflareAccountId' => (string) get_option( 'ai_cloudflare_account_id', '' ), + ) + ); + } +} diff --git a/includes/Admin/Provider_Metadata_Registry.php b/includes/Admin/Provider_Metadata_Registry.php new file mode 100644 index 00000000..68bdd324 --- /dev/null +++ b/includes/Admin/Provider_Metadata_Registry.php @@ -0,0 +1,289 @@ +> + */ + public static function get_metadata(): array { + $registry = AiClient::defaultRegistry(); + $providers = array(); + $overrides = self::get_branding_overrides(); + $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + + foreach ( $registry->getRegisteredProviderIds() as $provider_id ) { + $class_name = $registry->getProviderClassName( $provider_id ); + + if ( ! method_exists( $class_name, 'metadata' ) ) { + continue; + } + + /** @var ProviderMetadata $metadata */ + $metadata = $class_name::metadata(); + $brand = $overrides[ $metadata->getId() ] ?? array(); + + $providers[ $metadata->getId() ] = array( + 'id' => $metadata->getId(), + 'name' => $metadata->getName(), + 'type' => $metadata->getType()->value, + 'icon' => $brand['icon'] ?? $metadata->getId(), + 'initials' => $brand['initials'] ?? self::get_initials( $metadata->getName() ), + 'color' => $brand['color'] ?? '#1d2327', + 'url' => $brand['url'] ?? '', + 'tooltip' => $brand['tooltip'] ?? '', + 'keepDescription' => ! empty( $brand['keepDescription'] ), + 'isConfigured' => self::has_credentials( $metadata->getId(), $credentials ), + 'models' => self::get_models_for_provider( $class_name, $metadata->getId(), $credentials ), + ); + } + + return $providers; + } + + /** + * Builds a fallback initials string for providers without a brand override. + * + * @param string $name Provider display name. + * @return string + */ + private static function get_initials( string $name ): string { + $parts = preg_split( '/\s+/', trim( $name ) ); + if ( empty( $parts ) ) { + return strtoupper( substr( $name, 0, 2 ) ); + } + + $initials = ''; + foreach ( $parts as $part ) { + $initials .= strtoupper( substr( $part, 0, 1 ) ); + if ( strlen( $initials ) >= 2 ) { + break; + } + } + + return substr( $initials, 0, 2 ); + } + + /** + * Retrieves model metadata for a provider. + * + * @param string $provider_class Provider class name. + * @return array> + */ + private static function get_models_for_provider( string $provider_class, string $provider_id, array $credentials ): array { + if ( ! method_exists( $provider_class, 'modelMetadataDirectory' ) ) { + return array(); + } + + $cache_key = self::get_models_cache_key( $provider_id, $credentials[ $provider_id ] ?? '' ); + if ( $cache_key ) { + $cached = get_transient( $cache_key ); + if ( false !== $cached ) { + return $cached; + } + } + + try { + $directory = $provider_class::modelMetadataDirectory(); + $metadata = $directory->listModelMetadata(); + } catch ( \Throwable $error ) { + return array(); + } + + $models = array(); + + foreach ( $metadata as $model_metadata ) { + if ( ! $model_metadata instanceof ModelMetadata ) { + continue; + } + + $models[] = array( + 'id' => $model_metadata->getId(), + 'name' => $model_metadata->getName(), + 'capabilities' => array_map( + static function ( CapabilityEnum $capability ): string { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ), + ); + } + + if ( $cache_key ) { + set_transient( $cache_key, $models, self::MODEL_CACHE_TTL ); + } + + return $models; + } + + /** + * Determines whether stored credentials exist for a provider. + * + * @param string $provider_id Provider identifier. + * @param array $credentials Raw credentials map. + * @return bool + */ + private static function has_credentials( string $provider_id, array $credentials ): bool { + if ( 'ollama' === $provider_id ) { + return true; + } + + if ( ! isset( $credentials[ $provider_id ] ) ) { + return false; + } + + $value = $credentials[ $provider_id ]; + if ( is_array( $value ) ) { + $value = wp_json_encode( $value ); + } + + return is_string( $value ) && '' !== trim( $value ); + } + + /** + * Builds a cache key for provider models. + * + * @param string $provider_id Provider identifier. + * @param string|array $credential Credential value. + * @return string|null + */ + private static function get_models_cache_key( string $provider_id, $credential ): ?string { + if ( '' === $provider_id ) { + return null; + } + + if ( is_array( $credential ) ) { + $credential = wp_json_encode( $credential ); + } + + return 'ai_provider_models_' . md5( $provider_id . '|' . (string) $credential ); + } + + /** + * Defines manual branding overrides per provider ID. + * + * @return array> + */ + private static function get_branding_overrides(): array { + $link_template = esc_html__( 'Create and manage your %s API keys in these account settings.', 'ai' ); + + return array( + 'anthropic' => array( + 'icon' => 'anthropic', + 'initials' => 'An', + 'color' => '#111111', + 'url' => 'https://console.anthropic.com/settings/keys', + 'tooltip' => sprintf( $link_template, 'Anthropic' ), + ), + 'cohere' => array( + 'color' => '#6f2cff', + 'url' => 'https://dashboard.cohere.com/api-keys', + 'tooltip' => sprintf( $link_template, 'Cohere' ), + ), + 'cloudflare' => array( + 'icon' => 'cloudflare', + 'color' => '#f3801a', + 'url' => 'https://dash.cloudflare.com/profile/api-tokens', + 'tooltip' => sprintf( $link_template, 'Cloudflare Workers AI' ), + ), + 'deepseek' => array( + 'icon' => 'deepseek', + 'color' => '#0f172a', + 'url' => 'https://platform.deepseek.com/api_keys', + 'tooltip' => sprintf( $link_template, 'DeepSeek' ), + ), + 'fal' => array( + 'icon' => 'fal', + 'color' => '#0ea5e9', + 'url' => 'https://fal.ai/dashboard/keys', + 'tooltip' => sprintf( $link_template, 'Fal.ai' ), + ), + 'fal-ai' => array( + 'icon' => 'fal-ai', + 'color' => '#0ea5e9', + 'url' => 'https://fal.ai/dashboard/keys', + 'tooltip' => sprintf( $link_template, 'Fal.ai' ), + ), + 'grok' => array( + 'icon' => 'grok', + 'color' => '#ff6f00', + 'url' => 'https://console.x.ai/api-keys', + 'tooltip' => sprintf( $link_template, 'Grok' ), + ), + 'groq' => array( + 'icon' => 'groq', + 'color' => '#f43f5e', + 'url' => 'https://console.groq.com/keys', + 'tooltip' => sprintf( $link_template, 'Groq' ), + ), + 'google' => array( + 'icon' => 'google', + 'color' => '#4285f4', + 'url' => 'https://aistudio.google.com/app/api-keys', + 'tooltip' => sprintf( $link_template, 'Google' ), + ), + 'huggingface' => array( + 'icon' => 'huggingface', + 'color' => '#ffbe3c', + 'url' => 'https://huggingface.co/settings/tokens', + 'tooltip' => sprintf( $link_template, 'Hugging Face' ), + ), + 'openai' => array( + 'icon' => 'openai', + 'color' => '#10a37f', + 'url' => 'https://platform.openai.com/api-keys', + 'tooltip' => sprintf( $link_template, 'OpenAI' ), + ), + 'openrouter' => array( + 'icon' => 'openrouter', + 'color' => '#0f172a', + 'url' => 'https://openrouter.ai/settings/keys', + 'tooltip' => sprintf( $link_template, 'OpenRouter' ), + ), + 'ollama' => array( + 'icon' => 'ollama', + 'color' => '#111111', + 'tooltip' => esc_html__( 'Local Ollama instances at http://localhost:11434 do not require an API key. If you are calling https://ollama.com/api, create a key from your ollama.com account (for example via the dashboard or the `ollama signin` command) and paste it here.', 'ai' ), + 'keepDescription' => true, + ), + 'xai' => array( + 'icon' => 'xai', + 'color' => '#000000', + 'url' => 'https://console.x.ai/api-keys', + 'tooltip' => sprintf( $link_template, 'xAI' ), + ), + ); + } +} diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index c7223b27..6d5069f8 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -107,6 +107,7 @@ private function get_default_experiments(): array { \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, + \WordPress\AI\Experiments\Extended_Providers\Extended_Providers::class, ); /** diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php new file mode 100644 index 00000000..7cd70988 --- /dev/null +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -0,0 +1,368 @@ + 'extended-providers', + 'label' => __( 'Extended Providers', 'ai' ), + 'description' => __( 'Registers additional AI providers for experimentation without affecting the core set.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + add_action( 'init', array( $this, 'register_providers' ), 20 ); + } + + /** + * Registers any provider classes supplied via filters. + */ + public function register_providers(): void { + if ( ! $this->is_enabled() ) { + return; + } + + if ( ! class_exists( AiClient::class ) ) { + return; + } + + $provider_classes = $this->filter_enabled_provider_classes( + $this->get_provider_classes() + ); + + if ( empty( $provider_classes ) ) { + return; + } + + $registry = AiClient::defaultRegistry(); + + foreach ( $provider_classes as $class_name ) { + if ( ! is_string( $class_name ) || '' === $class_name ) { + continue; + } + + if ( ! class_exists( $class_name ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: provider class name. */ + __( 'Extended Providers experiment could not load "%s". Make sure the class is autoloadable.', 'ai' ), + esc_html( $class_name ) + ), + '0.1.0' + ); + continue; + } + + if ( $registry->hasProvider( $class_name ) ) { + continue; + } + + try { + $registry->registerProvider( $class_name ); + } catch ( \Throwable $t ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: provider class, 2: error message. */ + __( 'Failed to register provider "%1$s": %2$s', 'ai' ), + esc_html( $class_name ), + esc_html( $t->getMessage() ) + ), + '0.1.0' + ); + } + } + } + + /** + * {@inheritDoc} + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_provider_selection_option_name(), + array( + 'type' => 'array', + 'default' => array(), + 'sanitize_callback' => array( $this, 'sanitize_provider_selection' ), + ) + ); + } + + /** + * {@inheritDoc} + */ + public function render_settings_fields(): void { + $provider_classes = $this->get_provider_classes(); + + if ( empty( $provider_classes ) ) { + echo '

' . esc_html__( 'No providers are currently registered for this experiment.', 'ai' ) . '

'; + return; + } + + $selection = $this->get_provider_selection(); + $option_name = $this->get_provider_selection_option_name(); + ?> +
+

+ +

+ + is_provider_selected( $class_name, $selection ); + ?> +
+ " value="0" /> + " + value="1" + + /> + +
+ +
+ __( 'Credentials', 'ai' ), + 'url' => admin_url( 'options-general.php?page=wp-ai-client' ), + 'type' => 'dashboard', + ), + ); + } + + /** + * Returns the provider class list after filters have been applied. + * + * @return array + */ + private function get_provider_classes(): array { + $defaults = apply_filters( 'ai_extended_provider_default_classes', self::DEFAULT_PROVIDER_CLASSES ); + + /** + * Filters the provider class list registered by the Extended Providers experiment. + * + * @since 0.1.0 + * + * @param array $classes Provider class names. + * @param \WordPress\AI\Abstracts\Abstract_Experiment $experiment Experiment instance. + */ + $providers = apply_filters( 'ai_extended_provider_classes', (array) $defaults, $this ); + + return array_values( + array_filter( + array_map( + static function ( $class ) { + return is_string( $class ) ? trim( $class ) : ''; + }, + (array) $providers + ) + ) + ); + } + + /** + * Filters provider classes based on the admin selection. + * + * @param array $provider_classes Provider classes. + * + * @return array + */ + private function filter_enabled_provider_classes( array $provider_classes ): array { + $selection = $this->get_provider_selection(); + + if ( empty( $selection ) ) { + return $provider_classes; + } + + return array_values( + array_filter( + $provider_classes, + static function ( string $class ) use ( $selection ): bool { + return ! isset( $selection[ $class ] ) || true === $selection[ $class ]; + } + ) + ); + } + + /** + * Gets the stored provider selection map. + * + * @return array + */ + private function get_provider_selection(): array { + $selection = get_option( $this->get_provider_selection_option_name(), array() ); + + if ( ! is_array( $selection ) ) { + return array(); + } + + $sanitized = array(); + foreach ( $selection as $class => $enabled ) { + if ( ! is_string( $class ) || '' === $class ) { + continue; + } + + $sanitized[ $class ] = (bool) $enabled; + } + + return $sanitized; + } + + /** + * Determines if a provider should be registered. + * + * @param string $class_name Provider class name. + * @param array $selection Selection map. + * + * @return bool + */ + private function is_provider_selected( string $class_name, array $selection ): bool { + if ( empty( $selection ) ) { + return true; + } + + return $selection[ $class_name ] ?? true; + } + + /** + * Returns the option name that stores provider selection. + */ + private function get_provider_selection_option_name(): string { + return $this->get_field_option_name( self::FIELD_PROVIDERS ); + } + + /** + * Sanitizes the provider selection payload from the settings form. + * + * @param mixed $value Submitted value. + * + * @return array + */ + public function sanitize_provider_selection( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $sanitized = array(); + + foreach ( $value as $class => $enabled ) { + if ( ! is_string( $class ) || '' === $class ) { + continue; + } + + $sanitized[ $class ] = rest_sanitize_boolean( $enabled ); + } + + return $sanitized; + } + + /** + * Returns a human-friendly label for a provider class. + * + * @param string $class_name Provider class name. + * + * @return string + */ + private function get_provider_label( string $class_name ): string { + if ( class_exists( $class_name ) && method_exists( $class_name, 'metadata' ) ) { + try { + /** @var \WordPress\AiClient\Providers\DTO\ProviderMetadata $metadata */ + $metadata = $class_name::metadata(); + return $metadata->getName(); + } catch ( \Throwable $t ) { + // Fallback below. + } + } + + return $class_name; + } +} diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php b/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php new file mode 100644 index 00000000..8b2ba030 --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php @@ -0,0 +1,75 @@ + + */ + private $catalogue = array( + array( + 'id' => '@cf/meta/llama-3-8b-instruct', + 'name' => 'Meta Llama 3 8B (Cloudflare)', + ), + array( + 'id' => '@cf/meta/llama-3-70b-instruct', + 'name' => 'Meta Llama 3 70B (Cloudflare)', + ), + array( + 'id' => '@cf/mistral/mistral-7b-instruct-v0.2', + 'name' => 'Mistral 7B Instruct (Cloudflare)', + ), + ); + + /** + * {@inheritDoc} + */ + protected function sendListModelsRequest(): array { + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $options = array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::customOptions() ), + ); + + $map = array(); + foreach ( $this->catalogue as $model ) { + $map[ $model['id'] ] = new ModelMetadata( + $model['id'], + $model['name'], + $capabilities, + $options + ); + } + + return $map; + } +} diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php new file mode 100644 index 00000000..c379006e --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php @@ -0,0 +1,128 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new CloudflareWorkersAiTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported Cloudflare Workers AI model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'cloudflare', + 'Cloudflare Workers AI', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new CloudflareWorkersAiModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php new file mode 100644 index 00000000..db837b48 --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php @@ -0,0 +1,207 @@ +metadata()->getId() ), + array( 'Content-Type' => 'application/json' ), + $this->buildPayload( $prompt ) + ); + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $response = $this->getHttpTransporter()->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponse( $response ); + } + + /** + * {@inheritDoc} + */ + public function streamGenerateTextResult( array $prompt ): \Generator { + throw ResponseException::fromInvalidData( 'Cloudflare Workers AI', 'stream', 'Streaming is not implemented.' ); + } + + /** + * Builds the Cloudflare payload. + * + * @param list $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $messages = $this->convertPromptToMessages( $prompt ); + + if ( empty( $messages ) ) { + throw new InvalidArgumentException( + __( 'Cloudflare Workers AI chat requests require at least one user message.', 'ai' ) + ); + } + + $payload = array( + 'messages' => $messages, + 'stream' => false, + ); + + if ( null !== $config->getSystemInstruction() ) { + array_unshift( + $payload['messages'], + array( + 'role' => 'system', + 'content' => $config->getSystemInstruction(), + ) + ); + } + + if ( null !== $config->getTemperature() ) { + $payload['temperature'] = (float) $config->getTemperature(); + } + if ( null !== $config->getTopP() ) { + $payload['top_p'] = (float) $config->getTopP(); + } + if ( null !== $config->getMaxTokens() ) { + $payload['max_output_tokens'] = (int) $config->getMaxTokens(); + } + if ( $config->getStopSequences() ) { + $payload['stop_sequences'] = $config->getStopSequences(); + } + + foreach ( $config->getCustomOptions() as $key => $value ) { + if ( isset( $payload[ $key ] ) ) { + throw new InvalidArgumentException( + sprintf( + /* translators: %s: custom option key. */ + __( 'The custom option "%s" conflicts with an existing Cloudflare Workers AI parameter.', 'ai' ), + $key + ) + ); + } + + $payload[ $key ] = $value; + } + + return $payload; + } + + /** + * Converts the WP AI Client prompt into Cloudflare message objects. + * + * @param list $prompt Prompt messages. + * + * @return list + */ + private function convertPromptToMessages( array $prompt ): array { + $messages = array(); + + foreach ( $prompt as $message ) { + $text = $this->extractTextFromMessage( $message ); + if ( '' === $text ) { + continue; + } + + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + $messages[] = array( + 'role' => $role, + 'content' => $text, + ); + } + + return $messages; + } + + /** + * Extracts text from a message. + * + * @param Message $message Message instance. + * + * @return string + */ + private function extractTextFromMessage( Message $message ): string { + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + return $part->getText(); + } + } + + return ''; + } + + /** + * Parses the Workers AI response to a WP AI result. + * + * @param Response $response HTTP response. + * + * @return GenerativeAiResult + */ + private function parseResponse( Response $response ): GenerativeAiResult { + $data = $response->getData(); + if ( ! isset( $data['result']['response'] ) || ! is_string( $data['result']['response'] ) ) { + throw ResponseException::fromMissingData( 'Cloudflare Workers AI', 'result.response' ); + } + + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $data['result']['response'] ) ) + ); + + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + $prompt_tokens = (int) ( $data['result']['input_tokens'] ?? 0 ); + $output_tokens = (int) ( $data['result']['output_tokens'] ?? 0 ); + + return new GenerativeAiResult( + $data['result']['id'] ?? '', + array( $candidate ), + new TokenUsage( $prompt_tokens, $output_tokens, $prompt_tokens + $output_tokens ), + $this->providerMetadata(), + $this->metadata(), + $data + ); + } + + /** + * Ensures Workers AI returned a successful response. + * + * @param Response $response HTTP response. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } +} diff --git a/includes/Providers/Cohere/CohereModelMetadataDirectory.php b/includes/Providers/Cohere/CohereModelMetadataDirectory.php new file mode 100644 index 00000000..86ea5f60 --- /dev/null +++ b/includes/Providers/Cohere/CohereModelMetadataDirectory.php @@ -0,0 +1,109 @@ +getRequestAuthentication()->authenticateRequest( $request ); + $response = $this->getHttpTransporter()->send( $request ); + ResponseUtil::throwIfNotSuccessful( $response ); + + return $this->parseResponseToModelMetadataMap( $response ); + } + + /** + * Parses Cohere's `/models` response. + * + * @param Response $response Cohere response. + * + * @return array + */ + private function parseResponseToModelMetadataMap( Response $response ): array { + $data = $response->getData(); + if ( ! isset( $data['models'] ) || ! is_array( $data['models'] ) ) { + throw ResponseException::fromMissingData( 'Cohere', 'models' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + $options = $this->getTextOptions(); + + $metadata = array(); + foreach ( $data['models'] as $model ) { + if ( ! is_array( $model ) || empty( $model['name'] ) ) { + continue; + } + + $endpoints = $model['endpoints'] ?? array(); + if ( ! is_array( $endpoints ) || ! in_array( 'chat', $endpoints, true ) ) { + continue; + } + + $model_id = (string) $model['name']; + $model_name = isset( $model['display_name'] ) && is_string( $model['display_name'] ) + ? $model['display_name'] + : $model_id; + + $metadata[ $model_id ] = new ModelMetadata( + $model_id, + $model_name, + $capabilities, + $options + ); + } + + return $metadata; + } + + /** + * Returns baseline Cohere chat options. + * + * @return array + */ + private function getTextOptions(): array { + return array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::topK() ), + new SupportedOption( OptionEnum::presencePenalty() ), + new SupportedOption( OptionEnum::frequencyPenalty() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::customOptions() ), + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), + ); + } +} diff --git a/includes/Providers/Cohere/CohereProvider.php b/includes/Providers/Cohere/CohereProvider.php new file mode 100644 index 00000000..5d71b55e --- /dev/null +++ b/includes/Providers/Cohere/CohereProvider.php @@ -0,0 +1,84 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new CohereTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported Cohere model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'cohere', + 'Cohere', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new CohereModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Cohere/CohereTextGenerationModel.php b/includes/Providers/Cohere/CohereTextGenerationModel.php new file mode 100644 index 00000000..67ac86a9 --- /dev/null +++ b/includes/Providers/Cohere/CohereTextGenerationModel.php @@ -0,0 +1,334 @@ +buildPayload( $prompt ); + + $request = new Request( + HttpMethodEnum::POST(), + CohereProvider::url( 'chat' ), + array( 'Content-Type' => 'application/json' ), + $payload + ); + + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $httpTransport = $this->getHttpTransporter(); + $response = $httpTransport->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponseToResult( $response ); + } + + /** + * {@inheritDoc} + */ + public function streamGenerateTextResult( array $prompt ): \Generator { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'stream', + __( 'Streaming is not yet implemented for the Cohere provider.', 'ai' ) + ); + } + + /** + * Builds the Cohere `/chat` payload. + * + * @param list $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $messages = $this->convertPromptToMessages( $prompt ); + $system_text = $config->getSystemInstruction(); + + if ( empty( $messages ) ) { + throw new InvalidArgumentException( + __( 'Cohere chat requests require at least one user message.', 'ai' ) + ); + } + + $current_message = $this->extractLatestUserMessage( $messages ); + $chat_history = $this->convertMessagesToChatHistory( $messages ); + + $payload = array( + 'model' => $this->metadata()->getId(), + 'message' => $current_message, + ); + + if ( $system_text ) { + $payload['preamble'] = $system_text; + } + + if ( ! empty( $chat_history ) ) { + $payload['chat_history'] = $chat_history; + } + + if ( null !== $config->getCandidateCount() ) { + $payload['response_count'] = (int) $config->getCandidateCount(); + } + if ( null !== $config->getMaxTokens() ) { + $payload['max_tokens'] = (int) $config->getMaxTokens(); + } + if ( null !== $config->getTemperature() ) { + $payload['temperature'] = (float) $config->getTemperature(); + } + if ( null !== $config->getTopP() ) { + $payload['top_p'] = (float) $config->getTopP(); + } + if ( null !== $config->getTopK() ) { + $payload['top_k'] = (int) $config->getTopK(); + } + if ( $config->getStopSequences() ) { + $payload['stop_sequences'] = $config->getStopSequences(); + } + + foreach ( $config->getCustomOptions() as $key => $value ) { + if ( isset( $payload[ $key ] ) ) { + throw new InvalidArgumentException( + sprintf( + /* translators: %s: custom option key. */ + __( 'The custom option "%s" conflicts with an existing Cohere parameter.', 'ai' ), + $key + ) + ); + } + $payload[ $key ] = $value; + } + + return $payload; + } + + /** + * Converts the WP AI Client prompt into Cohere's messages array. + * + * @param list $prompt Prompt messages. + * + * @return list + */ + private function convertPromptToMessages( array $prompt ): array { + $messages = array(); + + foreach ( $prompt as $message ) { + $text = $this->extractTextFromMessage( $message ); + if ( '' === $text ) { + continue; + } + + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + + $messages[] = array( + 'role' => $role, + 'content' => $text, + ); + } + + return $messages; + } + + /** + * Extracts the first text fragment from a message. + * + * @param Message $message Prompt message. + * + * @return string + */ + private function extractTextFromMessage( Message $message ): string { + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + return $part->getText(); + } + } + + return ''; + } + + /** + * Converts Cohere API responses to standard results. + * + * @param Response $response Cohere response. + * + * @return GenerativeAiResult + */ + private function parseResponseToResult( Response $response ): GenerativeAiResult { + $data = $response->getData(); + + $text_candidates = $this->extractTextCandidates( $data ); + if ( empty( $text_candidates ) ) { + throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), 'text' ); + } + + $candidates = array_map( + static function ( string $text ): Candidate { + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $text ) ) + ); + return new Candidate( $message, FinishReasonEnum::stop() ); + }, + $text_candidates + ); + + $usage = $data['meta']['billed_units'] ?? array(); + $input_tokens = (int) ( $usage['input_tokens'] ?? 0 ); + $output_tokens = (int) ( $usage['output_tokens'] ?? 0 ); + $token_usage = new TokenUsage( + $input_tokens, + $output_tokens, + $input_tokens + $output_tokens + ); + + $additional = $data; + unset( $additional['text'], $additional['response'], $additional['generations'] ); + + return new GenerativeAiResult( + $data['generation_id'] ?? ( $data['id'] ?? '' ), + $candidates, + $token_usage, + $this->providerMetadata(), + $this->metadata(), + $additional + ); + } + + /** + * Normalizes Cohere text containers into strings. + * + * @param array $data Cohere response data. + * + * @return list + */ + private function extractTextCandidates( array $data ): array { + $candidates = array(); + + if ( isset( $data['message'] ) && is_array( $data['message'] ) ) { + $content = $data['message']['content'] ?? array(); + if ( is_array( $content ) ) { + foreach ( $content as $block ) { + if ( isset( $block['text'] ) && is_string( $block['text'] ) ) { + $candidates[] = $block['text']; + } + } + } + } + + if ( isset( $data['text'] ) && is_string( $data['text'] ) ) { + $candidates[] = $data['text']; + } + + if ( isset( $data['response'] ) && is_array( $data['response'] ) ) { + foreach ( $data['response'] as $entry ) { + if ( isset( $entry['message'] ) && is_string( $entry['message'] ) ) { + $candidates[] = $entry['message']; + } + } + } + + if ( isset( $data['generations'] ) && is_array( $data['generations'] ) ) { + foreach ( $data['generations'] as $generation ) { + if ( isset( $generation['text'] ) && is_string( $generation['text'] ) ) { + $candidates[] = $generation['text']; + } + } + } + + return $candidates; + } + + /** + * Ensures Cohere returned a successful response. + * + * @param Response $response Cohere response. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } + + /** + * Extracts the most recent user utterance for Cohere's `message` field. + * + * @param array $messages Normalized message list. + * + * @return string + */ + private function extractLatestUserMessage( array &$messages ): string { + for ( $index = count( $messages ) - 1; $index >= 0; $index-- ) { + if ( 'user' !== $messages[ $index ]['role'] ) { + continue; + } + + $content = $messages[ $index ]['content']; + unset( $messages[ $index ] ); + + return $content; + } + + throw new InvalidArgumentException( + __( 'Cohere chat requests require at least one user message.', 'ai' ) + ); + } + + /** + * Converts remaining messages into Cohere `chat_history` entries. + * + * @param array $messages Normalized message list. + * + * @return array + */ + private function convertMessagesToChatHistory( array $messages ): array { + $history = array(); + + foreach ( array_values( $messages ) as $message ) { + if ( 'system' === $message['role'] ) { + continue; + } + + $role = 'user' === $message['role'] ? 'USER' : 'CHATBOT'; + + $history[] = array( + 'role' => $role, + 'message' => $message['content'], + ); + } + + return $history; + } +} diff --git a/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php b/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php new file mode 100644 index 00000000..ff011245 --- /dev/null +++ b/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php @@ -0,0 +1,83 @@ +getData(); + if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { + throw ResponseException::fromMissingData( 'DeepSeek', 'data' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $options = array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::presencePenalty() ), + new SupportedOption( OptionEnum::frequencyPenalty() ), + new SupportedOption( OptionEnum::functionDeclarations() ), + new SupportedOption( OptionEnum::customOptions() ), + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), + ); + + return array_map( + static function ( array $model ) use ( $capabilities, $options ): ModelMetadata { + $model_id = (string) $model['id']; + return new ModelMetadata( + $model_id, + $model['id'], + $capabilities, + $options + ); + }, + $data['data'] + ); + } +} diff --git a/includes/Providers/DeepSeek/DeepSeekProvider.php b/includes/Providers/DeepSeek/DeepSeekProvider.php new file mode 100644 index 00000000..29a847bc --- /dev/null +++ b/includes/Providers/DeepSeek/DeepSeekProvider.php @@ -0,0 +1,84 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new DeepSeekTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported DeepSeek model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'deepseek', + 'DeepSeek', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new DeepSeekModelMetadataDirectory(); + } +} diff --git a/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php b/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php new file mode 100644 index 00000000..240bef66 --- /dev/null +++ b/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php @@ -0,0 +1,33 @@ +getHttpTransporter(); + $request = $this->createRequest( + HttpMethodEnum::POST(), + $this->metadata()->getId(), + array( 'Content-Type' => 'application/json' ), + $this->buildPayload( $prompt ) + ); + + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $request = $this->ensureFalAuthorizationHeader( $request ); + $response = $http_transporter->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponseToResult( $response ); + } + + /** + * Builds the HTTP request for the synchronous `fal.run` endpoint. + * + * @param HttpMethodEnum $method HTTP method. + * @param string $model_path Model identifier. + * @param array> $headers Headers. + * @param array|null $data Payload. + * + * @return Request + */ + protected function createRequest( + HttpMethodEnum $method, + string $model_path, + array $headers = array(), + ?array $data = null + ): Request { + return new Request( + $method, + FalAiProvider::url( $model_path ), + $headers, + $data + ); + } + + /** + * Builds the Fal.ai payload from the prompt. + * + * @param list $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + return array( + 'prompt' => $this->preparePromptText( $prompt ), + ); + } + + /** + * Converts Fal.ai responses to a GenerativeAiResult. + * + * @param Response $response Fal.ai response. + * + * @return GenerativeAiResult + */ + private function parseResponseToResult( Response $response ): GenerativeAiResult { + $response_data = $response->getData(); + if ( ! isset( $response_data['images'] ) || ! is_array( $response_data['images'] ) ) { + throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), 'images' ); + } + + $candidates = array(); + foreach ( $response_data['images'] as $index => $image_data ) { + if ( ! is_array( $image_data ) || empty( $image_data['url'] ) ) { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + "images[{$index}]", + 'Each image must include a URL.' + ); + } + + $mime_type = isset( $image_data['content_type'] ) && is_string( $image_data['content_type'] ) + ? $image_data['content_type'] + : 'image/png'; + + $file = new File( (string) $image_data['url'], $mime_type ); + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $file ) ) + ); + $candidates[] = new Candidate( $message, FinishReasonEnum::stop() ); + } + + $additional = $response_data; + unset( $additional['images'] ); + + return new GenerativeAiResult( + $additional['request_id'] ?? '', + $candidates, + new TokenUsage( 0, 0, 0 ), + $this->providerMetadata(), + $this->metadata(), + $additional + ); + } + + /** + * Normalizes the prompt into a single user string. + * + * @param list $messages Prompt messages. + * + * @return string + */ + private function preparePromptText( array $messages ): string { + if ( count( $messages ) !== 1 ) { + throw new InvalidArgumentException( + __( 'Fal.ai models require a single user prompt.', 'ai' ) + ); + } + + $message = $messages[0]; + if ( ! $message->getRole()->isUser() ) { + throw new InvalidArgumentException( + __( 'Fal.ai image prompts must originate from the user role.', 'ai' ) + ); + } + + foreach ( $message->getParts() as $part ) { + $text = $part->getText(); + if ( is_string( $text ) && '' !== trim( $text ) ) { + return $text; + } + } + + throw new InvalidArgumentException( + __( 'Fal.ai image prompts must include text content.', 'ai' ) + ); + } + + /** + * Throws an exception if the response indicates failure. + * + * @param Response $response Fal.ai response. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } + + /** + * Converts Bearer auth headers into Fal.ai `Key` headers. + * + * @param Request $request Authenticated request. + * + * @return Request + */ + private function ensureFalAuthorizationHeader( Request $request ): Request { + $authorization = $request->getHeader( 'Authorization' ); + if ( empty( $authorization ) || ! is_string( $authorization[0] ?? null ) ) { + return $request; + } + + $value = $authorization[0]; + if ( 0 !== strpos( $value, 'Bearer ' ) ) { + return $request; + } + + $token = trim( substr( $value, 7 ) ); + if ( '' === $token ) { + return $request; + } + + return $request->withHeader( 'Authorization', 'Key ' . $token ); + } +} diff --git a/includes/Providers/FalAi/FalAiModelMetadataDirectory.php b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php new file mode 100644 index 00000000..da2b7d81 --- /dev/null +++ b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php @@ -0,0 +1,114 @@ +> + */ + private $catalogue = array( + // FLUX.2 models. + array( + 'id' => 'fal-ai/flux-2', + 'name' => 'FLUX.2 Dev', + 'mime' => 'image/jpeg', + ), + array( + 'id' => 'fal-ai/flux-2-pro', + 'name' => 'FLUX.2 Pro', + 'mime' => 'image/jpeg', + ), + array( + 'id' => 'fal-ai/flux-2-flex', + 'name' => 'FLUX.2 Flex', + 'mime' => 'image/jpeg', + ), + // FLUX.1 models. + array( + 'id' => 'fal-ai/flux/dev', + 'name' => 'FLUX.1 Dev', + 'mime' => 'image/jpeg', + ), + array( + 'id' => 'fal-ai/flux/schnell', + 'name' => 'FLUX.1 Schnell', + 'mime' => 'image/jpeg', + ), + // Other models. + array( + 'id' => 'fal-ai/fast-sdxl', + 'name' => 'Fast SDXL', + 'mime' => 'image/png', + ), + ); + + /** + * {@inheritDoc} + */ + protected function sendListModelsRequest(): array { + $capabilities = array( CapabilityEnum::imageGeneration() ); + $options = $this->get_default_options(); + $metadata_map = array(); + + foreach ( $this->catalogue as $model ) { + $metadata_map[ $model['id'] ] = new ModelMetadata( + $model['id'], + $model['name'], + $capabilities, + $this->merge_options_with_mime( $options, $model['mime'] ) + ); + } + + return $metadata_map; + } + + /** + * Returns baseline supported options. + * + * @return array + */ + private function get_default_options(): array { + return array( + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::image() ) ) ), + new SupportedOption( OptionEnum::outputFileType(), array( FileTypeEnum::remote(), FileTypeEnum::inline() ) ), + new SupportedOption( OptionEnum::customOptions() ), + ); + } + + /** + * Adds MIME-specific option metadata. + * + * @param array $options Base option list. + * @param string $mime_type MIME string. + * + * @return array + */ + private function merge_options_with_mime( array $options, string $mime_type ): array { + $mime_option = new SupportedOption( OptionEnum::outputMimeType(), array( $mime_type ) ); + + return array_merge( $options, array( $mime_option ) ); + } +} diff --git a/includes/Providers/FalAi/FalAiProvider.php b/includes/Providers/FalAi/FalAiProvider.php new file mode 100644 index 00000000..b6f6f422 --- /dev/null +++ b/includes/Providers/FalAi/FalAiProvider.php @@ -0,0 +1,97 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isImageGeneration() ) { + return new FalAiImageGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported Fal.ai model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'fal', + 'Fal.ai', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new FalAiModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Grok/GrokModelMetadataDirectory.php b/includes/Providers/Grok/GrokModelMetadataDirectory.php new file mode 100644 index 00000000..164e50e0 --- /dev/null +++ b/includes/Providers/Grok/GrokModelMetadataDirectory.php @@ -0,0 +1,217 @@ +getData(); + $models_data = array(); + + if ( isset( $response_data['data'] ) && is_array( $response_data['data'] ) ) { + $models_data = $response_data['data']; + } elseif ( isset( $response_data['models'] ) && is_array( $response_data['models'] ) ) { + $models_data = $response_data['models']; + } + + if ( empty( $models_data ) ) { + throw ResponseException::fromMissingData( 'Grok', 'data' ); + } + + $metadata = array(); + foreach ( $models_data as $model_data ) { + if ( ! is_array( $model_data ) || empty( $model_data['id'] ) ) { + continue; + } + + $model_id = (string) $model_data['id']; + $metadata[] = new ModelMetadata( + $model_id, + $this->format_model_name( $model_id ), + $this->determine_capabilities( $model_id ), + $this->determine_supported_options( $model_id ) + ); + } + + return $metadata; + } + + /** + * Returns a human friendly label for a Grok model. + * + * @param string $model_id Model identifier. + * + * @return string + */ + private function format_model_name( string $model_id ): string { + $label = str_replace( array( '-', '_' ), ' ', $model_id ); + return ucwords( $label ); + } + + /** + * Determines the supported capabilities for a given model identifier. + * + * @param string $model_id Model identifier. + * + * @return array + */ + private function determine_capabilities( string $model_id ): array { + foreach ( self::IMAGE_MODEL_KEYWORDS as $keyword ) { + if ( false !== strpos( $model_id, $keyword ) ) { + return array( CapabilityEnum::imageGeneration() ); + } + } + + return array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + } + + /** + * Determines the supported options for a given model identifier. + * + * @param string $model_id Model identifier. + * + * @return array + */ + private function determine_supported_options( string $model_id ): array { + foreach ( self::IMAGE_MODEL_KEYWORDS as $keyword ) { + if ( false !== strpos( $model_id, $keyword ) ) { + return $this->get_image_options(); + } + } + + $is_multimodal = $this->has_keyword( $model_id, self::MULTIMODAL_KEYWORDS ); + return $this->get_text_options( $is_multimodal ); + } + + /** + * Checks whether a model identifier contains any keyword. + * + * @param string $model_id Model identifier. + * @param array $keywords Keywords to scan for. + * + * @return bool + */ + private function has_keyword( string $model_id, array $keywords ): bool { + foreach ( $keywords as $keyword ) { + if ( false !== strpos( $model_id, $keyword ) ) { + return true; + } + } + return false; + } + + /** + * Returns base supported options for Grok chat models. + * + * @param bool $supports_multimodal Whether the model supports image inputs. + * + * @return array + */ + private function get_text_options( bool $supports_multimodal ): array { + $options = array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::presencePenalty() ), + new SupportedOption( OptionEnum::frequencyPenalty() ), + new SupportedOption( OptionEnum::logprobs() ), + new SupportedOption( OptionEnum::topLogprobs() ), + new SupportedOption( OptionEnum::functionDeclarations() ), + new SupportedOption( OptionEnum::outputMimeType(), array( 'text/plain', 'application/json' ) ), + new SupportedOption( OptionEnum::outputSchema() ), + new SupportedOption( OptionEnum::customOptions() ), + ); + + $input_modalities = array( + array( ModalityEnum::text() ), + ); + + if ( $supports_multimodal ) { + $input_modalities[] = array( ModalityEnum::text(), ModalityEnum::image() ); + } + + $options[] = new SupportedOption( OptionEnum::inputModalities(), $input_modalities ); + $options[] = new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ); + + return $options; + } + + /** + * Returns supported options for Grok image generators. + * + * @return array + */ + private function get_image_options(): array { + return array( + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::image() ) ) ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::outputMimeType(), array( 'image/png', 'image/jpeg', 'image/webp' ) ), + new SupportedOption( OptionEnum::outputFileType(), array( FileTypeEnum::inline() ) ), + new SupportedOption( + OptionEnum::outputMediaOrientation(), + array( + MediaOrientationEnum::square(), + MediaOrientationEnum::landscape(), + MediaOrientationEnum::portrait(), + ) + ), + new SupportedOption( OptionEnum::customOptions() ), + ); + } +} diff --git a/includes/Providers/Grok/GrokProvider.php b/includes/Providers/Grok/GrokProvider.php new file mode 100644 index 00000000..56b407cf --- /dev/null +++ b/includes/Providers/Grok/GrokProvider.php @@ -0,0 +1,84 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new GrokTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported Grok model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'grok', + 'Grok (xAI)', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new GrokModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Grok/GrokTextGenerationModel.php b/includes/Providers/Grok/GrokTextGenerationModel.php new file mode 100644 index 00000000..a2217f1e --- /dev/null +++ b/includes/Providers/Grok/GrokTextGenerationModel.php @@ -0,0 +1,33 @@ +getData(); + if ( ! isset( $response_data['data'] ) || ! is_array( $response_data['data'] ) ) { + throw ResponseException::fromMissingData( 'Groq', 'data' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + $options = $this->get_text_options(); + $models_metadata = array(); + + foreach ( $response_data['data'] as $model_data ) { + if ( ! is_array( $model_data ) || empty( $model_data['id'] ) ) { + continue; + } + + $model_id = (string) $model_data['id']; + $model_name = isset( $model_data['name'] ) && is_string( $model_data['name'] ) + ? $model_data['name'] + : $model_id; + + $models_metadata[] = new ModelMetadata( + $model_id, + $model_name, + $capabilities, + $options + ); + } + + return $models_metadata; + } + + /** + * Returns supported options for Groq chat models. + * + * @return array + */ + private function get_text_options(): array { + return array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::presencePenalty() ), + new SupportedOption( OptionEnum::frequencyPenalty() ), + new SupportedOption( OptionEnum::logprobs() ), + new SupportedOption( OptionEnum::topLogprobs() ), + new SupportedOption( OptionEnum::functionDeclarations() ), + new SupportedOption( OptionEnum::outputMimeType(), array( 'text/plain', 'application/json' ) ), + new SupportedOption( OptionEnum::outputSchema() ), + new SupportedOption( OptionEnum::customOptions() ), + new SupportedOption( + OptionEnum::inputModalities(), + array( + array( ModalityEnum::text() ), + ) + ), + new SupportedOption( + OptionEnum::outputModalities(), + array( + array( ModalityEnum::text() ), + ) + ), + ); + } +} diff --git a/includes/Providers/Groq/GroqProvider.php b/includes/Providers/Groq/GroqProvider.php new file mode 100644 index 00000000..4c13314e --- /dev/null +++ b/includes/Providers/Groq/GroqProvider.php @@ -0,0 +1,84 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new GroqTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported Groq model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'groq', + 'Groq', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new GroqModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Groq/GroqTextGenerationModel.php b/includes/Providers/Groq/GroqTextGenerationModel.php new file mode 100644 index 00000000..5b72fa9f --- /dev/null +++ b/includes/Providers/Groq/GroqTextGenerationModel.php @@ -0,0 +1,33 @@ +getData(); + if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { + throw ResponseException::fromMissingData( 'Hugging Face', 'data' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + $options = $this->getTextOptions(); + + $models = array(); + foreach ( $data['data'] as $model ) { + if ( ! is_array( $model ) || empty( $model['id'] ) ) { + continue; + } + + $models[] = new ModelMetadata( + $model['id'], + $model['id'], + $capabilities, + $options + ); + } + + return $models; + } + + /** + * Returns supported options for Hugging Face chat models. + * + * @return array + */ + private function getTextOptions(): array { + return array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::presencePenalty() ), + new SupportedOption( OptionEnum::frequencyPenalty() ), + new SupportedOption( OptionEnum::customOptions() ), + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), + ); + } +} diff --git a/includes/Providers/HuggingFace/HuggingFaceProvider.php b/includes/Providers/HuggingFace/HuggingFaceProvider.php new file mode 100644 index 00000000..cb88cb35 --- /dev/null +++ b/includes/Providers/HuggingFace/HuggingFaceProvider.php @@ -0,0 +1,84 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new HuggingFaceTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported Hugging Face model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'huggingface', + 'Hugging Face', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new HuggingFaceModelMetadataDirectory(); + } +} diff --git a/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php b/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php new file mode 100644 index 00000000..454fb8cf --- /dev/null +++ b/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php @@ -0,0 +1,33 @@ +getHttpTransporter()->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponse( $response ); + } + + /** + * Parses Ollama tags response. + * + * @param Response $response Ollama response. + * + * @return array + */ + private function parseResponse( Response $response ): array { + $data = $response->getData(); + if ( ! isset( $data['models'] ) || ! is_array( $data['models'] ) ) { + throw ResponseException::fromMissingData( 'Ollama', 'models' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $options = array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::customOptions() ), + ); + + $map = array(); + foreach ( $data['models'] as $model ) { + if ( ! isset( $model['name'] ) ) { + continue; + } + + $id = (string) $model['name']; + $name = isset( $model['details']['family'] ) ? $model['details']['family'] . ' (' . $id . ')' : $id; + + $map[ $id ] = new ModelMetadata( + $id, + $name, + $capabilities, + $options + ); + } + + return $map; + } +} diff --git a/includes/Providers/Ollama/OllamaProvider.php b/includes/Providers/Ollama/OllamaProvider.php new file mode 100644 index 00000000..dad6ae51 --- /dev/null +++ b/includes/Providers/Ollama/OllamaProvider.php @@ -0,0 +1,100 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new OllamaTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported Ollama model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'ollama', + 'Ollama', + ProviderTypeEnum::client() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new OllamaModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Ollama/OllamaTextGenerationModel.php b/includes/Providers/Ollama/OllamaTextGenerationModel.php new file mode 100644 index 00000000..8a5ed52d --- /dev/null +++ b/includes/Providers/Ollama/OllamaTextGenerationModel.php @@ -0,0 +1,186 @@ + 'application/json' ), + $this->buildPayload( $prompt ) + ); + + $response = $this->getHttpTransporter()->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponse( $response ); + } + + /** + * {@inheritDoc} + */ + public function streamGenerateTextResult( array $prompt ): \Generator { + throw ResponseException::fromInvalidData( 'Ollama', 'stream', 'Streaming not implemented.' ); + } + + /** + * Builds the request payload. + * + * @param list $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $messages = $this->convertPromptToMessages( $prompt ); + + if ( empty( $messages ) ) { + throw new InvalidArgumentException( + __( 'Ollama chat requests require at least one user message.', 'ai' ) + ); + } + + $payload = array( + 'model' => $this->metadata()->getId(), + 'messages'=> $messages, + 'stream' => false, + ); + + if ( null !== $config->getTemperature() ) { + $payload['options']['temperature'] = (float) $config->getTemperature(); + } + if ( null !== $config->getTopP() ) { + $payload['options']['top_p'] = (float) $config->getTopP(); + } + if ( null !== $config->getTopK() ) { + $payload['options']['top_k'] = (float) $config->getTopK(); + } + + foreach ( $config->getCustomOptions() as $key => $value ) { + $payload['options'][ $key ] = $value; + } + + return $payload; + } + + /** + * Converts prompt messages to Ollama format. + * + * @param list $prompt Prompt messages. + * + * @return list + */ + private function convertPromptToMessages( array $prompt ): array { + $messages = array(); + + foreach ( $prompt as $message ) { + $text = $this->extractTextFromMessage( $message ); + if ( '' === $text ) { + continue; + } + + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + $messages[] = array( + 'role' => $role, + 'content' => $text, + ); + } + + return $messages; + } + + /** + * Extracts first text part from a message. + * + * @param Message $message Message instance. + * + * @return string + */ + private function extractTextFromMessage( Message $message ): string { + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + return $part->getText(); + } + } + + return ''; + } + + /** + * Converts Ollama response to a GenerativeAiResult. + * + * @param Response $response Response instance. + * + * @return GenerativeAiResult + */ + private function parseResponse( Response $response ): GenerativeAiResult { + $data = $response->getData(); + if ( ! isset( $data['message']['content'] ) || ! is_string( $data['message']['content'] ) ) { + throw ResponseException::fromMissingData( 'Ollama', 'message.content' ); + } + + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $data['message']['content'] ) ) + ); + + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $prompt_tokens = (int) ( $data['prompt_eval_count'] ?? 0 ); + $output_tokens = (int) ( $data['eval_count'] ?? 0 ); + + return new GenerativeAiResult( + $data['id'] ?? '', + array( $candidate ), + new TokenUsage( $prompt_tokens, $output_tokens, $prompt_tokens + $output_tokens ), + $this->providerMetadata(), + $this->metadata(), + $data + ); + } + + /** + * Validates response success. + * + * @param Response $response Response instance. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } +} diff --git a/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php new file mode 100644 index 00000000..1c2602fb --- /dev/null +++ b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php @@ -0,0 +1,91 @@ +getData(); + if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { + throw ResponseException::fromMissingData( 'OpenRouter', 'data' ); + } + + $options = $this->getTextOptions(); + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $models = array(); + foreach ( $data['data'] as $model ) { + if ( ! is_array( $model ) || empty( $model['id'] ) ) { + continue; + } + + $models[] = new ModelMetadata( + $model['id'], + $model['name'] ?? $model['id'], + $capabilities, + $options + ); + } + + return $models; + } + + /** + * Returns supported options for OpenRouter chat models. + * + * @return array + */ + private function getTextOptions(): array { + return array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::presencePenalty() ), + new SupportedOption( OptionEnum::frequencyPenalty() ), + new SupportedOption( OptionEnum::functionDeclarations() ), + new SupportedOption( OptionEnum::customOptions() ), + ); + } +} diff --git a/includes/Providers/OpenRouter/OpenRouterProvider.php b/includes/Providers/OpenRouter/OpenRouterProvider.php new file mode 100644 index 00000000..7820d0c1 --- /dev/null +++ b/includes/Providers/OpenRouter/OpenRouterProvider.php @@ -0,0 +1,84 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new OpenRouterTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + throw new RuntimeException( + 'Unsupported OpenRouter model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'openrouter', + 'OpenRouter', + ProviderTypeEnum::cloud() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new OpenRouterModelMetadataDirectory(); + } +} diff --git a/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php b/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php new file mode 100644 index 00000000..4c5cabc0 --- /dev/null +++ b/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php @@ -0,0 +1,40 @@ + 'application/json', + ), + $headers + ); + + return new Request( + $method, + OpenRouterProvider::url( $path ), + $headers, + $data + ); + } +} diff --git a/src/admin/_common.scss b/src/admin/_common.scss new file mode 100644 index 00000000..1094ab4d --- /dev/null +++ b/src/admin/_common.scss @@ -0,0 +1,150 @@ +/** + * Common Admin Page Styles + * + * Shared styles for AI admin pages including page headers with icons. + * + * @package WordPress\AI + */ + +/* Remove default .wrap top margin/padding for AI pages */ +.wrap.ai-mcp-server, +.wrap.ai-request-logs, +.wrap.ai-experiments-page { + margin-top: 0; + padding-top: 0; +} + +/* Force AI admin screens to use white page backgrounds */ +$ai-admin-white-pages: ( + 'settings_page_ai-request-logs', + 'settings_page_wp-ai-client', + 'settings_page_ai-experiments', + 'toplevel_page_ai-mcp' +); + +@each $page-class in $ai-admin-white-pages { + body.#{$page-class}, + body.#{$page-class} #wpwrap, + body.#{$page-class} #wpcontent, + body.#{$page-class} #wpbody, + body.#{$page-class} #wpbody-content { + background-color: #fff; + } +} + +/* Full-width page header (privacy-style) */ +.ai-admin-header { + background: #fff; + border-bottom: 1px solid #dcdcde; + margin: 0 0 1.5rem; + padding: 16px 20px; + + // Extend to full width by pulling out of .wrap padding + .wrap > & { + margin-left: -20px; + margin-right: -20px; + + @media screen and (max-width: 782px) { + margin-left: -10px; + margin-right: -10px; + padding-left: 10px; + padding-right: 10px; + } + } +} + +/* Card border-radius standardization (4px) */ +.ai-mcp-server, +.ai-request-logs { + .components-card { + border-radius: 4px; + } +} + +.ai-admin-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + max-width: 1400px; +} + +.ai-admin-header__left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.ai-admin-header__right { + display: flex; + align-items: center; + gap: 1rem; + + // Header toggle needs proper alignment + .components-toggle-control { + margin: 0; + + .components-base-control__field { + margin-bottom: 0; + } + } +} + +.ai-admin-header__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 6px; + background: #f6f7f7; + border-radius: 8px; + color: #1d2327; + + svg { + width: 100%; + height: 100%; + } +} + +.ai-admin-header__title { + h1 { + margin: 0; + padding: 0; + font-size: 23px; + font-weight: 600; + line-height: 1.3; + } +} + +/* Legacy page header with icon (for inline use) */ +.ai-page-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + + h1 { + margin: 0; + padding: 0; + } +} + +.ai-page-header__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 6px; + background: #fff; + border-radius: 8px; + color: #1d2327; + + svg { + width: 100%; + height: 100%; + } +} diff --git a/src/admin/components/ProviderTooltipContent.tsx b/src/admin/components/ProviderTooltipContent.tsx new file mode 100644 index 00000000..49f48a9e --- /dev/null +++ b/src/admin/components/ProviderTooltipContent.tsx @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { ProviderMetadata } from '../types/providers'; + +interface ProviderTooltipContentProps { + metadata: ProviderMetadata; + activeModel?: string | null; +} + +const ProviderTooltipContent: React.FC< ProviderTooltipContentProps > = ( { + metadata, + activeModel, +} ) => { + const topModels = metadata.models?.slice( 0, 4 ) ?? []; + + return ( +
+
+
+ { metadata.name } + + { metadata.type === 'client' + ? __( 'Local', 'ai' ) + : __( 'Cloud', 'ai' ) } + +
+ { activeModel && ( + + { sprintf( + /* translators: %s: AI model name. */ + __( 'Requested model: %s', 'ai' ), + activeModel + ) } + + ) } + { metadata.tooltip && ( +

{ metadata.tooltip }

+ ) } + { topModels.length > 0 && ( +
+ + { __( 'Available models', 'ai' ) } + +
    + { topModels.map( ( model ) => ( +
  • + { model.name } + { model.capabilities?.length > 0 && ( + + { model.capabilities.join( ', ' ) } + + ) } +
  • + ) ) } +
+
+ ) } +
+ { metadata.url && ( + + ) } +
+ ); +}; + +export default ProviderTooltipContent; diff --git a/src/admin/components/icons/AiIcon.tsx b/src/admin/components/icons/AiIcon.tsx new file mode 100644 index 00000000..f1daf850 --- /dev/null +++ b/src/admin/components/icons/AiIcon.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * AI Icon - Cauldron with sparkles design used for AI features. + */ +const AiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + + + + +); + +export default AiIcon; diff --git a/src/admin/components/icons/AnthropicIcon.tsx b/src/admin/components/icons/AnthropicIcon.tsx new file mode 100644 index 00000000..d64f80bd --- /dev/null +++ b/src/admin/components/icons/AnthropicIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Anthropic (Claude) Icon + */ +const AnthropicIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default AnthropicIcon; diff --git a/src/admin/components/icons/CloudflareIcon.tsx b/src/admin/components/icons/CloudflareIcon.tsx new file mode 100644 index 00000000..804c6573 --- /dev/null +++ b/src/admin/components/icons/CloudflareIcon.tsx @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Cloudflare Icon + */ +const CloudflareIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + +); + +export default CloudflareIcon; diff --git a/src/admin/components/icons/DeepSeekIcon.tsx b/src/admin/components/icons/DeepSeekIcon.tsx new file mode 100644 index 00000000..effcfe19 --- /dev/null +++ b/src/admin/components/icons/DeepSeekIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * DeepSeek Icon + */ +const DeepSeekIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default DeepSeekIcon; diff --git a/src/admin/components/icons/FalIcon.tsx b/src/admin/components/icons/FalIcon.tsx new file mode 100644 index 00000000..e5f43adb --- /dev/null +++ b/src/admin/components/icons/FalIcon.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Fal.ai Icon + */ +const FalIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default FalIcon; diff --git a/src/admin/components/icons/GoogleIcon.tsx b/src/admin/components/icons/GoogleIcon.tsx new file mode 100644 index 00000000..5ec6b0ee --- /dev/null +++ b/src/admin/components/icons/GoogleIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Google Icon + */ +const GoogleIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GoogleIcon; diff --git a/src/admin/components/icons/GrokIcon.tsx b/src/admin/components/icons/GrokIcon.tsx new file mode 100644 index 00000000..e6fcb836 --- /dev/null +++ b/src/admin/components/icons/GrokIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Grok Icon + */ +const GrokIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GrokIcon; diff --git a/src/admin/components/icons/GroqIcon.tsx b/src/admin/components/icons/GroqIcon.tsx new file mode 100644 index 00000000..4f87cfd2 --- /dev/null +++ b/src/admin/components/icons/GroqIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Groq Icon + */ +const GroqIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GroqIcon; diff --git a/src/admin/components/icons/HuggingFaceIcon.tsx b/src/admin/components/icons/HuggingFaceIcon.tsx new file mode 100644 index 00000000..ba89ab6d --- /dev/null +++ b/src/admin/components/icons/HuggingFaceIcon.tsx @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Hugging Face Icon + */ +const HuggingFaceIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + + + + + +); + +export default HuggingFaceIcon; diff --git a/src/admin/components/icons/McpIcon.tsx b/src/admin/components/icons/McpIcon.tsx new file mode 100644 index 00000000..abe8496a --- /dev/null +++ b/src/admin/components/icons/McpIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * MCP Icon - Model Context Protocol logo. + */ +const McpIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + Model Context Protocol + + + +); + +export default McpIcon; diff --git a/src/admin/components/icons/OllamaIcon.tsx b/src/admin/components/icons/OllamaIcon.tsx new file mode 100644 index 00000000..eb7211df --- /dev/null +++ b/src/admin/components/icons/OllamaIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Ollama Icon + */ +const OllamaIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OllamaIcon; diff --git a/src/admin/components/icons/OpenAiIcon.tsx b/src/admin/components/icons/OpenAiIcon.tsx new file mode 100644 index 00000000..6d4803c3 --- /dev/null +++ b/src/admin/components/icons/OpenAiIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * OpenAI Icon + */ +const OpenAiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OpenAiIcon; diff --git a/src/admin/components/icons/OpenRouterIcon.tsx b/src/admin/components/icons/OpenRouterIcon.tsx new file mode 100644 index 00000000..1d769c21 --- /dev/null +++ b/src/admin/components/icons/OpenRouterIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * OpenRouter Icon + */ +const OpenRouterIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OpenRouterIcon; diff --git a/src/admin/components/icons/XaiIcon.tsx b/src/admin/components/icons/XaiIcon.tsx new file mode 100644 index 00000000..aeca554c --- /dev/null +++ b/src/admin/components/icons/XaiIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * xAI Icon + */ +const XaiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default XaiIcon; diff --git a/src/admin/components/icons/index.ts b/src/admin/components/icons/index.ts new file mode 100644 index 00000000..a950307d --- /dev/null +++ b/src/admin/components/icons/index.ts @@ -0,0 +1,14 @@ +export { default as AiIcon } from './AiIcon'; +export { default as AnthropicIcon } from './AnthropicIcon'; +export { default as CloudflareIcon } from './CloudflareIcon'; +export { default as DeepSeekIcon } from './DeepSeekIcon'; +export { default as FalIcon } from './FalIcon'; +export { default as GoogleIcon } from './GoogleIcon'; +export { default as GroqIcon } from './GroqIcon'; +export { default as GrokIcon } from './GrokIcon'; +export { default as HuggingFaceIcon } from './HuggingFaceIcon'; +export { default as McpIcon } from './McpIcon'; +export { default as OpenAiIcon } from './OpenAiIcon'; +export { default as OpenRouterIcon } from './OpenRouterIcon'; +export { default as OllamaIcon } from './OllamaIcon'; +export { default as XaiIcon } from './XaiIcon'; diff --git a/src/admin/components/provider-icons.tsx b/src/admin/components/provider-icons.tsx new file mode 100644 index 00000000..c7580348 --- /dev/null +++ b/src/admin/components/provider-icons.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import type { ComponentType, SVGProps } from 'react'; + +/** + * Internal dependencies + */ +import { + AiIcon, + AnthropicIcon, + CloudflareIcon, + DeepSeekIcon, + FalIcon, + GoogleIcon, + GrokIcon, + GroqIcon, + HuggingFaceIcon, + OllamaIcon, + OpenAiIcon, + OpenRouterIcon, + XaiIcon, +} from './icons'; + +const ICON_COMPONENTS: Record< string, ComponentType< SVGProps< SVGSVGElement > > > = + Object.freeze( { + anthropic: AnthropicIcon, + openai: OpenAiIcon, + google: GoogleIcon, + fal: FalIcon, + 'fal-ai': FalIcon, + deepseek: DeepSeekIcon, + cloudflare: CloudflareIcon, + huggingface: HuggingFaceIcon, + ollama: OllamaIcon, + openrouter: OpenRouterIcon, + groq: GroqIcon, + grok: GrokIcon, + xai: XaiIcon, + default: AiIcon, + } ); + +export const getProviderIconComponent = ( + iconKey?: string, + fallbackKey?: string +): ComponentType< SVGProps< SVGSVGElement > > => { + const normalized = + ( iconKey || fallbackKey || '' ).toLowerCase().replace( /\s+/g, '' ); + + return ( + ICON_COMPONENTS[ normalized ] || + ICON_COMPONENTS[ iconKey || '' ] || + ICON_COMPONENTS[ fallbackKey || '' ] || + ICON_COMPONENTS.default + ); +}; diff --git a/src/admin/provider-credentials/index.tsx b/src/admin/provider-credentials/index.tsx new file mode 100644 index 00000000..081cbfa6 --- /dev/null +++ b/src/admin/provider-credentials/index.tsx @@ -0,0 +1,254 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { Popover } from '@wordpress/components'; +import { useRef, useState, useEffect } from '@wordpress/element'; +import * as React from 'react'; + +/** + * External dependencies + */ +import { createRoot } from 'react-dom/client'; + +/** + * Internal dependencies + */ +import { getProviderIconComponent } from '../components/provider-icons'; +import ProviderTooltipContent from '../components/ProviderTooltipContent'; +import type { ProviderMetadata, ProviderMetadataMap } from '../types/providers'; +import './style.scss'; + +declare global { + interface Window { + aiProviderCredentialsConfig?: { + providers?: ProviderMetadataMap; + cloudflareAccountId?: string; + }; + } +} + +const ProviderBadge: React.FC< { + providerId: string; + config: ProviderMetadata; + labelElement?: HTMLElement | null; +} > = ( { providerId, config, labelElement } ) => { + const [ isOpen, setIsOpen ] = useState( false ); + const triggerRef = useRef< HTMLDivElement | null >( null ); + const closeTimeout = useRef< ReturnType< typeof setTimeout > | null >( null ); + + const clearCloseTimeout = () => { + if ( closeTimeout.current ) { + clearTimeout( closeTimeout.current ); + closeTimeout.current = null; + } + }; + + const open = () => { + clearCloseTimeout(); + setIsOpen( true ); + }; + + const scheduleClose = () => { + clearCloseTimeout(); + closeTimeout.current = setTimeout( () => setIsOpen( false ), 120 ); + }; + + const close = () => { + clearCloseTimeout(); + setIsOpen( false ); + }; + + // Attach event listeners to the label element so it also triggers the popover + React.useEffect( () => { + if ( ! labelElement ) return; + + const handleMouseEnter = () => open(); + const handleMouseLeave = () => scheduleClose(); + const handleClick = () => open(); + + labelElement.addEventListener( 'mouseenter', handleMouseEnter ); + labelElement.addEventListener( 'mouseleave', handleMouseLeave ); + labelElement.addEventListener( 'click', handleClick ); + labelElement.style.cursor = 'pointer'; + + return () => { + labelElement.removeEventListener( 'mouseenter', handleMouseEnter ); + labelElement.removeEventListener( 'mouseleave', handleMouseLeave ); + labelElement.removeEventListener( 'click', handleClick ); + }; + }, [ labelElement ] ); + + const IconComponent = getProviderIconComponent( + config.icon || providerId, + providerId + ); + const icon = ( + + + + ); + + return ( +
+ + { isOpen && triggerRef.current && ( + +
+ +
+
+ ) } +
+ ); +}; + +const injectCloudflareAccountField = ( + row: HTMLTableRowElement | null, + currentValue: string +) => { + if ( ! row ) { + return; + } + + const targetCell = row.querySelector< HTMLTableCellElement >( 'td' ); + if ( ! targetCell ) { + return; + } + + if ( targetCell.querySelector( '.ai-provider-credentials__cloudflare-account' ) ) { + return; + } + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'ai-provider-credentials__cloudflare-account'; + + const label = document.createElement( 'label' ); + label.htmlFor = 'ai-cloudflare-account-id'; + label.textContent = 'Account ID'; + + const input = document.createElement( 'input' ); + input.type = 'text'; + input.id = 'ai-cloudflare-account-id'; + input.name = 'ai_cloudflare_account_id'; + input.className = 'regular-text'; + input.value = currentValue ?? ''; + input.placeholder = 'Enter your Cloudflare account ID'; + + const helpText = document.createElement( 'p' ); + helpText.className = 'description'; + helpText.textContent = + 'Find this under Workers AI → Overview in the Cloudflare dashboard.'; + + wrapper.appendChild( label ); + wrapper.appendChild( input ); + wrapper.appendChild( helpText ); + + targetCell.appendChild( wrapper ); +}; + +const enhanceProviderRows = ( + providers: ProviderMetadataMap, + cloudflareAccountId: string +) => { + const inputs = document.querySelectorAll( + 'input[id^="wp-ai-client-provider-api-key-"]' + ); + + inputs.forEach( ( input ) => { + const providerId = input.id.replace( + 'wp-ai-client-provider-api-key-', + '' + ); + const config = providers?.[ providerId ]; + + if ( ! config ) { + return; + } + + const row = input.closest( 'tr' ); + const header = row?.querySelector< HTMLElement >( 'th' ); + if ( ! header ) { + return; + } + + const label = + header.querySelector< HTMLElement >( 'label' ) || + header.querySelector< HTMLElement >( '.ai-provider-credentials__name' ) || + header.firstElementChild || + header; + + label.classList.add( 'ai-provider-credentials__name' ); + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'ai-provider-credentials__label-wrapper'; + + const iconHost = document.createElement( 'span' ); + iconHost.className = 'ai-provider-credentials__icon-host'; + wrapper.appendChild( iconHost ); + wrapper.appendChild( label ); + + header.innerHTML = ''; + header.appendChild( wrapper ); + + const description = row?.querySelector< HTMLElement >( 'p.description' ); + if ( description ) { + if ( config.keepDescription && config.tooltip ) { + description.textContent = config.tooltip; + } else if ( ! config.keepDescription ) { + description.remove(); + } + } + + const root = createRoot( iconHost ); + root.render( + + ); + + if ( providerId === 'cloudflare' ) { + injectCloudflareAccountField( row, cloudflareAccountId ); + } + } ); +}; + +domReady( () => { + const providers = + window.aiProviderCredentialsConfig?.providers ?? undefined; + const cloudflareAccountId = + window.aiProviderCredentialsConfig?.cloudflareAccountId ?? ''; + if ( providers ) { + enhanceProviderRows( providers, cloudflareAccountId ); + } +} ); diff --git a/src/admin/provider-credentials/style.scss b/src/admin/provider-credentials/style.scss new file mode 100644 index 00000000..5f542b7e --- /dev/null +++ b/src/admin/provider-credentials/style.scss @@ -0,0 +1,186 @@ +@use '../common'; + +.ai-provider-credentials__label-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.ai-provider-credentials__icon-host { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-provider-credentials__trigger { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-provider-credentials__icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} + +.ai-provider-credentials__icon { + --ai-provider-icon-color: #1d2327; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: #1d2327; + transition: color 0.15s ease; +} + +.ai-provider-credentials__icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +.ai-provider-credentials__trigger:hover .ai-provider-credentials__icon, +.ai-provider-credentials__trigger:focus-within .ai-provider-credentials__icon, +.ai-provider-credentials__trigger[aria-expanded="true"] .ai-provider-credentials__icon { + color: var(--ai-provider-icon-color); +} + +.ai-provider-credentials__name { + font-weight: 600; +} + +.ai-provider-tooltip { + display: flex; + flex-direction: column; + max-width: 300px; +} + +.ai-provider-tooltip__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.ai-provider-tooltip__name { + font-size: 14px; + font-weight: 600; + color: #1d2327; +} + +.ai-provider-tooltip__badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 500; + color: #50575e; + background: #f0f0f0; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.ai-provider-tooltip__section-title { + display: block; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #757575; + margin-bottom: 6px; +} + +.ai-provider-tooltip__models { + margin-top: 8px; +} + +.ai-provider-tooltip__models ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.ai-provider-tooltip__models li { + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px 8px; + background: #f6f7f7; + border-radius: 4px; +} + +.ai-provider-tooltip__model-name { + font-size: 12px; + font-weight: 500; + color: #1d2327; +} + +.ai-provider-tooltip__capabilities { + font-size: 11px; + color: #757575; +} + +.ai-provider-tooltip__link { + font-size: 11px; + color: var(--wp-components-color-foreground-muted, #50575e); +} + +.ai-provider-tooltip__hint { + font-size: 11px; + color: var(--wp-components-color-foreground-muted, #50575e); + line-height: 1.4; +} + +.ai-provider-credentials__popover .components-popover__content { + padding: 0; + background: #fff; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.14); + border: 1px solid rgba(0, 0, 0, 0.08); + min-width: 260px; + max-width: 360px; + border-radius: 4px; + overflow: hidden; +} + +.ai-provider-tooltip__body { + padding: 12px 16px; +} + +.ai-provider-tooltip__footer { + padding: 10px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + background: #f9f9f9; +} + +.ai-provider-credentials__cloudflare-account { + margin-top: 12px; + + label { + display: block; + font-weight: 600; + margin-bottom: 4px; + color: #1d2327; + } + + input { + width: 100%; + max-width: 24rem; + margin-bottom: 4px; + } + + .description { + margin: 0; + color: #5c5f62; + } +} diff --git a/webpack.config.js b/webpack.config.js index 49a296e9..124853f5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -29,6 +29,11 @@ module.exports = { 'src/experiments/title-generation', 'index.tsx' ), + 'admin/provider-credentials': path.resolve( + process.cwd(), + 'src/admin/provider-credentials', + 'index.tsx' + ), }, plugins: [ From 27fe36a005e5533a2cd61029d5d728350e2f9074 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:38:36 -0500 Subject: [PATCH 02/15] Fix extended providers not appearing on credentials screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Initialize HTTP discovery strategy before experiments load - Register extended providers immediately instead of on late init hook - Initialize Provider_Credentials_UI for enhanced credentials display - Reorder initialization: HTTP client → experiments → AI_Client The wp-ai-client package collects providers during AI_Client::init(). Extended providers must be registered before that collection occurs. --- .../Extended_Providers/Extended_Providers.php | 4 +++- includes/bootstrap.php | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 7cd70988..2d55e27f 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -73,7 +73,9 @@ protected function load_experiment_metadata(): array { * {@inheritDoc} */ public function register(): void { - add_action( 'init', array( $this, 'register_providers' ), 20 ); + // Register providers immediately so they're available when + // the WP AI Client collects provider metadata for the credentials screen. + $this->register_providers(); } /** diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 3ad96844..9cec8480 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -12,9 +12,11 @@ namespace WordPress\AI; use WordPress\AI\Abilities\Utilities\Posts; +use WordPress\AI\Admin\Provider_Credentials_UI; use WordPress\AI\Settings\Settings_Page; use WordPress\AI\Settings\Settings_Registration; use WordPress\AI_Client\AI_Client; +use WordPress\AI_Client\HTTP\WP_AI_Client_Discovery_Strategy; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { @@ -201,14 +203,19 @@ function load(): void { */ function initialize_experiments(): void { try { - // Initialize the WP AI Client. - AI_Client::init(); + // Wire up the WordPress HTTP client first (needed by provider registration). + WP_AI_Client_Discovery_Strategy::init(); + // Initialize experiments so extended providers are registered + // before the WP AI Client collects provider metadata. $registry = new Experiment_Registry(); $loader = new Experiment_Loader( $registry ); $loader->register_default_experiments(); $loader->initialize_experiments(); + // Initialize the WP AI Client (collects providers for credentials screen). + AI_Client::init(); + // Initialize settings registration. $settings_registration = new Settings_Registration( $registry ); $settings_registration->init(); @@ -217,6 +224,9 @@ function initialize_experiments(): void { if ( is_admin() ) { $settings_page = new Settings_Page( $registry ); $settings_page->init(); + + // Initialize enhanced provider credentials UI. + Provider_Credentials_UI::init(); } // Register our post-related WordPress Abilities. From 190ae2212b134a9615e8b786a03686feb70540b9 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:44:54 -0500 Subject: [PATCH 03/15] Fix partial linting issues in Extended_Providers experiment --- .../Extended_Providers/Extended_Providers.php | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 2d55e27f..8409fa21 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -29,7 +29,6 @@ use function class_exists; use function esc_attr; use function esc_html; -use function esc_html_e; use function get_option; use function is_string; use function register_setting; @@ -45,6 +44,12 @@ * experiment is enabled. */ class Extended_Providers extends Abstract_Experiment { + + /** + * Default provider classes to register. + * + * @var array + */ private const DEFAULT_PROVIDER_CLASSES = array( CloudflareWorkersAiProvider::class, CohereProvider::class, @@ -56,6 +61,12 @@ class Extended_Providers extends Abstract_Experiment { OllamaProvider::class, OpenRouterProvider::class, ); + + /** + * Field name for provider selection setting. + * + * @var string + */ private const FIELD_PROVIDERS = 'providers'; /** @@ -108,10 +119,12 @@ public function register_providers(): void { if ( ! class_exists( $class_name ) ) { _doing_it_wrong( __METHOD__, - sprintf( - /* translators: %s: provider class name. */ - __( 'Extended Providers experiment could not load "%s". Make sure the class is autoloadable.', 'ai' ), - esc_html( $class_name ) + esc_html( + sprintf( + /* translators: %s: provider class name. */ + __( 'Extended Providers experiment could not load "%s". Make sure the class is autoloadable.', 'ai' ), + $class_name + ) ), '0.1.0' ); @@ -127,11 +140,13 @@ public function register_providers(): void { } catch ( \Throwable $t ) { _doing_it_wrong( __METHOD__, - sprintf( - /* translators: 1: provider class, 2: error message. */ - __( 'Failed to register provider "%1$s": %2$s', 'ai' ), - esc_html( $class_name ), - esc_html( $t->getMessage() ) + esc_html( + sprintf( + /* translators: 1: provider class, 2: error message. */ + __( 'Failed to register provider "%1$s": %2$s', 'ai' ), + $class_name, + $t->getMessage() + ) ), '0.1.0' ); @@ -361,7 +376,8 @@ private function get_provider_label( string $class_name ): string { $metadata = $class_name::metadata(); return $metadata->getName(); } catch ( \Throwable $t ) { - // Fallback below. + // Fallback to class name below. + unset( $t ); } } From 36311f47ff204d4e5bf1b3c0c0f2a83b01e2a408 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:08:19 -0500 Subject: [PATCH 04/15] Fix PHPCS lint errors across Extended Providers - Add phpcs:disable/enable blocks for exception escaping false positives - Add phpcs:ignore for DisallowMultiConstantDefinition false positives - Update @return annotations to use fully qualified class names - Fix variable alignment spacing issues - Add missing translators comment for placeholder --- includes/Admin/Provider_Credentials_UI.php | 4 +- includes/Admin/Provider_Metadata_Registry.php | 44 ++++++++--------- .../Extended_Providers/Extended_Providers.php | 11 +++-- .../CloudflareWorkersAiProvider.php | 6 ++- ...CloudflareWorkersAiTextGenerationModel.php | 22 +++++---- .../Cohere/CohereModelMetadataDirectory.php | 14 +++--- includes/Providers/Cohere/CohereProvider.php | 2 + .../Cohere/CohereTextGenerationModel.php | 49 ++++++++++++------- .../Providers/DeepSeek/DeepSeekProvider.php | 2 + .../FalAi/FalAiImageGenerationModel.php | 27 +++++----- .../FalAi/FalAiModelMetadataDirectory.php | 42 ++++++++-------- includes/Providers/FalAi/FalAiProvider.php | 2 + .../Grok/GrokModelMetadataDirectory.php | 14 +++--- includes/Providers/Grok/GrokProvider.php | 2 + .../Groq/GroqModelMetadataDirectory.php | 2 +- includes/Providers/Groq/GroqProvider.php | 2 + .../HuggingFaceModelMetadataDirectory.php | 4 +- .../HuggingFace/HuggingFaceProvider.php | 2 + .../Ollama/OllamaModelMetadataDirectory.php | 4 +- includes/Providers/Ollama/OllamaProvider.php | 2 + .../Ollama/OllamaTextGenerationModel.php | 22 +++++---- .../OpenRouterModelMetadataDirectory.php | 4 +- .../OpenRouter/OpenRouterProvider.php | 2 + 23 files changed, 165 insertions(+), 120 deletions(-) diff --git a/includes/Admin/Provider_Credentials_UI.php b/includes/Admin/Provider_Credentials_UI.php index 98d3070a..9c036230 100644 --- a/includes/Admin/Provider_Credentials_UI.php +++ b/includes/Admin/Provider_Credentials_UI.php @@ -23,7 +23,7 @@ class Provider_Credentials_UI { * Bootstraps the enhancements. */ public static function init(): void { - add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); + add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_assets' ) ); } /** @@ -50,7 +50,7 @@ public static function enqueue_assets( string $hook ): void { 'ai_provider_credentials', 'aiProviderCredentialsConfig', array( - 'providers' => Provider_Metadata_Registry::get_metadata(), + 'providers' => Provider_Metadata_Registry::get_metadata(), 'cloudflareAccountId' => (string) get_option( 'ai_cloudflare_account_id', '' ), ) ); diff --git a/includes/Admin/Provider_Metadata_Registry.php b/includes/Admin/Provider_Metadata_Registry.php index 68bdd324..b455d1cc 100644 --- a/includes/Admin/Provider_Metadata_Registry.php +++ b/includes/Admin/Provider_Metadata_Registry.php @@ -8,21 +8,18 @@ namespace WordPress\AI\Admin; use WordPress\AiClient\AiClient; -use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; - -use function __; use function esc_html__; use function get_option; use function get_transient; use function is_array; use function is_string; +use function md5; +use function set_transient; use function sprintf; use function trim; use function wp_json_encode; -use function set_transient; -use function md5; /** * Provides a single source of truth for provider metadata and branding. @@ -39,9 +36,9 @@ class Provider_Metadata_Registry { * @return array> */ public static function get_metadata(): array { - $registry = AiClient::defaultRegistry(); - $providers = array(); - $overrides = self::get_branding_overrides(); + $registry = AiClient::defaultRegistry(); + $providers = array(); + $overrides = self::get_branding_overrides(); $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); foreach ( $registry->getRegisteredProviderIds() as $provider_id ) { @@ -51,7 +48,7 @@ public static function get_metadata(): array { continue; } - /** @var ProviderMetadata $metadata */ + /** @var \WordPress\AiClient\Providers\DTO\ProviderMetadata $metadata */ $metadata = $class_name::metadata(); $brand = $overrides[ $metadata->getId() ] ?? array(); @@ -197,88 +194,89 @@ private static function get_models_cache_key( string $provider_id, $credential ) * @return array> */ private static function get_branding_overrides(): array { + /* translators: %s: provider name (e.g., "OpenAI", "Anthropic"). */ $link_template = esc_html__( 'Create and manage your %s API keys in these account settings.', 'ai' ); return array( - 'anthropic' => array( + 'anthropic' => array( 'icon' => 'anthropic', 'initials' => 'An', 'color' => '#111111', 'url' => 'https://console.anthropic.com/settings/keys', 'tooltip' => sprintf( $link_template, 'Anthropic' ), ), - 'cohere' => array( + 'cohere' => array( 'color' => '#6f2cff', 'url' => 'https://dashboard.cohere.com/api-keys', 'tooltip' => sprintf( $link_template, 'Cohere' ), ), - 'cloudflare' => array( + 'cloudflare' => array( 'icon' => 'cloudflare', 'color' => '#f3801a', 'url' => 'https://dash.cloudflare.com/profile/api-tokens', 'tooltip' => sprintf( $link_template, 'Cloudflare Workers AI' ), ), - 'deepseek' => array( + 'deepseek' => array( 'icon' => 'deepseek', 'color' => '#0f172a', 'url' => 'https://platform.deepseek.com/api_keys', 'tooltip' => sprintf( $link_template, 'DeepSeek' ), ), - 'fal' => array( + 'fal' => array( 'icon' => 'fal', 'color' => '#0ea5e9', 'url' => 'https://fal.ai/dashboard/keys', 'tooltip' => sprintf( $link_template, 'Fal.ai' ), ), - 'fal-ai' => array( + 'fal-ai' => array( 'icon' => 'fal-ai', 'color' => '#0ea5e9', 'url' => 'https://fal.ai/dashboard/keys', 'tooltip' => sprintf( $link_template, 'Fal.ai' ), ), - 'grok' => array( + 'grok' => array( 'icon' => 'grok', 'color' => '#ff6f00', 'url' => 'https://console.x.ai/api-keys', 'tooltip' => sprintf( $link_template, 'Grok' ), ), - 'groq' => array( + 'groq' => array( 'icon' => 'groq', 'color' => '#f43f5e', 'url' => 'https://console.groq.com/keys', 'tooltip' => sprintf( $link_template, 'Groq' ), ), - 'google' => array( + 'google' => array( 'icon' => 'google', 'color' => '#4285f4', 'url' => 'https://aistudio.google.com/app/api-keys', 'tooltip' => sprintf( $link_template, 'Google' ), ), - 'huggingface' => array( + 'huggingface' => array( 'icon' => 'huggingface', 'color' => '#ffbe3c', 'url' => 'https://huggingface.co/settings/tokens', 'tooltip' => sprintf( $link_template, 'Hugging Face' ), ), - 'openai' => array( + 'openai' => array( 'icon' => 'openai', 'color' => '#10a37f', 'url' => 'https://platform.openai.com/api-keys', 'tooltip' => sprintf( $link_template, 'OpenAI' ), ), - 'openrouter' => array( + 'openrouter' => array( 'icon' => 'openrouter', 'color' => '#0f172a', 'url' => 'https://openrouter.ai/settings/keys', 'tooltip' => sprintf( $link_template, 'OpenRouter' ), ), - 'ollama' => array( + 'ollama' => array( 'icon' => 'ollama', 'color' => '#111111', 'tooltip' => esc_html__( 'Local Ollama instances at http://localhost:11434 do not require an API key. If you are calling https://ollama.com/api, create a key from your ollama.com account (for example via the dashboard or the `ollama signin` command) and paste it here.', 'ai' ), 'keepDescription' => true, ), - 'xai' => array( + 'xai' => array( 'icon' => 'xai', 'color' => '#000000', 'url' => 'https://console.x.ai/api-keys', diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 8409fa21..b223e973 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -50,6 +50,7 @@ class Extended_Providers extends Abstract_Experiment { * * @var array */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive with ::class array. private const DEFAULT_PROVIDER_CLASSES = array( CloudflareWorkersAiProvider::class, CohereProvider::class, @@ -193,7 +194,7 @@ public function render_settings_fields(): void {

is_provider_selected( $class_name, $selection ); ?>
@@ -257,8 +258,8 @@ private function get_provider_classes(): array { return array_values( array_filter( array_map( - static function ( $class ) { - return is_string( $class ) ? trim( $class ) : ''; + static function ( $class_name ) { + return is_string( $class_name ) ? trim( $class_name ) : ''; }, (array) $providers ) @@ -283,8 +284,8 @@ private function filter_enabled_provider_classes( array $provider_classes ): arr return array_values( array_filter( $provider_classes, - static function ( string $class ) use ( $selection ): bool { - return ! isset( $selection[ $class ] ) || true === $selection[ $class ]; + static function ( string $class_name ) use ( $selection ): bool { + return ! isset( $selection[ $class_name ] ) || true === $selection[ $class_name ]; } ) ); diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php index c379006e..c5dc8453 100644 --- a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php @@ -18,10 +18,9 @@ use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; - use function apply_filters; -use function getenv; use function get_option; +use function getenv; use function is_string; /** @@ -68,6 +67,7 @@ public static function get_account_id(): string { $account_id = apply_filters( 'ai_cloudflare_account_id', $account_id ); if ( ! $account_id || ! is_string( $account_id ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Cloudflare Workers AI requires a Cloudflare account ID. Set the CLOUDFLARE_ACCOUNT_ID environment variable or use the ai_cloudflare_account_id filter.' ); @@ -86,6 +86,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported Cloudflare Workers AI model capabilities: ' . implode( ', ', @@ -97,6 +98,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php index db837b48..b9221577 100644 --- a/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php @@ -37,7 +37,7 @@ class CloudflareWorkersAiTextGenerationModel extends AbstractApiBasedModel imple * {@inheritDoc} */ public function generateTextResult( array $prompt ): GenerativeAiResult { - $request = new Request( + $request = new Request( HttpMethodEnum::POST(), CloudflareWorkersAiProvider::url( 'run/' . $this->metadata()->getId() ), array( 'Content-Type' => 'application/json' ), @@ -60,7 +60,7 @@ public function streamGenerateTextResult( array $prompt ): \Generator { /** * Builds the Cloudflare payload. * - * @param list $prompt Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. * * @return array */ @@ -69,9 +69,11 @@ private function buildPayload( array $prompt ): array { $messages = $this->convertPromptToMessages( $prompt ); if ( empty( $messages ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new InvalidArgumentException( __( 'Cloudflare Workers AI chat requests require at least one user message.', 'ai' ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $payload = array( @@ -104,6 +106,7 @@ private function buildPayload( array $prompt ): array { foreach ( $config->getCustomOptions() as $key => $value ) { if ( isset( $payload[ $key ] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new InvalidArgumentException( sprintf( /* translators: %s: custom option key. */ @@ -111,6 +114,7 @@ private function buildPayload( array $prompt ): array { $key ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $payload[ $key ] = $value; @@ -122,7 +126,7 @@ private function buildPayload( array $prompt ): array { /** * Converts the WP AI Client prompt into Cloudflare message objects. * - * @param list $prompt Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. * * @return list */ @@ -135,7 +139,7 @@ private function convertPromptToMessages( array $prompt ): array { continue; } - $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; $messages[] = array( 'role' => $role, 'content' => $text, @@ -148,7 +152,7 @@ private function convertPromptToMessages( array $prompt ): array { /** * Extracts text from a message. * - * @param Message $message Message instance. + * @param \WordPress\AiClient\Messages\DTO\Message $message Message instance. * * @return string */ @@ -165,9 +169,9 @@ private function extractTextFromMessage( Message $message ): string { /** * Parses the Workers AI response to a WP AI result. * - * @param Response $response HTTP response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response HTTP response. * - * @return GenerativeAiResult + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponse( Response $response ): GenerativeAiResult { $data = $response->getData(); @@ -180,7 +184,7 @@ private function parseResponse( Response $response ): GenerativeAiResult { array( new MessagePart( $data['result']['response'] ) ) ); - $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); $prompt_tokens = (int) ( $data['result']['input_tokens'] ?? 0 ); $output_tokens = (int) ( $data['result']['output_tokens'] ?? 0 ); @@ -197,7 +201,7 @@ private function parseResponse( Response $response ): GenerativeAiResult { /** * Ensures Workers AI returned a successful response. * - * @param Response $response HTTP response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response HTTP response. * * @return void */ diff --git a/includes/Providers/Cohere/CohereModelMetadataDirectory.php b/includes/Providers/Cohere/CohereModelMetadataDirectory.php index 86ea5f60..2abcebcc 100644 --- a/includes/Providers/Cohere/CohereModelMetadataDirectory.php +++ b/includes/Providers/Cohere/CohereModelMetadataDirectory.php @@ -31,9 +31,9 @@ class CohereModelMetadataDirectory extends AbstractApiBasedModelMetadataDirector * {@inheritDoc} */ protected function sendListModelsRequest(): array { - $request = new Request( HttpMethodEnum::GET(), CohereProvider::url( 'models' ) ); - $request = $this->getRequestAuthentication()->authenticateRequest( $request ); - $response = $this->getHttpTransporter()->send( $request ); + $request = new Request( HttpMethodEnum::GET(), CohereProvider::url( 'models' ) ); + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $response = $this->getHttpTransporter()->send( $request ); ResponseUtil::throwIfNotSuccessful( $response ); return $this->parseResponseToModelMetadataMap( $response ); @@ -42,9 +42,9 @@ protected function sendListModelsRequest(): array { /** * Parses Cohere's `/models` response. * - * @param Response $response Cohere response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Cohere response. * - * @return array + * @return array */ private function parseResponseToModelMetadataMap( Response $response ): array { $data = $response->getData(); @@ -56,7 +56,7 @@ private function parseResponseToModelMetadataMap( Response $response ): array { CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory(), ); - $options = $this->getTextOptions(); + $options = $this->getTextOptions(); $metadata = array(); foreach ( $data['models'] as $model ) { @@ -88,7 +88,7 @@ private function parseResponseToModelMetadataMap( Response $response ): array { /** * Returns baseline Cohere chat options. * - * @return array + * @return array */ private function getTextOptions(): array { return array( diff --git a/includes/Providers/Cohere/CohereProvider.php b/includes/Providers/Cohere/CohereProvider.php index 5d71b55e..412de4c1 100644 --- a/includes/Providers/Cohere/CohereProvider.php +++ b/includes/Providers/Cohere/CohereProvider.php @@ -42,6 +42,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported Cohere model capabilities: ' . implode( ', ', @@ -53,6 +54,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/Cohere/CohereTextGenerationModel.php b/includes/Providers/Cohere/CohereTextGenerationModel.php index 67ac86a9..d9a357e6 100644 --- a/includes/Providers/Cohere/CohereTextGenerationModel.php +++ b/includes/Providers/Cohere/CohereTextGenerationModel.php @@ -47,9 +47,9 @@ public function generateTextResult( array $prompt ): GenerativeAiResult { $payload ); - $request = $this->getRequestAuthentication()->authenticateRequest( $request ); - $httpTransport = $this->getHttpTransporter(); - $response = $httpTransport->send( $request ); + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $http_transport = $this->getHttpTransporter(); + $response = $http_transport->send( $request ); $this->throwIfNotSuccessful( $response ); return $this->parseResponseToResult( $response ); @@ -59,17 +59,19 @@ public function generateTextResult( array $prompt ): GenerativeAiResult { * {@inheritDoc} */ public function streamGenerateTextResult( array $prompt ): \Generator { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), 'stream', __( 'Streaming is not yet implemented for the Cohere provider.', 'ai' ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** * Builds the Cohere `/chat` payload. * - * @param list $prompt Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. * * @return array */ @@ -79,17 +81,19 @@ private function buildPayload( array $prompt ): array { $system_text = $config->getSystemInstruction(); if ( empty( $messages ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new InvalidArgumentException( __( 'Cohere chat requests require at least one user message.', 'ai' ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $current_message = $this->extractLatestUserMessage( $messages ); $chat_history = $this->convertMessagesToChatHistory( $messages ); $payload = array( - 'model' => $this->metadata()->getId(), - 'message' => $current_message, + 'model' => $this->metadata()->getId(), + 'message' => $current_message, ); if ( $system_text ) { @@ -121,6 +125,7 @@ private function buildPayload( array $prompt ): array { foreach ( $config->getCustomOptions() as $key => $value ) { if ( isset( $payload[ $key ] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new InvalidArgumentException( sprintf( /* translators: %s: custom option key. */ @@ -128,6 +133,7 @@ private function buildPayload( array $prompt ): array { $key ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $payload[ $key ] = $value; } @@ -138,7 +144,7 @@ private function buildPayload( array $prompt ): array { /** * Converts the WP AI Client prompt into Cohere's messages array. * - * @param list $prompt Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. * * @return list */ @@ -165,7 +171,7 @@ private function convertPromptToMessages( array $prompt ): array { /** * Extracts the first text fragment from a message. * - * @param Message $message Prompt message. + * @param \WordPress\AiClient\Messages\DTO\Message $message Prompt message. * * @return string */ @@ -182,15 +188,16 @@ private function extractTextFromMessage( Message $message ): string { /** * Converts Cohere API responses to standard results. * - * @param Response $response Cohere response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Cohere response. * - * @return GenerativeAiResult + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponseToResult( Response $response ): GenerativeAiResult { $data = $response->getData(); $text_candidates = $this->extractTextCandidates( $data ); if ( empty( $text_candidates ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), 'text' ); } @@ -241,9 +248,11 @@ private function extractTextCandidates( array $data ): array { $content = $data['message']['content'] ?? array(); if ( is_array( $content ) ) { foreach ( $content as $block ) { - if ( isset( $block['text'] ) && is_string( $block['text'] ) ) { - $candidates[] = $block['text']; + if ( ! isset( $block['text'] ) || ! is_string( $block['text'] ) ) { + continue; } + + $candidates[] = $block['text']; } } } @@ -254,17 +263,21 @@ private function extractTextCandidates( array $data ): array { if ( isset( $data['response'] ) && is_array( $data['response'] ) ) { foreach ( $data['response'] as $entry ) { - if ( isset( $entry['message'] ) && is_string( $entry['message'] ) ) { - $candidates[] = $entry['message']; + if ( ! isset( $entry['message'] ) || ! is_string( $entry['message'] ) ) { + continue; } + + $candidates[] = $entry['message']; } } if ( isset( $data['generations'] ) && is_array( $data['generations'] ) ) { foreach ( $data['generations'] as $generation ) { - if ( isset( $generation['text'] ) && is_string( $generation['text'] ) ) { - $candidates[] = $generation['text']; + if ( ! isset( $generation['text'] ) || ! is_string( $generation['text'] ) ) { + continue; } + + $candidates[] = $generation['text']; } } @@ -274,7 +287,7 @@ private function extractTextCandidates( array $data ): array { /** * Ensures Cohere returned a successful response. * - * @param Response $response Cohere response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Cohere response. * * @return void */ @@ -301,9 +314,11 @@ private function extractLatestUserMessage( array &$messages ): string { return $content; } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new InvalidArgumentException( __( 'Cohere chat requests require at least one user message.', 'ai' ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/DeepSeek/DeepSeekProvider.php b/includes/Providers/DeepSeek/DeepSeekProvider.php index 29a847bc..8cb6f575 100644 --- a/includes/Providers/DeepSeek/DeepSeekProvider.php +++ b/includes/Providers/DeepSeek/DeepSeekProvider.php @@ -42,6 +42,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported DeepSeek model capabilities: ' . implode( ', ', @@ -53,6 +54,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/FalAi/FalAiImageGenerationModel.php b/includes/Providers/FalAi/FalAiImageGenerationModel.php index ac2fc143..5a6f5d67 100644 --- a/includes/Providers/FalAi/FalAiImageGenerationModel.php +++ b/includes/Providers/FalAi/FalAiImageGenerationModel.php @@ -57,12 +57,12 @@ public function generateImageResult( array $prompt ): GenerativeAiResult { /** * Builds the HTTP request for the synchronous `fal.run` endpoint. * - * @param HttpMethodEnum $method HTTP method. + * @param \WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum $method HTTP method. * @param string $model_path Model identifier. * @param array> $headers Headers. * @param array|null $data Payload. * - * @return Request + * @return \WordPress\AiClient\Providers\Http\DTO\Request */ protected function createRequest( HttpMethodEnum $method, @@ -81,7 +81,7 @@ protected function createRequest( /** * Builds the Fal.ai payload from the prompt. * - * @param list $prompt Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. * * @return array */ @@ -94,32 +94,35 @@ private function buildPayload( array $prompt ): array { /** * Converts Fal.ai responses to a GenerativeAiResult. * - * @param Response $response Fal.ai response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Fal.ai response. * - * @return GenerativeAiResult + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponseToResult( Response $response ): GenerativeAiResult { $response_data = $response->getData(); if ( ! isset( $response_data['images'] ) || ! is_array( $response_data['images'] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), 'images' ); } $candidates = array(); foreach ( $response_data['images'] as $index => $image_data ) { if ( ! is_array( $image_data ) || empty( $image_data['url'] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), "images[{$index}]", 'Each image must include a URL.' ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $mime_type = isset( $image_data['content_type'] ) && is_string( $image_data['content_type'] ) ? $image_data['content_type'] : 'image/png'; - $file = new File( (string) $image_data['url'], $mime_type ); - $message = new Message( + $file = new File( (string) $image_data['url'], $mime_type ); + $message = new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ); @@ -142,11 +145,12 @@ private function parseResponseToResult( Response $response ): GenerativeAiResult /** * Normalizes the prompt into a single user string. * - * @param list $messages Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $messages Prompt messages. * * @return string */ private function preparePromptText( array $messages ): string { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. if ( count( $messages ) !== 1 ) { throw new InvalidArgumentException( __( 'Fal.ai models require a single user prompt.', 'ai' ) @@ -170,12 +174,13 @@ private function preparePromptText( array $messages ): string { throw new InvalidArgumentException( __( 'Fal.ai image prompts must include text content.', 'ai' ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** * Throws an exception if the response indicates failure. * - * @param Response $response Fal.ai response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Fal.ai response. * * @return void */ @@ -186,9 +191,9 @@ protected function throwIfNotSuccessful( Response $response ): void { /** * Converts Bearer auth headers into Fal.ai `Key` headers. * - * @param Request $request Authenticated request. + * @param \WordPress\AiClient\Providers\Http\DTO\Request $request Authenticated request. * - * @return Request + * @return \WordPress\AiClient\Providers\Http\DTO\Request */ private function ensureFalAuthorizationHeader( Request $request ): Request { $authorization = $request->getHeader( 'Authorization' ); diff --git a/includes/Providers/FalAi/FalAiModelMetadataDirectory.php b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php index da2b7d81..6b943669 100644 --- a/includes/Providers/FalAi/FalAiModelMetadataDirectory.php +++ b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php @@ -31,36 +31,36 @@ class FalAiModelMetadataDirectory extends AbstractApiBasedModelMetadataDirectory private $catalogue = array( // FLUX.2 models. array( - 'id' => 'fal-ai/flux-2', - 'name' => 'FLUX.2 Dev', - 'mime' => 'image/jpeg', + 'id' => 'fal-ai/flux-2', + 'name' => 'FLUX.2 Dev', + 'mime' => 'image/jpeg', ), array( - 'id' => 'fal-ai/flux-2-pro', - 'name' => 'FLUX.2 Pro', - 'mime' => 'image/jpeg', + 'id' => 'fal-ai/flux-2-pro', + 'name' => 'FLUX.2 Pro', + 'mime' => 'image/jpeg', ), array( - 'id' => 'fal-ai/flux-2-flex', - 'name' => 'FLUX.2 Flex', - 'mime' => 'image/jpeg', + 'id' => 'fal-ai/flux-2-flex', + 'name' => 'FLUX.2 Flex', + 'mime' => 'image/jpeg', ), // FLUX.1 models. array( - 'id' => 'fal-ai/flux/dev', - 'name' => 'FLUX.1 Dev', - 'mime' => 'image/jpeg', + 'id' => 'fal-ai/flux/dev', + 'name' => 'FLUX.1 Dev', + 'mime' => 'image/jpeg', ), array( - 'id' => 'fal-ai/flux/schnell', - 'name' => 'FLUX.1 Schnell', - 'mime' => 'image/jpeg', + 'id' => 'fal-ai/flux/schnell', + 'name' => 'FLUX.1 Schnell', + 'mime' => 'image/jpeg', ), // Other models. array( - 'id' => 'fal-ai/fast-sdxl', - 'name' => 'Fast SDXL', - 'mime' => 'image/png', + 'id' => 'fal-ai/fast-sdxl', + 'name' => 'Fast SDXL', + 'mime' => 'image/png', ), ); @@ -87,7 +87,7 @@ protected function sendListModelsRequest(): array { /** * Returns baseline supported options. * - * @return array + * @return array */ private function get_default_options(): array { return array( @@ -101,10 +101,10 @@ private function get_default_options(): array { /** * Adds MIME-specific option metadata. * - * @param array $options Base option list. + * @param array $options Base option list. * @param string $mime_type MIME string. * - * @return array + * @return array */ private function merge_options_with_mime( array $options, string $mime_type ): array { $mime_option = new SupportedOption( OptionEnum::outputMimeType(), array( $mime_type ) ); diff --git a/includes/Providers/FalAi/FalAiProvider.php b/includes/Providers/FalAi/FalAiProvider.php index b6f6f422..29fe5b4b 100644 --- a/includes/Providers/FalAi/FalAiProvider.php +++ b/includes/Providers/FalAi/FalAiProvider.php @@ -55,6 +55,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported Fal.ai model capabilities: ' . implode( ', ', @@ -66,6 +67,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/Grok/GrokModelMetadataDirectory.php b/includes/Providers/Grok/GrokModelMetadataDirectory.php index 164e50e0..4cd583a8 100644 --- a/includes/Providers/Grok/GrokModelMetadataDirectory.php +++ b/includes/Providers/Grok/GrokModelMetadataDirectory.php @@ -31,11 +31,13 @@ class GrokModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDi /** * Known suffixes for image-only models. */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive: array values, not multiple constants. private const IMAGE_MODEL_KEYWORDS = array( 'image', 'img' ); /** * Known suffixes for multimodal chat models. */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive: array values, not multiple constants. private const MULTIMODAL_KEYWORDS = array( 'vision', '4.1', 'omni' ); /** @@ -73,8 +75,8 @@ protected function parseResponseToModelMetadataList( Response $response ): array continue; } - $model_id = (string) $model_data['id']; - $metadata[] = new ModelMetadata( + $model_id = (string) $model_data['id']; + $metadata[] = new ModelMetadata( $model_id, $this->format_model_name( $model_id ), $this->determine_capabilities( $model_id ), @@ -102,7 +104,7 @@ private function format_model_name( string $model_id ): string { * * @param string $model_id Model identifier. * - * @return array + * @return array */ private function determine_capabilities( string $model_id ): array { foreach ( self::IMAGE_MODEL_KEYWORDS as $keyword ) { @@ -122,7 +124,7 @@ private function determine_capabilities( string $model_id ): array { * * @param string $model_id Model identifier. * - * @return array + * @return array */ private function determine_supported_options( string $model_id ): array { foreach ( self::IMAGE_MODEL_KEYWORDS as $keyword ) { @@ -157,7 +159,7 @@ private function has_keyword( string $model_id, array $keywords ): bool { * * @param bool $supports_multimodal Whether the model supports image inputs. * - * @return array + * @return array */ private function get_text_options( bool $supports_multimodal ): array { $options = array( @@ -194,7 +196,7 @@ private function get_text_options( bool $supports_multimodal ): array { /** * Returns supported options for Grok image generators. * - * @return array + * @return array */ private function get_image_options(): array { return array( diff --git a/includes/Providers/Grok/GrokProvider.php b/includes/Providers/Grok/GrokProvider.php index 56b407cf..8635e73f 100644 --- a/includes/Providers/Grok/GrokProvider.php +++ b/includes/Providers/Grok/GrokProvider.php @@ -42,6 +42,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported Grok model capabilities: ' . implode( ', ', @@ -53,6 +54,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/Groq/GroqModelMetadataDirectory.php b/includes/Providers/Groq/GroqModelMetadataDirectory.php index 66ef5238..df7dbfa2 100644 --- a/includes/Providers/Groq/GroqModelMetadataDirectory.php +++ b/includes/Providers/Groq/GroqModelMetadataDirectory.php @@ -78,7 +78,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array /** * Returns supported options for Groq chat models. * - * @return array + * @return array */ private function get_text_options(): array { return array( diff --git a/includes/Providers/Groq/GroqProvider.php b/includes/Providers/Groq/GroqProvider.php index 4c13314e..14378fb2 100644 --- a/includes/Providers/Groq/GroqProvider.php +++ b/includes/Providers/Groq/GroqProvider.php @@ -42,6 +42,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported Groq model capabilities: ' . implode( ', ', @@ -53,6 +54,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php b/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php index d3aa85b5..9db06c62 100644 --- a/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php +++ b/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php @@ -51,7 +51,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory(), ); - $options = $this->getTextOptions(); + $options = $this->getTextOptions(); $models = array(); foreach ( $data['data'] as $model ) { @@ -73,7 +73,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array /** * Returns supported options for Hugging Face chat models. * - * @return array + * @return array */ private function getTextOptions(): array { return array( diff --git a/includes/Providers/HuggingFace/HuggingFaceProvider.php b/includes/Providers/HuggingFace/HuggingFaceProvider.php index cb88cb35..e97f5f66 100644 --- a/includes/Providers/HuggingFace/HuggingFaceProvider.php +++ b/includes/Providers/HuggingFace/HuggingFaceProvider.php @@ -42,6 +42,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported Hugging Face model capabilities: ' . implode( ', ', @@ -53,6 +54,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/Ollama/OllamaModelMetadataDirectory.php b/includes/Providers/Ollama/OllamaModelMetadataDirectory.php index c3c99d1a..212782f0 100644 --- a/includes/Providers/Ollama/OllamaModelMetadataDirectory.php +++ b/includes/Providers/Ollama/OllamaModelMetadataDirectory.php @@ -43,9 +43,9 @@ protected function sendListModelsRequest(): array { /** * Parses Ollama tags response. * - * @param Response $response Ollama response. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Ollama response. * - * @return array + * @return array */ private function parseResponse( Response $response ): array { $data = $response->getData(); diff --git a/includes/Providers/Ollama/OllamaProvider.php b/includes/Providers/Ollama/OllamaProvider.php index dad6ae51..258bd0d3 100644 --- a/includes/Providers/Ollama/OllamaProvider.php +++ b/includes/Providers/Ollama/OllamaProvider.php @@ -58,6 +58,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported Ollama model capabilities: ' . implode( ', ', @@ -69,6 +70,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** diff --git a/includes/Providers/Ollama/OllamaTextGenerationModel.php b/includes/Providers/Ollama/OllamaTextGenerationModel.php index 8a5ed52d..9e934d93 100644 --- a/includes/Providers/Ollama/OllamaTextGenerationModel.php +++ b/includes/Providers/Ollama/OllamaTextGenerationModel.php @@ -60,7 +60,7 @@ public function streamGenerateTextResult( array $prompt ): \Generator { /** * Builds the request payload. * - * @param list $prompt Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. * * @return array */ @@ -69,15 +69,17 @@ private function buildPayload( array $prompt ): array { $messages = $this->convertPromptToMessages( $prompt ); if ( empty( $messages ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new InvalidArgumentException( __( 'Ollama chat requests require at least one user message.', 'ai' ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $payload = array( - 'model' => $this->metadata()->getId(), - 'messages'=> $messages, - 'stream' => false, + 'model' => $this->metadata()->getId(), + 'messages' => $messages, + 'stream' => false, ); if ( null !== $config->getTemperature() ) { @@ -100,7 +102,7 @@ private function buildPayload( array $prompt ): array { /** * Converts prompt messages to Ollama format. * - * @param list $prompt Prompt messages. + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. * * @return list */ @@ -113,7 +115,7 @@ private function convertPromptToMessages( array $prompt ): array { continue; } - $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; $messages[] = array( 'role' => $role, 'content' => $text, @@ -126,7 +128,7 @@ private function convertPromptToMessages( array $prompt ): array { /** * Extracts first text part from a message. * - * @param Message $message Message instance. + * @param \WordPress\AiClient\Messages\DTO\Message $message Message instance. * * @return string */ @@ -143,9 +145,9 @@ private function extractTextFromMessage( Message $message ): string { /** * Converts Ollama response to a GenerativeAiResult. * - * @param Response $response Response instance. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Response instance. * - * @return GenerativeAiResult + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponse( Response $response ): GenerativeAiResult { $data = $response->getData(); @@ -176,7 +178,7 @@ private function parseResponse( Response $response ): GenerativeAiResult { /** * Validates response success. * - * @param Response $response Response instance. + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Response instance. * * @return void */ diff --git a/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php index 1c2602fb..4599b586 100644 --- a/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php +++ b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php @@ -46,7 +46,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array throw ResponseException::fromMissingData( 'OpenRouter', 'data' ); } - $options = $this->getTextOptions(); + $options = $this->getTextOptions(); $capabilities = array( CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory(), @@ -72,7 +72,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array /** * Returns supported options for OpenRouter chat models. * - * @return array + * @return array */ private function getTextOptions(): array { return array( diff --git a/includes/Providers/OpenRouter/OpenRouterProvider.php b/includes/Providers/OpenRouter/OpenRouterProvider.php index 7820d0c1..80850a70 100644 --- a/includes/Providers/OpenRouter/OpenRouterProvider.php +++ b/includes/Providers/OpenRouter/OpenRouterProvider.php @@ -42,6 +42,7 @@ protected static function createModel( ModelMetadata $model_metadata, ProviderMe } } + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw new RuntimeException( 'Unsupported OpenRouter model capabilities: ' . implode( ', ', @@ -53,6 +54,7 @@ static function ( $capability ) { ) ) ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** From e2205eee270239cadc41ab35b653faa2d79c3ffd Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:02:04 -0500 Subject: [PATCH 05/15] fix: support WordPress 7.0 core AI client integration --- composer.json | 5 +- composer.lock | 363 ++++++++++--- .../Excerpt_Generation/Excerpt_Generation.php | 4 +- .../Abilities/Image/Alt_Text_Generation.php | 4 +- includes/Abilities/Image/Generate_Image.php | 30 +- .../Abilities/Image/Generate_Image_Prompt.php | 4 +- .../Abilities/Summarization/Summarization.php | 4 +- .../Title_Generation/Title_Generation.php | 26 +- includes/Services/AI_Service.php | 9 +- includes/Settings/Settings_Page.php | 92 +++- includes/bootstrap.php | 480 +++++++++++++++++- includes/helpers.php | 205 +++++++- tests/Integration/Includes/BootstrapTest.php | 135 +++++ tests/Integration/Includes/HelpersTest.php | 56 ++ .../Includes/Services/AI_ServiceTest.php | 25 +- tests/bootstrap.php | 109 +++- 16 files changed, 1405 insertions(+), 146 deletions(-) create mode 100644 tests/Integration/Includes/BootstrapTest.php diff --git a/composer.json b/composer.json index 8fcf66a8..3b3d472a 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,10 @@ "ext-json": "*", "php": ">=7.4", "wordpress/mcp-adapter": "^0.3.0", - "wordpress/wp-ai-client": "^0.2.0" + "wordpress/wp-ai-client": "^0.3.0", + "wordpress/anthropic-ai-provider": "^1.0", + "wordpress/google-ai-provider": "^1.0", + "wordpress/openai-ai-provider": "^1.0" }, "require-dev": { "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index c5b92351..6412c078 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8c7b4ffe92f2ad6287883af4d77ae819", + "content-hash": "10dc5a302fe2921f526e0ac28c2be407", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -286,32 +286,30 @@ "time": "2024-09-23T11:39:58+00:00" }, { - "name": "php-http/message-factory", - "version": "1.1.0", + "name": "php-http/promise", + "version": "1.3.1", "source": { "type": "git", - "url": "https://github.com/php-http/message-factory.git", - "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57" + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57", - "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", "shasum": "" }, "require": { - "php": ">=5.4", - "psr/http-message": "^1.0 || ^2.0" + "php": "^7.1 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" }, + "type": "library", "autoload": { "psr-4": { - "Http\\Message\\": "src/" + "Http\\Promise\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -319,52 +317,52 @@ "MIT" ], "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, { "name": "Márk Sági-Kazár", "email": "mark.sagikazar@gmail.com" } ], - "description": "Factory interfaces for PSR-7 HTTP Message", - "homepage": "http://php-http.org", + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", "keywords": [ - "factory", - "http", - "message", - "stream", - "uri" + "promise" ], "support": { - "issues": "https://github.com/php-http/message-factory/issues", - "source": "https://github.com/php-http/message-factory/tree/1.1.0" + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" }, - "abandoned": "psr/http-factory", - "time": "2023-04-14T14:16:17+00:00" + "time": "2024-03-15T13:55:21+00:00" }, { - "name": "php-http/promise", - "version": "1.3.1", + "name": "psr/event-dispatcher", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/php-http/promise.git", - "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", - "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", - "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + "php": ">=7.2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { "psr-4": { - "Http\\Promise\\": "src/" + "Psr\\EventDispatcher\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -373,24 +371,21 @@ ], "authors": [ { - "name": "Joel Wurtz", - "email": "joel.wurtz@gmail.com" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" } ], - "description": "Promise used for asynchronous HTTP requests", - "homepage": "http://httplug.io", + "description": "Standard interfaces for event handling.", "keywords": [ - "promise" + "events", + "psr", + "psr-14" ], "support": { - "issues": "https://github.com/php-http/promise/issues", - "source": "https://github.com/php-http/promise/tree/1.3.1" + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" }, - "time": "2024-03-15T13:55:21+00:00" + "time": "2019-01-08T18:20:26+00:00" }, { "name": "psr/http-client", @@ -552,6 +547,175 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/master" + }, + "time": "2017-10-23T01:57:42+00:00" + }, + { + "name": "wordpress/anthropic-ai-provider", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/anthropic-ai-provider.git", + "reference": "7abfab9d10f13c2c713484e3f5f096f81ad84b61" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/anthropic-ai-provider/zipball/7abfab9d10f13c2c713484e3f5f096f81ad84b61", + "reference": "7abfab9d10f13c2c713484e3f5f096f81ad84b61", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/php-compatibility": "dev-develop", + "phpstan/phpstan": "~2.1", + "slevomat/coding-standard": "^8.20", + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "wordpress/php-ai-client": "^0.4 || dev-trunk" + }, + "suggest": { + "wordpress/php-ai-client": "Required. The core PHP AI Client SDK that this provider extends." + }, + "type": "wordpress-plugin", + "autoload": { + "psr-4": { + "WordPress\\AnthropicAiProvider\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "Anthropic AI provider for the PHP AI Client SDK. Works as both a Composer package and WordPress plugin.", + "homepage": "https://github.com/WordPress/anthropic-ai-provider", + "keywords": [ + "ai", + "anthropic", + "claude", + "llm", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/anthropic-ai-provider/issues", + "source": "https://github.com/WordPress/anthropic-ai-provider" + }, + "time": "2026-02-12T05:24:09+00:00" + }, + { + "name": "wordpress/google-ai-provider", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/google-ai-provider.git", + "reference": "ce063b2f13e54e2b59c0501c5437d4d7ae049d17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/google-ai-provider/zipball/ce063b2f13e54e2b59c0501c5437d4d7ae049d17", + "reference": "ce063b2f13e54e2b59c0501c5437d4d7ae049d17", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/php-compatibility": "dev-develop", + "phpstan/phpstan": "~2.1", + "slevomat/coding-standard": "^8.20", + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "wordpress/php-ai-client": "^0.4 || dev-trunk" + }, + "suggest": { + "wordpress/php-ai-client": "Required. The core PHP AI Client SDK that this provider extends." + }, + "type": "wordpress-plugin", + "autoload": { + "psr-4": { + "WordPress\\GoogleAiProvider\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "Google AI provider for the PHP AI Client SDK. Works as both a Composer package and WordPress plugin.", + "homepage": "https://github.com/WordPress/google-ai-provider", + "keywords": [ + "Gemini", + "ai", + "google", + "llm", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/google-ai-provider/issues", + "source": "https://github.com/WordPress/google-ai-provider" + }, + "time": "2026-02-12T05:21:47+00:00" + }, { "name": "wordpress/mcp-adapter", "version": "v0.3.0", @@ -626,29 +790,90 @@ }, "time": "2025-11-06T14:56:51+00:00" }, + { + "name": "wordpress/openai-ai-provider", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/openai-ai-provider.git", + "reference": "5db93179e52e9b63ce94c2a339a9cf46d6d51a32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/openai-ai-provider/zipball/5db93179e52e9b63ce94c2a339a9cf46d6d51a32", + "reference": "5db93179e52e9b63ce94c2a339a9cf46d6d51a32", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/php-compatibility": "dev-develop", + "phpstan/phpstan": "~2.1", + "slevomat/coding-standard": "^8.20", + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "wordpress/php-ai-client": "^0.4 || dev-trunk" + }, + "suggest": { + "wordpress/php-ai-client": "Required. The core PHP AI Client SDK that this provider extends." + }, + "type": "wordpress-plugin", + "autoload": { + "psr-4": { + "WordPress\\OpenAiAiProvider\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "OpenAI provider for the PHP AI Client SDK. Works as both a Composer package and WordPress plugin.", + "homepage": "https://github.com/WordPress/openai-ai-provider", + "keywords": [ + "ai", + "gpt", + "llm", + "openai", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/openai-ai-provider/issues", + "source": "https://github.com/WordPress/openai-ai-provider" + }, + "time": "2026-02-12T05:23:02+00:00" + }, { "name": "wordpress/php-ai-client", - "version": "0.3.1", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/WordPress/php-ai-client.git", - "reference": "48cc7de403e00d3035ce2fcb88128dea5e283444" + "reference": "c23867f6eb79028eeda89c6dcfd4467aaa7df14f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/48cc7de403e00d3035ce2fcb88128dea5e283444", - "reference": "48cc7de403e00d3035ce2fcb88128dea5e283444", + "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/c23867f6eb79028eeda89c6dcfd4467aaa7df14f", + "reference": "c23867f6eb79028eeda89c6dcfd4467aaa7df14f", "shasum": "" }, "require": { "ext-json": "*", + "nyholm/psr7": "^1.8", "php": ">=7.4", "php-http/discovery": "^1.0", "php-http/httplug": "^2.0", - "php-http/message-factory": "^1.0", + "psr/event-dispatcher": "^1.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0 || ^2.0" + "psr/http-message": "^1.0 || ^2.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", @@ -659,7 +884,16 @@ "phpstan/phpstan": "~2.1", "phpunit/phpunit": "^9.5 || ^10.0", "slevomat/coding-standard": "^8.20", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "symfony/dotenv": "^5.4", + "wordpress/anthropic-ai-provider": "^1.0", + "wordpress/google-ai-provider": "^1.0", + "wordpress/openai-ai-provider": "^1.0" + }, + "suggest": { + "wordpress/anthropic-ai-provider": "For Anthropic Claude model support", + "wordpress/google-ai-provider": "For Google Gemini model support", + "wordpress/openai-ai-provider": "For OpenAI GPT model support" }, "type": "library", "autoload": { @@ -691,27 +925,28 @@ "issues": "https://github.com/WordPress/php-ai-client/issues", "source": "https://github.com/WordPress/php-ai-client" }, - "time": "2025-12-08T03:41:36+00:00" + "time": "2026-02-20T06:40:54+00:00" }, { "name": "wordpress/wp-ai-client", - "version": "0.2.1", + "version": "0.3.0", "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "3abf133dc8c964672d1012dca070c1cdb6f0ad58" + "reference": "ae559d9c6449f59b7d9a215be310655e131361f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/3abf133dc8c964672d1012dca070c1cdb6f0ad58", - "reference": "3abf133dc8c964672d1012dca070c1cdb6f0ad58", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/ae559d9c6449f59b7d9a215be310655e131361f6", + "reference": "ae559d9c6449f59b7d9a215be310655e131361f6", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7": "^1.5", "php": ">=7.4", - "wordpress/php-ai-client": "^0.3" + "psr/simple-cache": "^1.0", + "wordpress/php-ai-client": "^1.0" }, "require-dev": { "automattic/vipwpcs": "^3.0", @@ -753,7 +988,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-12-04T20:12:45+00:00" + "time": "2026-02-17T02:53:42+00:00" } ], "packages-dev": [ diff --git a/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php b/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php index b24ae6a2..5704dc54 100644 --- a/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php +++ b/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -226,7 +226,7 @@ static function ( $key, $value ) { } // Generate an excerpt using the AI client. - return AI_Client::prompt_with_wp_error( $content ) + return ai_client_prompt_with_wp_error( $content ) ->using_system_instruction( $this->get_system_instruction() ) ->using_temperature( 0.7 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) diff --git a/includes/Abilities/Image/Alt_Text_Generation.php b/includes/Abilities/Image/Alt_Text_Generation.php index a3027f4e..deb3274d 100644 --- a/includes/Abilities/Image/Alt_Text_Generation.php +++ b/includes/Abilities/Image/Alt_Text_Generation.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_preferred_vision_models; use function WordPress\AI\normalize_content; @@ -189,7 +189,7 @@ protected function get_image_reference( array $args ) { * @return string|\WP_Error The generated alt text or WP_Error on failure. */ protected function generate_alt_text( array $image_reference, string $context = '' ) { - $result = AI_Client::prompt_with_wp_error( $this->build_prompt( $context ) ) + $result = ai_client_prompt_with_wp_error( $this->build_prompt( $context ) ) ->with_file( $image_reference['reference'] ) ->using_system_instruction( $this->get_system_instruction( 'alt-text-system-instruction.php' ) ) ->using_temperature( 0.3 ) diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index 2e3eb8d9..7caa563e 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -12,12 +12,12 @@ use Throwable; use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_preferred_image_models; /** @@ -170,7 +170,7 @@ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.Na $request_options->setTimeout( 90 ); // Generate the image using the AI client. - $result = AI_Client::prompt_with_wp_error( $prompt ) + $result = ai_client_prompt_with_wp_error( $prompt ) ->using_request_options( $request_options ) ->as_output_file_type( FileTypeEnum::inline() ) ->using_model_preference( ...get_preferred_image_models() ) @@ -201,13 +201,27 @@ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.Na } // Get details about the provider and model that generated the image. - $data['provider_metadata'] = $result->getProviderMetadata()->toArray(); - $data['model_metadata'] = $result->getModelMetadata()->toArray(); + $provider_raw = $result->getProviderMetadata()->toArray(); + $model_raw = $result->getModelMetadata()->toArray(); + + // Remove data we don't care about. + unset( $provider_raw[ ProviderMetadata::KEY_CREDENTIALS_URL ] ); + unset( $model_raw[ ModelMetadata::KEY_SUPPORTED_OPTIONS ] ); + unset( $model_raw[ ModelMetadata::KEY_SUPPORTED_CAPABILITIES ] ); + + $data['provider_metadata'] = array_map( + static function ( $value ): string { + return (string) $value; + }, + $provider_raw + ); - // Remove data we don't care about. - unset( $data['provider_metadata'][ ProviderMetadata::KEY_CREDENTIALS_URL ] ); - unset( $data['model_metadata'][ ModelMetadata::KEY_SUPPORTED_OPTIONS ] ); - unset( $data['model_metadata'][ ModelMetadata::KEY_SUPPORTED_CAPABILITIES ] ); + $data['model_metadata'] = array_map( + static function ( $value ): string { + return (string) $value; + }, + $model_raw + ); } catch ( Throwable $t ) { return new WP_Error( 'no_image_data', diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php index b7b6706d..c19898c5 100644 --- a/includes/Abilities/Image/Generate_Image_Prompt.php +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -243,7 +243,7 @@ static function ( $key, $value ) { } // Generate the prompt using the AI client. - return AI_Client::prompt_with_wp_error( $content ) + return ai_client_prompt_with_wp_error( $content ) ->using_system_instruction( $this->get_system_instruction( 'image-prompt-system-instruction.php' ) ) ->using_temperature( 0.9 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) diff --git a/includes/Abilities/Summarization/Summarization.php b/includes/Abilities/Summarization/Summarization.php index 8d7f7b32..e6516475 100644 --- a/includes/Abilities/Summarization/Summarization.php +++ b/includes/Abilities/Summarization/Summarization.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -243,7 +243,7 @@ static function ( $key, $value ) { } // Generate the summary using the AI client. - return AI_Client::prompt_with_wp_error( $content ) + return ai_client_prompt_with_wp_error( $content ) ->using_system_instruction( $this->get_system_instruction( 'system-instruction.php', array( 'length' => $length ) ) ) ->using_temperature( 0.9 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index a8eda67e..31f5ade0 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -262,12 +262,22 @@ static function ( $key, $value ) { ); } - // Generate the titles using the AI client. - return AI_Client::prompt_with_wp_error( '"""' . $context . '"""' ) - ->using_system_instruction( $this->get_system_instruction() ) - ->using_temperature( 0.7 ) - ->using_candidate_count( (int) $candidates ) - ->using_model_preference( ...get_preferred_models_for_text_generation() ) - ->generate_texts(); + // Generate one title per request for broader provider compatibility. + $results = array(); + for ( $i = 0; $i < $candidates; $i++ ) { + $result = ai_client_prompt_with_wp_error( '"""' . $context . '"""' ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( 0.7 ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $results[] = $result; + } + + return $results; } } diff --git a/includes/Services/AI_Service.php b/includes/Services/AI_Service.php index 9fb2ec45..762ae733 100644 --- a/includes/Services/AI_Service.php +++ b/includes/Services/AI_Service.php @@ -11,10 +11,9 @@ namespace WordPress\AI\Services; -use WordPress\AI_Client\AI_Client; -use WordPress\AI_Client\Builders\Prompt_Builder_With_WP_Error; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_preferred_models_for_text_generation; /** @@ -121,10 +120,10 @@ private function __construct() {} * @type bool $logprobs Whether to return log probabilities. * @type int $top_logprobs Top log probabilities to return. * } - * @return \WordPress\AI_Client\Builders\Prompt_Builder_With_WP_Error The prompt builder instance. + * @return mixed Prompt builder instance. */ - public function create_textgen_prompt( ?string $prompt = null, array $options = array() ): Prompt_Builder_With_WP_Error { - $builder = AI_Client::prompt_with_wp_error( $prompt ); + public function create_textgen_prompt( ?string $prompt = null, array $options = array() ) { + $builder = ai_client_prompt_with_wp_error( $prompt ); // Apply default model preferences. $models = get_preferred_models_for_text_generation(); diff --git a/includes/Settings/Settings_Page.php b/includes/Settings/Settings_Page.php index 07ce1de8..837048d8 100644 --- a/includes/Settings/Settings_Page.php +++ b/includes/Settings/Settings_Page.php @@ -15,6 +15,7 @@ use WordPress\AI\Experiment_Category; use WordPress\AI\Experiment_Registry; +use function WordPress\AI\get_ai_provider_api_key_variable_names; use function WordPress\AI\has_ai_credentials; use function WordPress\AI\has_valid_ai_credentials; @@ -116,6 +117,45 @@ public function enqueue_styles(): void { Asset_Loader::enqueue_style( 'experiments-settings', 'admin/settings' ); } + /** + * Returns the AI credentials settings URL if available. + * + * @since 0.1.0 + * + * @return string|null The URL to the credentials settings screen, or null. + */ + private function get_ai_credentials_settings_url(): ?string { + return \WordPress\AI\get_ai_credentials_settings_url(); + } + + /** + * Returns fallback help text for configuring AI credentials without a UI screen. + * + * @since 0.1.0 + * + * @return string Human-readable setup help. + */ + private function get_ai_credentials_configuration_help(): string { + $variable_names = get_ai_provider_api_key_variable_names(); + $example_names = array_slice( $variable_names, 0, 3 ); + + if ( empty( $example_names ) ) { + return __( + 'Set provider API keys via environment variables or constants in wp-config.php.', + 'ai' + ); + } + + return sprintf( + /* translators: %s: Comma-separated credential variable names. */ + __( + 'Set provider API keys via environment variables or constants in wp-config.php, for example: %s.', + 'ai' + ), + implode( ', ', $example_names ) + ); + } + /** * Renders the settings page. * @@ -134,23 +174,49 @@ public function render_page(): void {

here.', 'ai' ), - admin_url( 'options-general.php?page=wp-ai-client' ) - ); + $credentials_settings_url = $this->get_ai_credentials_settings_url(); + + if ( is_string( $credentials_settings_url ) && '' !== $credentials_settings_url ) { + if ( ! has_ai_credentials() ) { + $warning_message = sprintf( + /* translators: 1: Link to the AI credentials settings page. */ + __( 'AI credentials are not configured yet. AI features may not work until you add one or more credentials here.', 'ai' ), + esc_url( $credentials_settings_url ) + ); + } else { + $warning_message = sprintf( + /* translators: 1: Link to the AI credentials settings page. */ + __( 'AI credentials appear invalid. AI features may fail until you update them here.', 'ai' ), + esc_url( $credentials_settings_url ) + ); + } } else { - $error_message = sprintf( - /* translators: 1: Link to the AI credentials settings page. */ - __( 'Most experiments require valid AI credentials to function properly. Please review the AI credentials you have set to ensure they are valid.', 'ai' ), - admin_url( 'options-general.php?page=wp-ai-client' ) - ); + $configuration_help = $this->get_ai_credentials_configuration_help(); + + if ( ! has_ai_credentials() ) { + $warning_message = sprintf( + /* translators: %s: Credential setup guidance. */ + __( + 'AI credentials are not configured yet. AI features may not work until credentials are added. This WordPress build does not currently expose an AI credentials settings screen. %s', + 'ai' + ), + $configuration_help + ); + } else { + $warning_message = sprintf( + /* translators: %s: Credential setup guidance. */ + __( + 'AI credentials appear invalid. AI features may fail until credentials are corrected. This WordPress build does not currently expose an AI credentials settings screen. %s', + 'ai' + ), + $configuration_help + ); + } } - wp_admin_notice( $error_message, array( 'type' => 'error' ) ); + wp_admin_notice( $warning_message, array( 'type' => 'warning' ) ); } ?> diff --git a/includes/bootstrap.php b/includes/bootstrap.php index b4f6a7a8..f7256335 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -142,10 +142,456 @@ function display_composer_notice(): void { hasProperty( 'psr4_map' ) ) { + $psr4_property = $reflection->getProperty( 'psr4_map' ); + $psr4_property->setAccessible( true ); + $psr4_map = $psr4_property->getValue( $jetpack_autoloader_loader ); + + if ( is_array( $psr4_map ) ) { + foreach ( $psr4_map as $prefix => $data ) { + if ( ! is_array( $data ) || ! isset( $data['path'] ) || ! is_array( $data['path'] ) ) { + continue; + } + + $data['path'] = array_values( + array_filter( + $data['path'], + static function ( $path ) use ( $path_uses_bundled_package ): bool { + return ! $path_uses_bundled_package( $path ); + } + ) + ); + + if ( empty( $data['path'] ) ) { + unset( $psr4_map[ $prefix ] ); + continue; + } + + $psr4_map[ $prefix ] = $data; + } + + $psr4_property->setValue( $jetpack_autoloader_loader, $psr4_map ); + } + } + + if ( $reflection->hasProperty( 'classmap' ) ) { + $classmap_property = $reflection->getProperty( 'classmap' ); + $classmap_property->setAccessible( true ); + $classmap = $classmap_property->getValue( $jetpack_autoloader_loader ); + + if ( is_array( $classmap ) ) { + foreach ( $classmap as $class => $data ) { + if ( ! is_array( $data ) || ! isset( $data['path'] ) ) { + continue; + } + + if ( ! $path_uses_bundled_package( $data['path'] ) ) { + continue; + } + + unset( $classmap[ $class ] ); + } + + $classmap_property->setValue( $jetpack_autoloader_loader, $classmap ); + } + } + + if ( $reflection->hasProperty( 'filemap' ) ) { + $filemap_property = $reflection->getProperty( 'filemap' ); + $filemap_property->setAccessible( true ); + $filemap = $filemap_property->getValue( $jetpack_autoloader_loader ); + + if ( is_array( $filemap ) ) { + foreach ( $filemap as $identifier => $data ) { + if ( ! is_array( $data ) || ! isset( $data['path'] ) ) { + continue; + } + + if ( ! $path_uses_bundled_package( $data['path'] ) ) { + continue; + } + + unset( $filemap[ $identifier ] ); + } + + $filemap_property->setValue( $jetpack_autoloader_loader, $filemap ); + } + } + } catch ( \Throwable $t ) { + _doing_it_wrong( + __NAMESPACE__ . '\maybe_disable_bundled_wp_ai_client_packages', + sprintf( + /* translators: %s: Error message. */ + esc_html__( 'Could not adjust bundled WP AI Client autoloading: %s', 'ai' ), + esc_html( $t->getMessage() ) + ), + '0.1.0' + ); + } +} + +/** + * Registers available provider implementation classes with the default AI registry. + * + * This ensures provider classes are registered even when core ships the client API + * but does not automatically register built-in providers. + * + * @since 0.3.1 + * + * @return void + */ +function maybe_register_available_ai_client_providers(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + return; + } + + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + } catch ( \Throwable $t ) { + return; + } + + $provider_candidates = array( + 'anthropic' => array( + '\WordPress\AnthropicAiProvider\Provider\AnthropicProvider', + '\WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider', + ), + 'google' => array( + '\WordPress\GoogleAiProvider\Provider\GoogleProvider', + '\WordPress\AiClient\ProviderImplementations\Google\GoogleProvider', + ), + 'openai' => array( + '\WordPress\OpenAiAiProvider\Provider\OpenAiProvider', + '\WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider', + ), + ); + + /** + * Filters provider implementation class name candidates by provider ID. + * + * @since 0.3.1 + * + * @param array|string> $provider_candidates Provider class candidates keyed by provider ID. + * @return array|string> Filtered provider class candidates. + */ + $provider_candidates = (array) apply_filters( 'ai_experiments_ai_client_provider_classes', $provider_candidates ); + + foreach ( $provider_candidates as $provider_id => $candidate_classes ) { + try { + if ( is_string( $provider_id ) && '' !== $provider_id && $registry->hasProvider( $provider_id ) ) { + continue; + } + } catch ( \Throwable $t ) { + continue; + } + + if ( is_string( $candidate_classes ) && '' !== $candidate_classes ) { + $candidate_classes = array( $candidate_classes ); + } + + if ( ! is_array( $candidate_classes ) ) { + continue; + } + + $provider_class = ''; + foreach ( $candidate_classes as $candidate_class_raw ) { + $candidate_class = trim( $candidate_class_raw ); + if ( '' === $candidate_class ) { + continue; + } + + if ( ! class_exists( $candidate_class ) ) { + continue; + } + + $provider_class = $candidate_class; + break; + } + + if ( '' === $provider_class ) { + continue; + } + + if ( ! is_subclass_of( $provider_class, '\WordPress\AiClient\Providers\Contracts\ProviderInterface' ) ) { + continue; + } + + try { + if ( $registry->hasProvider( $provider_class ) ) { + continue; + } + + $registry->registerProvider( $provider_class ); + } catch ( \Throwable $t ) { + _doing_it_wrong( + __NAMESPACE__ . '\maybe_register_available_ai_client_providers', + sprintf( + /* translators: 1: Provider class name. 2: Error message. */ + esc_html__( 'Could not register AI provider class %1$s: %2$s', 'ai' ), + esc_html( $provider_class ), + esc_html( $t->getMessage() ) + ), + '0.3.1' + ); + } + } +} + +/** + * Applies legacy option-based credentials to core AI providers when needed. + * + * WordPress 6.9 with bundled WP AI Client stores credentials in the + * `wp_ai_client_provider_credentials` option. WordPress 7.0 core currently + * resolves provider credentials from environment variables/constants by default. + * This bridge preserves existing installs by re-applying saved option values. + * + * @since 0.3.1 + * + * @return void + */ +function maybe_apply_option_credentials_to_core_ai_client(): void { + if ( should_use_bundled_wp_ai_client() ) { + return; + } + + /** + * Filters whether to apply option-based credentials to the core AI client. + * + * @since 0.3.1 + * + * @param bool $should_apply True to apply option credentials to core providers. + * @return bool Whether to apply option-based credentials. + */ + $should_apply = apply_filters( 'ai_experiments_apply_option_credentials_to_core_ai_client', true ); + if ( ! $should_apply ) { + return; + } + + if ( + ! class_exists( '\WordPress\AiClient\AiClient' ) || + ! class_exists( '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication' ) || + ! interface_exists( '\WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface' ) + ) { + return; + } + + $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + if ( ! is_array( $credentials ) || empty( $credentials ) ) { + return; + } + + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + + foreach ( $credentials as $provider_id => $api_key ) { + if ( ! is_string( $provider_id ) || '' === $provider_id ) { + continue; + } + + if ( ! is_string( $api_key ) || '' === trim( $api_key ) ) { + continue; + } + + if ( ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + $authentication_class = '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication'; + + try { + $provider_class = $registry->getProviderClassName( $provider_id ); + if ( + class_exists( $provider_class ) && + method_exists( $provider_class, 'metadata' ) + ) { + $authentication_method = $provider_class::metadata()->getAuthenticationMethod(); + if ( null !== $authentication_method ) { + $candidate_authentication_class = $authentication_method->getImplementationClass(); + if ( + is_string( $candidate_authentication_class ) && + class_exists( $candidate_authentication_class ) && + is_subclass_of( $candidate_authentication_class, '\WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface' ) + ) { + $authentication_class = $candidate_authentication_class; + } + } + } + } catch ( \Throwable $t ) { + $authentication_class = '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication'; + } + + $api_key = trim( $api_key ); + $authentication_instance = null; + + if ( is_subclass_of( $authentication_class, '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication' ) ) { + $authentication_instance = $authentication_class::fromArray( + array( + 'apiKey' => $api_key, + ) + ); + } + + if ( ! $authentication_instance instanceof \WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface ) { + $authentication_instance = new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ); + } + + $registry->setProviderRequestAuthentication( $provider_id, $authentication_instance ); + } + } catch ( \Throwable $t ) { + _doing_it_wrong( + __NAMESPACE__ . '\maybe_apply_option_credentials_to_core_ai_client', + sprintf( + /* translators: %s: Error message. */ + esc_html__( 'Could not apply saved AI credentials to core providers: %s', 'ai' ), + esc_html( $t->getMessage() ) + ), + '0.3.1' + ); + } +} + +/** + * Returns the AI credentials settings URL if available. + * + * @since 0.3.1 + * + * @return string|null The URL to the credentials settings screen, or null. + */ +function get_ai_credentials_settings_url(): ?string { + /** + * Filters the AI credentials settings URL. + * + * @since 0.3.1 + * + * @param string|null $url URL to the credentials settings screen, or null if unavailable. + * @return string|null The URL to use, or null if unavailable. + */ + $url = apply_filters( 'ai_experiments_credentials_settings_url', null ); + if ( is_string( $url ) && '' !== $url ) { + return $url; + } + + $settings_slug = 'wp-ai-client'; + + // If any plugin or core registered the AI credentials screen, use it. + global $_parent_pages, $submenu; + if ( + ( + is_array( $_parent_pages ) && + isset( $_parent_pages[ $settings_slug ] ) + ) || + ( + is_array( $submenu ) && + isset( $submenu['options-general.php'] ) && + is_array( $submenu['options-general.php'] ) && + in_array( + $settings_slug, + array_map( + static function ( $submenu_item ): string { + return isset( $submenu_item[2] ) && is_string( $submenu_item[2] ) ? $submenu_item[2] : ''; + }, + $submenu['options-general.php'] + ), + true + ) + ) + ) { + return admin_url( 'options-general.php?page=' . $settings_slug ); + } + + // The bundled package registers this settings page slug. + if ( should_use_bundled_wp_ai_client() ) { + return admin_url( 'options-general.php?page=' . $settings_slug ); + } + + return null; +} + /** * Adds action links to the plugin list table. * - * This adds "Experiments" and "Credentials" links to + * This adds "Experiments" and (when available) "Credentials" links to * the plugin's action links on the Plugins page. * * @since 0.1.1 @@ -159,14 +605,17 @@ function plugin_action_links( array $links ): array { admin_url( 'options-general.php?page=ai-experiments' ), esc_html__( 'Experiments', 'ai' ) ); + array_unshift( $links, $experiments_link ); - $credentials_link = sprintf( - '%2$s', - admin_url( 'options-general.php?page=wp-ai-client' ), - esc_html__( 'Credentials', 'ai' ) - ); - - array_unshift( $links, $credentials_link, $experiments_link ); + $credentials_url = get_ai_credentials_settings_url(); + if ( is_string( $credentials_url ) && '' !== $credentials_url ) { + $credentials_link = sprintf( + '%2$s', + esc_url( $credentials_url ), + esc_html__( 'Credentials', 'ai' ) + ); + array_unshift( $links, $credentials_link ); + } return $links; } @@ -195,6 +644,7 @@ function load(): void { return; } require_once AI_EXPERIMENTS_PLUGIN_DIR . 'vendor/autoload_packages.php'; + maybe_disable_bundled_wp_ai_client_packages(); $loaded = true; @@ -212,8 +662,15 @@ function load(): void { */ function initialize_experiments(): void { try { - // Wire up the WordPress HTTP client first (needed by provider registration). - WP_AI_Client_Discovery_Strategy::init(); + // Ensure default providers are registered across core and bundled client combinations. + maybe_register_available_ai_client_providers(); + + // Initialize bundled WP AI Client when not relying on core. + if ( should_use_bundled_wp_ai_client() && class_exists( AI_Client::class ) ) { + AI_Client::init(); + } + + maybe_apply_option_credentials_to_core_ai_client(); // Initialize experiments so extended providers are registered // before the WP AI Client collects provider metadata. @@ -222,9 +679,6 @@ function initialize_experiments(): void { $loader->register_default_experiments(); $loader->initialize_experiments(); - // Initialize the WP AI Client (collects providers for credentials screen). - AI_Client::init(); - // Initialize settings registration. $settings_registration = new Settings_Registration( $registry ); $settings_registration->init(); diff --git a/includes/helpers.php b/includes/helpers.php index 9946ed71..b3267b9a 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -295,6 +295,182 @@ function get_preferred_vision_models(): array { return (array) apply_filters( 'ai_experiments_preferred_vision_models', $preferred_models ); } +/** + * Returns a prompt builder with WP_Error error handling. + * + * Uses the core AI Client API when available, otherwise falls back to the + * bundled WP AI Client package. + * + * @since 0.1.0 + * + * @param mixed $prompt Optional initial prompt content. + * @return mixed Prompt builder instance. + */ +function ai_client_prompt_with_wp_error( $prompt = null ) { + if ( ! should_use_bundled_wp_ai_client() && function_exists( 'wp_ai_client_prompt' ) ) { + return wp_ai_client_prompt( $prompt ); + } + + if ( class_exists( AI_Client::class ) ) { + return AI_Client::prompt_with_wp_error( $prompt ); + } + + throw new \RuntimeException( 'No AI Client prompt builder is available.' ); +} + +/** + * Returns a prompt builder. + * + * Uses the core AI Client API when available, otherwise falls back to the + * bundled WP AI Client package. + * + * @since 0.1.0 + * + * @param mixed $prompt Optional initial prompt content. + * @return mixed Prompt builder instance. + */ +function ai_client_prompt( $prompt = null ) { + if ( ! should_use_bundled_wp_ai_client() && function_exists( 'wp_ai_client_prompt' ) ) { + return wp_ai_client_prompt( $prompt ); + } + + if ( class_exists( AI_Client::class ) ) { + return AI_Client::prompt( $prompt ); + } + + throw new \RuntimeException( 'No AI Client prompt builder is available.' ); +} + +/** + * Converts a value to CONSTANT_CASE. + * + * @since 0.1.0 + * + * @param string $value Value to convert. + * @return string CONSTANT_CASE value. + */ +function to_constant_case( string $value ): string { + $value = str_replace( '-', '_', $value ); + $value = preg_replace( '/([a-z])([A-Z])/', '$1_$2', $value ); + return strtoupper( (string) $value ); +} + +/** + * Returns registered provider IDs for the AI client. + * + * @since 0.1.0 + * + * @return array Provider IDs. + */ +function get_ai_provider_ids(): array { + $provider_ids = array(); + + if ( class_exists( '\WordPress\AiClient\AiClient' ) ) { + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $provider_ids = array_filter( + $registry->getRegisteredProviderIds(), + static function ( string $provider_id ): bool { + return '' !== $provider_id; + } + ); + } catch ( Throwable $t ) { + $provider_ids = array(); + } + } + + $stored_credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + if ( is_array( $stored_credentials ) ) { + $provider_ids = array_merge( $provider_ids, array_keys( $stored_credentials ) ); + } + + $provider_ids = array_values( + array_unique( + array_filter( + $provider_ids, + static function ( $provider_id ): bool { + return is_string( $provider_id ) && '' !== $provider_id; + } + ) + ) + ); + + // Fall back to known cloud providers if IDs cannot be resolved at runtime. + if ( empty( $provider_ids ) ) { + $provider_ids = array( 'openai', 'anthropic', 'google' ); + } + + /** + * Filters AI provider IDs used for credential discovery. + * + * @since 0.1.0 + * @hook ai_experiments_provider_ids + * + * @param array $provider_ids Provider IDs. + * @return array Filtered provider IDs. + */ + return (array) apply_filters( 'ai_experiments_provider_ids', $provider_ids ); +} + +/** + * Returns provider API key environment variable / constant names. + * + * The default format follows the core/provider registry convention: + * `_API_KEY`. + * + * @since 0.1.0 + * + * @return array Environment variable / constant names. + */ +function get_ai_provider_api_key_variable_names(): array { + $variable_names = array_map( + static function ( string $provider_id ): string { + return to_constant_case( $provider_id ) . '_API_KEY'; + }, + get_ai_provider_ids() + ); + + $variable_names = array_values( array_unique( $variable_names ) ); + + /** + * Filters provider API key variable names used for credential discovery. + * + * @since 0.1.0 + * @hook ai_experiments_provider_api_key_variable_names + * + * @param array $variable_names Variable names. + * @return array Filtered variable names. + */ + return (array) apply_filters( 'ai_experiments_provider_api_key_variable_names', $variable_names ); +} + +/** + * Checks whether any provider API key exists in env vars or constants. + * + * @since 0.1.0 + * + * @return bool True if an API key variable is set, otherwise false. + */ +function has_ai_credentials_in_environment(): bool { + foreach ( get_ai_provider_api_key_variable_names() as $variable_name ) { + $env_value = getenv( $variable_name ); + if ( false !== $env_value && '' !== trim( (string) $env_value ) ) { + return true; + } + + if ( ! defined( $variable_name ) ) { + continue; + } + + $constant_value = constant( $variable_name ); + if ( is_scalar( $constant_value ) && '' !== trim( (string) $constant_value ) ) { + return true; + } + } + + return false; +} + /** * Checks if we have AI credentials set. * @@ -305,20 +481,23 @@ function get_preferred_vision_models(): array { function has_ai_credentials(): bool { $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); - // If there are no credentials, return false. - if ( ! is_array( $credentials ) || empty( $credentials ) ) { - return false; + if ( is_array( $credentials ) && ! empty( $credentials ) ) { + // If all of the AI keys are empty, return false; otherwise, return true. + $has_option_credentials = ! empty( + array_filter( + $credentials, + static function ( $api_key ): bool { + return is_string( $api_key ) && '' !== trim( $api_key ); + } + ) + ); + + if ( $has_option_credentials ) { + return true; + } } - // If all of the AI keys are empty, return false; otherwise, return true. - return ! empty( - array_filter( - $credentials, - static function ( $api_key ): bool { - return is_string( $api_key ) && '' !== $api_key; - } - ) - ); + return has_ai_credentials_in_environment(); } /** @@ -351,7 +530,7 @@ function has_valid_ai_credentials(): bool { // See if we have credentials that give us access to generate text. try { - return AI_Client::prompt( 'Test' )->is_supported_for_text_generation(); + return ai_client_prompt( 'Test' )->is_supported_for_text_generation(); } catch ( Throwable $t ) { return false; } diff --git a/tests/Integration/Includes/BootstrapTest.php b/tests/Integration/Includes/BootstrapTest.php new file mode 100644 index 00000000..0714ebe1 --- /dev/null +++ b/tests/Integration/Includes/BootstrapTest.php @@ -0,0 +1,135 @@ +original_credentials_option = get_option( 'wp_ai_client_provider_credentials', null ); + } + + /** + * Tears down test fixture. + * + * @since 0.3.1 + */ + public function tearDown(): void { + remove_all_filters( 'ai_experiments_credentials_settings_url' ); + + if ( null === $this->original_credentials_option ) { + delete_option( 'wp_ai_client_provider_credentials' ); + } else { + update_option( 'wp_ai_client_provider_credentials', $this->original_credentials_option ); + } + + parent::tearDown(); + } + + /** + * Tests that credentials settings URL can be provided by filter. + * + * @since 0.3.1 + */ + public function test_get_ai_credentials_settings_url_uses_filter(): void { + add_filter( + 'ai_experiments_credentials_settings_url', + static function (): string { + return 'https://example.com/credentials'; + } + ); + + $this->assertSame( + 'https://example.com/credentials', + \WordPress\AI\get_ai_credentials_settings_url() + ); + } + + /** + * Tests that core provider registration runs without requiring bundled mode. + * + * @since 0.3.1 + */ + public function test_maybe_register_available_ai_client_providers(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + $this->markTestSkipped( 'AI Client is not available.' ); + } + + \WordPress\AI\maybe_register_available_ai_client_providers(); + + $provider_ids = \WordPress\AiClient\AiClient::defaultRegistry()->getRegisteredProviderIds(); + $this->assertIsArray( $provider_ids ); + } + + /** + * Tests that saved option credentials are applied to the AI client registry. + * + * @since 0.3.1 + */ + public function test_maybe_apply_option_credentials_to_core_ai_client(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + $this->markTestSkipped( 'AI Client is not available.' ); + } + + if ( \WordPress\AI\should_use_bundled_wp_ai_client() ) { + $this->markTestSkipped( 'Bundled WP AI Client mode does not use the core credential bridge.' ); + } + + \WordPress\AI\maybe_register_available_ai_client_providers(); + + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $provider_ids = $registry->getRegisteredProviderIds(); + if ( ! in_array( 'openai', $provider_ids, true ) ) { + $this->markTestSkipped( 'OpenAI provider is not registered in this environment.' ); + } + + update_option( + 'wp_ai_client_provider_credentials', + array( + 'openai' => 'sk-bootstrap-test-key', + ) + ); + + \WordPress\AI\maybe_apply_option_credentials_to_core_ai_client(); + + $authentication = $registry->getProviderRequestAuthentication( 'openai' ); + if ( null === $authentication ) { + $this->markTestSkipped( 'Core registry did not retain provider authentication in this test environment.' ); + } + + $this->assertTrue( + $authentication instanceof \WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface + ); + + if ( ! method_exists( $authentication, 'getApiKey' ) ) { + return; + } + + $this->assertSame( 'sk-bootstrap-test-key', $authentication->getApiKey() ); + } +} diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index af5976ca..6b5409c9 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -575,4 +575,60 @@ function( $models ) { remove_all_filters( 'ai_experiments_preferred_vision_models' ); } + + /** + * Test that environment credentials are detected via provider variable filter. + * + * @since 0.3.1 + */ + public function test_has_ai_credentials_in_environment_with_filtered_variable_names() { + $constant_name = 'AI_EXPERIMENTS_TEST_ENV_KEY'; + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'unit-test-key' ); + } + + add_filter( + 'ai_experiments_provider_api_key_variable_names', + static function () use ( $constant_name ): array { + return array( $constant_name ); + } + ); + + $this->assertTrue( \WordPress\AI\has_ai_credentials_in_environment() ); + + remove_all_filters( 'ai_experiments_provider_api_key_variable_names' ); + } + + /** + * Test that has_ai_credentials() falls back to environment credentials. + * + * @since 0.3.1 + */ + public function test_has_ai_credentials_falls_back_to_environment() { + $constant_name = 'AI_EXPERIMENTS_TEST_FALLBACK_KEY'; + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'fallback-key' ); + } + + $original = get_option( 'wp_ai_client_provider_credentials', null ); + + add_filter( + 'ai_experiments_provider_api_key_variable_names', + static function () use ( $constant_name ): array { + return array( $constant_name ); + } + ); + + update_option( 'wp_ai_client_provider_credentials', array() ); + + $this->assertTrue( \WordPress\AI\has_ai_credentials() ); + + remove_all_filters( 'ai_experiments_provider_api_key_variable_names' ); + + if ( null === $original ) { + delete_option( 'wp_ai_client_provider_credentials' ); + } else { + update_option( 'wp_ai_client_provider_credentials', $original ); + } + } } diff --git a/tests/Integration/Includes/Services/AI_ServiceTest.php b/tests/Integration/Includes/Services/AI_ServiceTest.php index 2ef24331..d6ea4162 100644 --- a/tests/Integration/Includes/Services/AI_ServiceTest.php +++ b/tests/Integration/Includes/Services/AI_ServiceTest.php @@ -9,7 +9,6 @@ use WP_UnitTestCase; use WordPress\AI\Services\AI_Service; -use WordPress\AI_Client\Builders\Prompt_Builder_With_WP_Error; use function WordPress\AI\get_ai_service; @@ -78,10 +77,14 @@ public function test_get_ai_service_helper_returns_instance(): void { public function test_create_textgen_prompt_returns_builder(): void { $builder = $this->service->create_textgen_prompt( 'Test prompt' ); - $this->assertInstanceOf( - Prompt_Builder_With_WP_Error::class, - $builder, - 'Should return Prompt_Builder_With_WP_Error instance' + $acceptable_classes = array( + 'WordPress\\AI_Client\\Builders\\Prompt_Builder_With_WP_Error', + 'WP_AI_Client_Prompt_Builder', + ); + + $this->assertTrue( + is_object( $builder ) && in_array( get_class( $builder ), $acceptable_classes, true ), + 'Should return a supported prompt builder object' ); } @@ -100,10 +103,14 @@ public function test_create_textgen_prompt_with_options(): void { ) ); - $this->assertInstanceOf( - Prompt_Builder_With_WP_Error::class, - $builder, - 'Should return Prompt_Builder_With_WP_Error instance with options applied' + $acceptable_classes = array( + 'WordPress\\AI_Client\\Builders\\Prompt_Builder_With_WP_Error', + 'WP_AI_Client_Prompt_Builder', + ); + + $this->assertTrue( + is_object( $builder ) && in_array( get_class( $builder ), $acceptable_classes, true ), + 'Should return a supported prompt builder object with options applied' ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 352b49e9..02f2687b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,6 +7,108 @@ define( 'TESTS_REPO_ROOT_DIR', dirname( __DIR__ ) ); +if ( ! defined( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH' ) ) { + define( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH', TESTS_REPO_ROOT_DIR . '/vendor/yoast/phpunit-polyfills' ); +} + +/** + * Preloads core AI client contracts to avoid test bootstrap interface conflicts. + * + * PHPUnit is invoked through Composer, which registers the plugin's Composer + * autoloader before WordPress core loads. On WordPress trunk/7.0, core ships a + * scoped AI client contract for `ClientWithOptionsInterface`. If that interface + * is first loaded from Composer dependencies, signature mismatch fatals can + * occur when core classes are declared. + * + * @return void + */ +function wp_ai_maybe_preload_core_ai_client_contracts(): void { + $core_roots = array( + '/var/www/html/wp-includes', + TESTS_REPO_ROOT_DIR . '/../../../../wp-includes', + TESTS_REPO_ROOT_DIR . '/../../../../../wp-includes', + ); + + $core_autoload_path = ''; + foreach ( $core_roots as $core_root ) { + $autoload_path = rtrim( $core_root, '/\\' ) . '/php-ai-client/autoload.php'; + if ( ! file_exists( $autoload_path ) ) { + continue; + } + + $core_autoload_path = $autoload_path; + break; + } + + if ( '' === $core_autoload_path ) { + return; + } + + require_once $core_autoload_path; + + // Move the core AI client autoloader ahead of Composer's autoloader for tests. + $autoloaders = spl_autoload_functions(); + if ( ! is_array( $autoloaders ) ) { + return; + } + + foreach ( $autoloaders as $autoloader ) { + if ( ! $autoloader instanceof \Closure ) { + continue; + } + + $reflection = new \ReflectionFunction( $autoloader ); + $file_name = $reflection->getFileName(); + if ( ! is_string( $file_name ) ) { + continue; + } + + $normalized_file_name = str_replace( '\\', '/', $file_name ); + $normalized_core_autoload_path = str_replace( '\\', '/', $core_autoload_path ); + if ( false === strpos( $normalized_file_name, $normalized_core_autoload_path ) ) { + continue; + } + + spl_autoload_unregister( $autoloader ); + spl_autoload_register( $autoloader, true, true ); + break; + } + + // Preload key classes/contracts from core to avoid mixed-version declarations. + $symbols_to_preload = array( + array( + 'type' => 'class', + 'name' => '\WordPress\AiClient\AiClient', + ), + array( + 'type' => 'interface', + 'name' => '\WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface', + ), + array( + 'type' => 'class', + 'name' => '\WordPress\AiClient\Providers\Http\Abstracts\AbstractClientDiscoveryStrategy', + ), + ); + + foreach ( $symbols_to_preload as $symbol ) { + if ( ! isset( $symbol['type'], $symbol['name'] ) || ! is_string( $symbol['type'] ) || ! is_string( $symbol['name'] ) ) { + continue; + } + + if ( 'interface' === $symbol['type'] ) { + interface_exists( $symbol['name'] ); + continue; + } + + if ( 'class' === $symbol['type'] ) { + class_exists( $symbol['name'] ); + continue; + } + } +} + +wp_ai_maybe_preload_core_ai_client_contracts(); + /** * Check if WordPress core has the Abilities API (e.g., in trunk). * @@ -38,10 +140,9 @@ function wp_ai_has_core_abilities_api(): bool { require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php'; } -// Load Composer dependencies if applicable. -if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/autoload.php' ) ) { - require_once TESTS_REPO_ROOT_DIR . '/vendor/autoload.php'; -} +// Do not load Composer's regular autoloader in this bootstrap. +// The plugin itself loads Jetpack autoloader from ai.php, and loading Composer +// here can preload conflicting classes when core provides AI client packages. // Load Abilities API bootstrap for functions. // Only load from vendor if WordPress core doesn't already include it. From fcae67f38676323800fdf875240bbffa67ef2656 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:22:58 -0500 Subject: [PATCH 06/15] fix: guard AI capability callback in plugin-check environments --- includes/bootstrap.php | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/includes/bootstrap.php b/includes/bootstrap.php index f7256335..9038ab60 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -531,6 +531,46 @@ class_exists( $candidate_authentication_class ) && } } +/** + * Removes invalid AI client capability callbacks from the `user_has_cap` hook. + * + * Plugin Check currently ships an older wp-ai-client package, which can load a + * Capabilities_Manager class without newer methods expected by wp-ai-client + * 0.3+. If that class is loaded first, this callback becomes invalid and can + * fatally error when capabilities are evaluated. + * + * @since 0.3.1 + * + * @return void + */ +function maybe_remove_invalid_ai_client_capability_callbacks(): void { + $capabilities_manager_class = (string) apply_filters( + 'ai_experiments_ai_client_capabilities_manager_class', + '\WordPress\AI_Client\Capabilities\Capabilities_Manager' + ); + + if ( '' === $capabilities_manager_class ) { + return; + } + + $callback = array( + ltrim( $capabilities_manager_class, '\\' ), + 'grant_list_ai_providers_models_to_administrators', + ); + + if ( ! has_filter( 'user_has_cap', $callback ) ) { + return; + } + + remove_filter( 'user_has_cap', $callback, 10 ); + + if ( ! class_exists( $callback[0] ) || ! method_exists( $callback[0], $callback[1] ) ) { + return; + } + + add_filter( 'user_has_cap', $callback ); +} + /** * Returns the AI credentials settings URL if available. * @@ -670,6 +710,8 @@ function initialize_experiments(): void { AI_Client::init(); } + maybe_remove_invalid_ai_client_capability_callbacks(); + maybe_apply_option_credentials_to_core_ai_client(); // Initialize experiments so extended providers are registered From a8d01ca89b64f7d1cabccf9546ce456af74aacc9 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:27:54 -0500 Subject: [PATCH 07/15] docs: use x.x.x since tags for unreleased changes --- includes/bootstrap.php | 22 ++++++++++---------- includes/helpers.php | 16 +++++++------- tests/Integration/Includes/BootstrapTest.php | 14 ++++++------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 9038ab60..1029b159 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -49,7 +49,7 @@ /** * Displays an admin notice for version requirement failures. * - * @since 0.1.0 + * @since x.x.x * * @param string $message The error message to display. */ @@ -67,7 +67,7 @@ function version_notice( string $message ): void { /** * Checks if the PHP version meets the minimum requirement. * - * @since 0.1.0 + * @since x.x.x * * @return bool True if PHP version is sufficient, false otherwise. */ @@ -171,7 +171,7 @@ function should_use_bundled_wp_ai_client(): bool { /** * Filters whether to initialize the bundled WP AI Client package. * - * @since 0.1.0 + * @since x.x.x * * @param bool $use_bundled_wp_ai_client Whether to use the bundled WP AI Client package. * @return bool True to use the bundled package, false to rely on core. @@ -185,7 +185,7 @@ function should_use_bundled_wp_ai_client(): bool { * This allows relying on core-bundled AI client classes when available while * keeping the rest of the plugin dependencies managed by Jetpack autoloader. * - * @since 0.1.0 + * @since x.x.x * * @return void */ @@ -317,7 +317,7 @@ static function ( $path ) use ( $path_uses_bundled_package ): bool { * This ensures provider classes are registered even when core ships the client API * but does not automatically register built-in providers. * - * @since 0.3.1 + * @since x.x.x * * @return void */ @@ -350,7 +350,7 @@ function maybe_register_available_ai_client_providers(): void { /** * Filters provider implementation class name candidates by provider ID. * - * @since 0.3.1 + * @since x.x.x * * @param array|string> $provider_candidates Provider class candidates keyed by provider ID. * @return array|string> Filtered provider class candidates. @@ -426,7 +426,7 @@ function maybe_register_available_ai_client_providers(): void { * resolves provider credentials from environment variables/constants by default. * This bridge preserves existing installs by re-applying saved option values. * - * @since 0.3.1 + * @since x.x.x * * @return void */ @@ -438,7 +438,7 @@ function maybe_apply_option_credentials_to_core_ai_client(): void { /** * Filters whether to apply option-based credentials to the core AI client. * - * @since 0.3.1 + * @since x.x.x * * @param bool $should_apply True to apply option credentials to core providers. * @return bool Whether to apply option-based credentials. @@ -539,7 +539,7 @@ class_exists( $candidate_authentication_class ) && * 0.3+. If that class is loaded first, this callback becomes invalid and can * fatally error when capabilities are evaluated. * - * @since 0.3.1 + * @since x.x.x * * @return void */ @@ -574,7 +574,7 @@ function maybe_remove_invalid_ai_client_capability_callbacks(): void { /** * Returns the AI credentials settings URL if available. * - * @since 0.3.1 + * @since x.x.x * * @return string|null The URL to the credentials settings screen, or null. */ @@ -582,7 +582,7 @@ function get_ai_credentials_settings_url(): ?string { /** * Filters the AI credentials settings URL. * - * @since 0.3.1 + * @since x.x.x * * @param string|null $url URL to the credentials settings screen, or null if unavailable. * @return string|null The URL to use, or null if unavailable. diff --git a/includes/helpers.php b/includes/helpers.php index b3267b9a..9e95f9ed 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -28,7 +28,7 @@ /** * Normalizes the content by cleaning it and removing unwanted HTML tags. * - * @since 0.1.0 + * @since x.x.x * * @param string $content The content to normalize. * @return string The normalized content. @@ -77,7 +77,7 @@ function normalize_content( string $content ): string { /** * Returns the context for the given post ID. * - * @since 0.1.0 + * @since x.x.x * * @param int $post_id The ID of the post to get the context for. * @return array The context for the given post ID. @@ -301,7 +301,7 @@ function get_preferred_vision_models(): array { * Uses the core AI Client API when available, otherwise falls back to the * bundled WP AI Client package. * - * @since 0.1.0 + * @since x.x.x * * @param mixed $prompt Optional initial prompt content. * @return mixed Prompt builder instance. @@ -324,7 +324,7 @@ function ai_client_prompt_with_wp_error( $prompt = null ) { * Uses the core AI Client API when available, otherwise falls back to the * bundled WP AI Client package. * - * @since 0.1.0 + * @since x.x.x * * @param mixed $prompt Optional initial prompt content. * @return mixed Prompt builder instance. @@ -403,7 +403,7 @@ static function ( $provider_id ): bool { /** * Filters AI provider IDs used for credential discovery. * - * @since 0.1.0 + * @since x.x.x * @hook ai_experiments_provider_ids * * @param array $provider_ids Provider IDs. @@ -418,7 +418,7 @@ static function ( $provider_id ): bool { * The default format follows the core/provider registry convention: * `_API_KEY`. * - * @since 0.1.0 + * @since x.x.x * * @return array Environment variable / constant names. */ @@ -435,7 +435,7 @@ static function ( string $provider_id ): string { /** * Filters provider API key variable names used for credential discovery. * - * @since 0.1.0 + * @since x.x.x * @hook ai_experiments_provider_api_key_variable_names * * @param array $variable_names Variable names. @@ -447,7 +447,7 @@ static function ( string $provider_id ): string { /** * Checks whether any provider API key exists in env vars or constants. * - * @since 0.1.0 + * @since x.x.x * * @return bool True if an API key variable is set, otherwise false. */ diff --git a/tests/Integration/Includes/BootstrapTest.php b/tests/Integration/Includes/BootstrapTest.php index 0714ebe1..292faecb 100644 --- a/tests/Integration/Includes/BootstrapTest.php +++ b/tests/Integration/Includes/BootstrapTest.php @@ -12,13 +12,13 @@ /** * Bootstrap test case. * - * @since 0.3.1 + * @since x.x.x */ class BootstrapTest extends WP_UnitTestCase { /** * Tracks original credentials option value for restoration. * - * @since 0.3.1 + * @since x.x.x * * @var mixed */ @@ -27,7 +27,7 @@ class BootstrapTest extends WP_UnitTestCase { /** * Sets up test fixture. * - * @since 0.3.1 + * @since x.x.x */ public function setUp(): void { parent::setUp(); @@ -37,7 +37,7 @@ public function setUp(): void { /** * Tears down test fixture. * - * @since 0.3.1 + * @since x.x.x */ public function tearDown(): void { remove_all_filters( 'ai_experiments_credentials_settings_url' ); @@ -54,7 +54,7 @@ public function tearDown(): void { /** * Tests that credentials settings URL can be provided by filter. * - * @since 0.3.1 + * @since x.x.x */ public function test_get_ai_credentials_settings_url_uses_filter(): void { add_filter( @@ -73,7 +73,7 @@ static function (): string { /** * Tests that core provider registration runs without requiring bundled mode. * - * @since 0.3.1 + * @since x.x.x */ public function test_maybe_register_available_ai_client_providers(): void { if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { @@ -89,7 +89,7 @@ public function test_maybe_register_available_ai_client_providers(): void { /** * Tests that saved option credentials are applied to the AI client registry. * - * @since 0.3.1 + * @since x.x.x */ public function test_maybe_apply_option_credentials_to_core_ai_client(): void { if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { From 7797189624a8218500aad60e5c760130340a6899 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:14:44 -0500 Subject: [PATCH 08/15] feat: add WP 7.0 Connectors integration for extended providers On WP 7.0+, extended providers now appear on Settings > Connectors with custom SVG icons, API key management via REST API, and provider-specific settings (Cloudflare Account ID + API Key, Ollama endpoint URL). - Add connectors JS module with SVG icons for all 9 providers - Register connector settings with show_in_rest for REST API access - Bridge stored keys to AiClient registry via core helpers - Fix PHP 8.1+ setAccessible() deprecation in bootstrap.php - Fix Ollama URL construction per PR review feedback Co-Authored-By: Claude Opus 4.6 --- build/connectors-extended.js | 659 ++++++++++++++++++ .../Extended_Providers/Extended_Providers.php | 337 +++++++++ .../Ollama/OllamaModelMetadataDirectory.php | 2 +- .../Ollama/OllamaTextGenerationModel.php | 2 +- includes/bootstrap.php | 12 +- 5 files changed, 1007 insertions(+), 5 deletions(-) create mode 100644 build/connectors-extended.js diff --git a/build/connectors-extended.js b/build/connectors-extended.js new file mode 100644 index 00000000..9bd6f82f --- /dev/null +++ b/build/connectors-extended.js @@ -0,0 +1,659 @@ +/** + * Extended Providers – WP 7.0 Connectors Integration + * + * Registers additional AI provider connectors on the Settings > Connectors page. + * Provider data is injected by PHP into window.wpAiExtendedConnectors. + * + * @since 0.4.0 + * @package WordPress\AI\Experiments\Extended_Providers + */ + +import { + __experimentalRegisterConnector as registerConnector, + __experimentalConnectorItem as ConnectorItem, + __experimentalDefaultConnectorSettings as DefaultConnectorSettings, +} from '@wordpress/connectors'; + +const { createElement: h, useState, useEffect, useCallback } = window.wp.element; +const { + Button, + TextControl, + __experimentalHStack: HStack, + __experimentalVStack: VStack, + ExternalLink, +} = window.wp.components; +const apiFetch = window.wp.apiFetch; +const { __, sprintf } = window.wp.i18n; + +/** + * Provider data injected from PHP. + * + * @type {Array<{id: string, label: string, description: string, settingName: string, helpUrl: string, helpLabel: string, extraFields: Array}>} + */ +const providers = window.wpAiExtendedConnectors || []; + +/* ────────────────────────────────────────────── + * Provider SVG icons (40×40, matching core style) + * Extracted from feature/providers branch TSX. + * ────────────────────────────────────────────── */ + +function svg( viewBox, ...children ) { + return h( + 'svg', + { + width: '40', + height: '40', + viewBox, + fill: 'currentColor', + xmlns: 'http://www.w3.org/2000/svg', + }, + ...children + ); +} + +function path( d, extra ) { + return h( 'path', { d, ...extra } ); +} + +const ICONS = { + cloudflare: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437', + { fill: '#F38020' } + ), + path( + 'M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777', + { fill: '#FCAD32' } + ) + ), + + cohere: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z', + { fill: '#39594D', fillRule: 'evenodd', clipRule: 'evenodd' } + ), + path( + 'M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z', + { fill: '#D18EE2', fillRule: 'evenodd', clipRule: 'evenodd' } + ), + path( + 'M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z', + { fill: '#FF7759' } + ) + ), + + deepseek: () => + svg( + '0 0 24 24', + path( + 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z' + ) + ), + + fal: () => + svg( + '0 0 24 24', + path( + 'M15.477 0c.415 0 .749.338.788.752a7.775 7.775 0 006.985 6.984c.413.04.752.373.752.788v6.952c0 .415-.338.748-.752.788a7.775 7.775 0 00-6.985 6.984c-.04.414-.373.752-.788.752H8.525c-.416 0-.749-.338-.789-.752a7.775 7.775 0 00-6.984-6.984c-.414-.04-.752-.373-.752-.788V8.524c0-.415.338-.748.752-.788A7.775 7.775 0 007.736.752C7.776.338 8.11 0 8.526 0h6.95zM4.819 11.98a7.226 7.226 0 007.223 7.23 7.226 7.226 0 007.223-7.23c0-3.994-3.234-7.23-7.223-7.23a7.227 7.227 0 00-7.223 7.23z', + { fillRule: 'evenodd', clipRule: 'evenodd' } + ) + ), + + grok: () => + svg( + '0 0 24 24', + path( + 'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815' + ) + ), + + groq: () => + svg( + '0 0 24 24', + path( + 'M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z' + ) + ), + + huggingface: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z', + { fill: '#FF9D0B' } + ), + path( + 'M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z', + { fill: '#FFD21E' } + ), + path( + 'M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z', + { fill: '#FF323D' } + ), + path( + 'M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z', + { fill: '#3A3B45' } + ) + ), + + ollama: () => + svg( + '0 0 24 24', + path( + 'M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z', + { fillRule: 'evenodd' } + ) + ), + + openrouter: () => + svg( + '0 0 24 24', + path( + 'M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z', + { fillRule: 'evenodd' } + ) + ), +}; + +/* ────────────────────────────────────────────── + * Shared UI helpers + * ────────────────────────────────────────────── */ + +function ConnectedBadge() { + return h( + 'span', + { + style: { + color: '#345b37', + backgroundColor: '#eff8f0', + padding: '4px 12px', + borderRadius: '2px', + fontSize: '13px', + fontWeight: 500, + whiteSpace: 'nowrap', + }, + }, + __( 'Connected' ) + ); +} + +/* ────────────────────────────────────────────── + * Cloudflare custom settings (Account ID + API Key) + * ────────────────────────────────────────────── */ + +function CloudflareConnectorSettings( { + onSave, + onRemove, + initialApiKey = '', + initialAccountId = '', + helpUrl, + helpLabel, + readOnly = false, + accountIdSettingName, +} ) { + const [ apiKey, setApiKey ] = useState( initialApiKey ); + const [ accountId, setAccountId ] = useState( initialAccountId ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ saveError, setSaveError ] = useState( null ); + + const helpLinkLabel = helpLabel || helpUrl?.replace( /^https?:\/\//, '' ); + + const handleSave = async () => { + setSaveError( null ); + setIsSaving( true ); + try { + await onSave?.( apiKey, accountId ); + } catch ( error ) { + setSaveError( + error instanceof Error + ? error.message + : __( + 'It was not possible to connect to the provider using this key.' + ) + ); + } finally { + setIsSaving( false ); + } + }; + + const getHelp = () => { + if ( readOnly ) { + return h( + window.wp.element.Fragment, + null, + __( + 'Your API key is stored securely. You can reset it at' + ), + ' ', + helpUrl + ? h( ExternalLink, { href: helpUrl }, helpLinkLabel ) + : null + ); + } + if ( saveError ) { + return h( + 'span', + { style: { color: '#cc1818' } }, + saveError + ); + } + if ( helpUrl ) { + return h( + window.wp.element.Fragment, + null, + __( 'Get your API key at' ), + ' ', + h( ExternalLink, { href: helpUrl }, helpLinkLabel ) + ); + } + return undefined; + }; + + return h( + VStack, + { + spacing: 4, + className: 'connector-settings', + style: readOnly + ? { '--wp-components-color-background': '#f0f0f0' } + : undefined, + }, + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'Account ID' ), + value: accountId, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setAccountId( v ); + } + }, + placeholder: 'YOUR_ACCOUNT_ID', + disabled: readOnly || isSaving, + help: __( + 'Found in the Cloudflare dashboard under Workers & Pages.' + ), + } ), + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'API Key' ), + value: apiKey, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setApiKey( v ); + } + }, + placeholder: 'YOUR_API_KEY', + disabled: readOnly || isSaving, + help: getHelp(), + } ), + readOnly + ? h( + Button, + { + variant: 'link', + isDestructive: true, + onClick: onRemove, + }, + __( 'Remove and replace' ) + ) + : h( + HStack, + { justify: 'flex-start' }, + h( + Button, + { + __next40pxDefaultSize: true, + variant: 'primary', + disabled: ! apiKey || ! accountId || isSaving, + accessibleWhenDisabled: true, + isBusy: isSaving, + onClick: handleSave, + }, + __( 'Save' ) + ) + ) + ); +} + +/* ────────────────────────────────────────────── + * Ollama custom settings (Endpoint URL only, no API key) + * ────────────────────────────────────────────── */ + +function OllamaConnectorSettings( { + onSave, + onRemove, + initialEndpoint = '', + helpUrl, + helpLabel, + readOnly = false, +} ) { + const [ endpoint, setEndpoint ] = useState( initialEndpoint ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ saveError, setSaveError ] = useState( null ); + + const handleSave = async () => { + setSaveError( null ); + setIsSaving( true ); + try { + await onSave?.( endpoint ); + } catch ( error ) { + setSaveError( + error instanceof Error + ? error.message + : __( 'Could not save the endpoint.' ) + ); + } finally { + setIsSaving( false ); + } + }; + + const getHelp = () => { + if ( readOnly ) { + return __( 'Your endpoint is configured.' ); + } + if ( saveError ) { + return h( 'span', { style: { color: '#cc1818' } }, saveError ); + } + return __( + 'Enter the URL where Ollama is running. Default is http://localhost:11434' + ); + }; + + return h( + VStack, + { + spacing: 4, + className: 'connector-settings', + style: readOnly + ? { '--wp-components-color-background': '#f0f0f0' } + : undefined, + }, + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'Endpoint URL' ), + value: endpoint, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setEndpoint( v ); + } + }, + placeholder: 'http://localhost:11434', + disabled: readOnly || isSaving, + help: getHelp(), + } ), + readOnly + ? h( + Button, + { + variant: 'link', + isDestructive: true, + onClick: onRemove, + }, + __( 'Remove and replace' ) + ) + : h( + HStack, + { justify: 'flex-start' }, + h( + Button, + { + __next40pxDefaultSize: true, + variant: 'primary', + disabled: ! endpoint || isSaving, + accessibleWhenDisabled: true, + isBusy: isSaving, + onClick: handleSave, + }, + __( 'Save' ) + ) + ) + ); +} + +/* ────────────────────────────────────────────── + * Generic extended provider connector component + * ────────────────────────────────────────────── */ + +function ExtendedProviderConnector( { label, description, slug } ) { + const provider = providers.find( + ( p ) => 'ai-experiments/' + p.id === slug + ); + if ( ! provider ) { + return null; + } + + const { id, settingName, helpUrl, helpLabel, type } = provider; + const isCloudflare = id === 'cloudflare'; + const isEndpoint = type === 'endpoint'; + const accountIdSetting = isCloudflare ? 'ai_cloudflare_account_id' : null; + + const [ isExpanded, setIsExpanded ] = useState( false ); + const [ currentValue, setCurrentValue ] = useState( '' ); + const [ currentAccountId, setCurrentAccountId ] = useState( '' ); + const [ isLoading, setIsLoading ] = useState( true ); + + const isConnected = + currentValue !== '' && currentValue !== 'invalid_key'; + + const fetchValue = useCallback( async () => { + try { + let fields = settingName; + if ( accountIdSetting ) { + fields += ',' + accountIdSetting; + } + const settings = await apiFetch( { + path: '/wp/v2/settings?_fields=' + fields, + } ); + const val = settings[ settingName ] || ''; + setCurrentValue( val === 'invalid_key' ? '' : val ); + if ( accountIdSetting ) { + setCurrentAccountId( settings[ accountIdSetting ] || '' ); + } + } catch ( e ) { + // Setting may not be registered yet. + } + setIsLoading( false ); + }, [ settingName, accountIdSetting ] ); + + useEffect( () => { + fetchValue(); + }, [ fetchValue ] ); + + const saveValue = async ( value, accountId ) => { + const data = { [ settingName ]: value }; + let fields = settingName; + if ( accountIdSetting && accountId !== undefined ) { + data[ accountIdSetting ] = accountId; + fields += ',' + accountIdSetting; + } + const result = await apiFetch( { + method: 'POST', + path: '/wp/v2/settings?_fields=' + fields, + data, + } ); + // If the key was submitted but the response is empty, the save failed. + if ( ! isEndpoint && value && ! result[ settingName ] ) { + throw new Error( + __( + 'It was not possible to save the API key.' + ) + ); + } + setCurrentValue( result[ settingName ] || '' ); + if ( accountIdSetting ) { + setCurrentAccountId( result[ accountIdSetting ] || '' ); + } + }; + + const removeValue = async () => { + const data = { [ settingName ]: '' }; + let fields = settingName; + if ( accountIdSetting ) { + data[ accountIdSetting ] = ''; + fields += ',' + accountIdSetting; + } + await apiFetch( { + method: 'POST', + path: '/wp/v2/settings?_fields=' + fields, + data, + } ); + setCurrentValue( '' ); + setCurrentAccountId( '' ); + }; + + const handleButtonClick = () => setIsExpanded( ! isExpanded ); + + const getButtonLabel = () => { + if ( isLoading ) { + return __( 'Checking\u2026' ); + } + if ( isExpanded ) { + return __( 'Cancel' ); + } + if ( isConnected ) { + return __( 'Edit' ); + } + return __( 'Set up' ); + }; + + const IconComponent = ICONS[ id ]; + + const renderSettings = () => { + if ( ! isExpanded ) { + return null; + } + + if ( isEndpoint ) { + return h( OllamaConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialEndpoint: currentValue, + helpUrl, + helpLabel, + readOnly: isConnected, + onRemove: removeValue, + onSave: async ( endpoint ) => { + await saveValue( endpoint ); + setIsExpanded( false ); + }, + } ); + } + + if ( isCloudflare ) { + return h( CloudflareConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialApiKey: currentValue, + initialAccountId: currentAccountId, + helpUrl, + helpLabel, + readOnly: isConnected, + accountIdSettingName: accountIdSetting, + onRemove: removeValue, + onSave: async ( apiKey, accountId ) => { + await saveValue( apiKey, accountId ); + setIsExpanded( false ); + }, + } ); + } + + return h( DefaultConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialValue: currentValue, + helpUrl, + helpLabel, + readOnly: isConnected, + onRemove: removeValue, + onSave: async ( apiKey ) => { + await saveValue( apiKey ); + setIsExpanded( false ); + }, + } ); + }; + + return h( + ConnectorItem, + { + icon: IconComponent ? h( IconComponent ) : undefined, + name: label, + description, + actionArea: h( + HStack, + { spacing: 3, expanded: false }, + isConnected && h( ConnectedBadge ), + h( + Button, + { + variant: + isExpanded || isConnected + ? 'tertiary' + : 'secondary', + size: + isExpanded || isConnected + ? undefined + : 'compact', + onClick: handleButtonClick, + disabled: isLoading, + 'aria-expanded': isExpanded, + }, + getButtonLabel() + ) + ), + }, + renderSettings() + ); +} + +/* ────────────────────────────────────────────── + * Registration – deferred so core's 3 defaults register first. + * ────────────────────────────────────────────── */ + +/** + * Defer registration until after core's route modules have loaded. + * + * Core's connectors-home/content.js registers the 3 default connectors + * (OpenAI, Claude, Gemini) during route module initialisation. We use + * wp.domReady + requestAnimationFrame to ensure the Redux store already + * contains the defaults, so our extended providers appear below them + * in insertion order. + */ +window.wp.domReady( () => { + requestAnimationFrame( () => { + providers.forEach( ( provider ) => { + registerConnector( 'ai-experiments/' + provider.id, { + label: provider.label, + description: provider.description, + render: ExtendedProviderConnector, + } ); + } ); + } ); +} ); diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index b223e973..6651d988 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -33,8 +33,11 @@ use function is_string; use function register_setting; use function rest_sanitize_boolean; +use function sanitize_text_field; use function sprintf; +use function wp_enqueue_script_module; use function wp_kses_post; +use function wp_register_script_module; /** * Registers additional AI providers with the WP AI Client registry. @@ -81,6 +84,72 @@ protected function load_experiment_metadata(): array { ); } + /** + * Provider metadata for connectors integration. + * + * Maps provider ID => [ label, description, helpUrl, helpLabel ]. + * + * @var array + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive with array constant. + private const CONNECTOR_META = array( + 'cloudflare' => array( + 'label' => 'Cloudflare Workers AI', + 'description' => "Run AI models on Cloudflare\u{2019}s global edge network.", + 'helpUrl' => 'https://dash.cloudflare.com/', + 'helpLabel' => 'dash.cloudflare.com', + ), + 'cohere' => array( + 'label' => 'Cohere', + 'description' => 'Enterprise-grade language models for text generation and embeddings.', + 'helpUrl' => 'https://dashboard.cohere.com/', + 'helpLabel' => 'dashboard.cohere.com', + ), + 'deepseek' => array( + 'label' => 'DeepSeek', + 'description' => 'Advanced reasoning and code generation with DeepSeek models.', + 'helpUrl' => 'https://platform.deepseek.com/', + 'helpLabel' => 'platform.deepseek.com', + ), + 'fal' => array( + 'label' => 'Fal.ai', + 'description' => 'Fast image generation and media models.', + 'helpUrl' => 'https://fal.ai/dashboard/', + 'helpLabel' => 'fal.ai', + ), + 'grok' => array( + 'label' => 'Grok (xAI)', + 'description' => "Text generation with xAI\u{2019}s Grok models.", + 'helpUrl' => 'https://console.x.ai/', + 'helpLabel' => 'console.x.ai', + ), + 'groq' => array( + 'label' => 'Groq', + 'description' => 'Ultra-fast inference for open-source language models.', + 'helpUrl' => 'https://console.groq.com/', + 'helpLabel' => 'console.groq.com', + ), + 'huggingface' => array( + 'label' => 'Hugging Face', + 'description' => 'Access thousands of open-source models via the Inference API.', + 'helpUrl' => 'https://huggingface.co/settings/tokens', + 'helpLabel' => 'huggingface.co', + ), + 'ollama' => array( + 'label' => 'Ollama', + 'description' => 'Run large language models locally on your own hardware.', + 'helpUrl' => 'https://ollama.com/', + 'helpLabel' => 'ollama.com', + 'type' => 'endpoint', + ), + 'openrouter' => array( + 'label' => 'OpenRouter', + 'description' => 'Unified API gateway for hundreds of AI models.', + 'helpUrl' => 'https://openrouter.ai/keys', + 'helpLabel' => 'openrouter.ai', + ), + ); + /** * {@inheritDoc} */ @@ -88,6 +157,274 @@ public function register(): void { // Register providers immediately so they're available when // the WP AI Client collects provider metadata for the credentials screen. $this->register_providers(); + + // On WP 7.0+, register connector settings and enqueue the JS module + // for the Settings > Connectors screen. + if ( ! $this->is_enabled() || ! $this->is_connectors_supported() ) { + return; + } + + $this->register_connector_settings(); + $this->pass_connector_keys_to_registry(); + add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_connectors_script' ) ); + } + + /** + * Checks whether the WP 7.0 Connectors screen is available. + * + * @return bool + */ + private function is_connectors_supported(): bool { + return function_exists( '_wp_connectors_get_provider_settings' ); + } + + /** + * Returns the provider IDs that are currently enabled in the experiment. + * + * @return string[] + */ + private function get_enabled_provider_ids(): array { + $provider_classes = $this->filter_enabled_provider_classes( + $this->get_provider_classes() + ); + + $ids = array(); + foreach ( $provider_classes as $class_name ) { + if ( ! class_exists( $class_name ) || ! method_exists( $class_name, 'metadata' ) ) { + continue; + } + + try { + $ids[] = $class_name::metadata()->getId(); + } catch ( \Throwable $t ) { + continue; + } + } + + return $ids; + } + + /** + * Registers `connectors_ai_{id}_api_key` settings for extended providers. + * + * Mirrors core's `_wp_register_default_connector_settings()` for our providers. + */ + private function register_connector_settings(): void { + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + if ( ! isset( self::CONNECTOR_META[ $provider_id ] ) ) { + continue; + } + + $meta = self::CONNECTOR_META[ $provider_id ]; + $type = $meta['type'] ?? 'api_key'; + + // Ollama is endpoint-based (no API key). + if ( 'endpoint' === $type ) { + register_setting( + 'connectors', + "ai_{$provider_id}_endpoint", + array( + 'type' => 'string', + 'label' => sprintf( + /* translators: %s: AI provider name. */ + __( '%s Endpoint URL', 'ai' ), + $meta['label'] + ), + 'description' => sprintf( + /* translators: %s: AI provider name. */ + __( 'Endpoint URL for the %s provider.', 'ai' ), + $meta['label'] + ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => 'sanitize_url', + ) + ); + continue; + } + + $setting_name = "connectors_ai_{$provider_id}_api_key"; + + register_setting( + 'connectors', + $setting_name, + array( + 'type' => 'string', + 'label' => sprintf( + /* translators: %s: AI provider name. */ + __( '%s API Key', 'ai' ), + $meta['label'] + ), + 'description' => sprintf( + /* translators: %s: AI provider name. */ + __( 'API key for the %s AI provider.', 'ai' ), + $meta['label'] + ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => static function ( $value ): string { + return sanitize_text_field( (string) $value ); + }, + ) + ); + + // Mask stored keys in option reads. + if ( function_exists( '_wp_connectors_mask_api_key' ) ) { + add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); + } + + // Cloudflare also needs an Account ID field. + if ( 'cloudflare' !== $provider_id ) { + continue; + } + + register_setting( + 'connectors', + 'ai_cloudflare_account_id', + array( + 'type' => 'string', + 'label' => __( 'Cloudflare Account ID', 'ai' ), + 'description' => __( 'Cloudflare account ID for Workers AI API requests.', 'ai' ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + } + } + + /** + * Passes stored connector API keys to the AI client registry. + */ + private function pass_connector_keys_to_registry(): void { + if ( ! class_exists( AiClient::class ) ) { + return; + } + + try { + $registry = AiClient::defaultRegistry(); + } catch ( \Throwable $t ) { + return; + } + + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + $meta = self::CONNECTOR_META[ $provider_id ] ?? array(); + $type = $meta['type'] ?? 'api_key'; + + // Ollama: apply endpoint URL via filter, no API key needed. + if ( 'endpoint' === $type ) { + $endpoint = (string) get_option( "ai_{$provider_id}_endpoint", '' ); + if ( '' !== $endpoint ) { + add_filter( + "ai_{$provider_id}_base_url", + static function () use ( $endpoint ): string { + return rtrim( $endpoint, '/' ) . '/api'; + } + ); + } + continue; + } + + $setting_name = "connectors_ai_{$provider_id}_api_key"; + $mask_callback = '_wp_connectors_mask_api_key'; + + // Read the unmasked value. + if ( function_exists( '_wp_connectors_get_real_api_key' ) && function_exists( '_wp_connectors_mask_api_key' ) ) { + $api_key = _wp_connectors_get_real_api_key( $setting_name, $mask_callback ); + } else { + $api_key = (string) get_option( $setting_name, '' ); + } + + if ( '' === $api_key || ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + try { + $registry->setProviderRequestAuthentication( + $provider_id, + new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ) + ); + } catch ( \Throwable $t ) { + continue; + } + } + } + + /** + * Enqueues the connectors script module on the Connectors admin page. + * + * @param string $hook_suffix Admin page hook suffix. + */ + public function maybe_enqueue_connectors_script( string $hook_suffix ): void { + if ( 'settings_page_connectors-wp-admin' !== $hook_suffix ) { + return; + } + + if ( ! function_exists( 'wp_register_script_module' ) ) { + return; + } + + $script_path = AI_EXPERIMENTS_DIR . 'build/connectors-extended.js'; + if ( ! file_exists( $script_path ) ) { + return; + } + + $module_id = 'ai-experiments/connectors-extended'; + $deps = array( + array( + 'id' => '@wordpress/connectors', + 'import' => 'static', + ), + ); + + wp_register_script_module( + $module_id, + AI_EXPERIMENTS_PLUGIN_URL . 'build/connectors-extended.js', + $deps, + (string) filemtime( $script_path ) + ); + + wp_enqueue_script_module( $module_id ); + + // Pass provider data to JS. + $provider_data = array(); + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + if ( ! isset( self::CONNECTOR_META[ $provider_id ] ) ) { + continue; + } + + $meta = self::CONNECTOR_META[ $provider_id ]; + $type = $meta['type'] ?? 'api_key'; + + $entry = array( + 'id' => $provider_id, + 'label' => $meta['label'], + 'description' => $meta['description'], + 'helpUrl' => $meta['helpUrl'], + 'helpLabel' => $meta['helpLabel'], + 'type' => $type, + ); + + if ( 'endpoint' === $type ) { + $entry['settingName'] = "ai_{$provider_id}_endpoint"; + } else { + $entry['settingName'] = "connectors_ai_{$provider_id}_api_key"; + } + + $provider_data[] = $entry; + } + + // Output data as a global before the module loads. + add_action( + 'admin_print_footer_scripts', + static function () use ( $provider_data ): void { + printf( + '', + wp_json_encode( $provider_data ) + ); + }, + 1 + ); } /** diff --git a/includes/Providers/Ollama/OllamaModelMetadataDirectory.php b/includes/Providers/Ollama/OllamaModelMetadataDirectory.php index 212782f0..085375ca 100644 --- a/includes/Providers/Ollama/OllamaModelMetadataDirectory.php +++ b/includes/Providers/Ollama/OllamaModelMetadataDirectory.php @@ -31,7 +31,7 @@ class OllamaModelMetadataDirectory extends AbstractApiBasedModelMetadataDirector protected function sendListModelsRequest(): array { $request = new Request( HttpMethodEnum::GET(), - rtrim( OllamaProvider::get_base_url(), '/api' ) . '/api/tags' + preg_replace( '#/api$#', '', OllamaProvider::get_base_url() ) . '/api/tags' ); $response = $this->getHttpTransporter()->send( $request ); diff --git a/includes/Providers/Ollama/OllamaTextGenerationModel.php b/includes/Providers/Ollama/OllamaTextGenerationModel.php index 9e934d93..fb23967e 100644 --- a/includes/Providers/Ollama/OllamaTextGenerationModel.php +++ b/includes/Providers/Ollama/OllamaTextGenerationModel.php @@ -39,7 +39,7 @@ class OllamaTextGenerationModel extends AbstractApiBasedModel implements TextGen public function generateTextResult( array $prompt ): GenerativeAiResult { $request = new Request( HttpMethodEnum::POST(), - rtrim( OllamaProvider::get_base_url(), '/api' ) . '/api/chat', + preg_replace( '#/api$#', '', OllamaProvider::get_base_url() ) . '/api/chat', array( 'Content-Type' => 'application/json' ), $this->buildPayload( $prompt ) ); diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 1029b159..da9bd19c 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -225,7 +225,9 @@ function maybe_disable_bundled_wp_ai_client_packages(): void { if ( $reflection->hasProperty( 'psr4_map' ) ) { $psr4_property = $reflection->getProperty( 'psr4_map' ); - $psr4_property->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $psr4_property->setAccessible( true ); + } $psr4_map = $psr4_property->getValue( $jetpack_autoloader_loader ); if ( is_array( $psr4_map ) ) { @@ -257,7 +259,9 @@ static function ( $path ) use ( $path_uses_bundled_package ): bool { if ( $reflection->hasProperty( 'classmap' ) ) { $classmap_property = $reflection->getProperty( 'classmap' ); - $classmap_property->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $classmap_property->setAccessible( true ); + } $classmap = $classmap_property->getValue( $jetpack_autoloader_loader ); if ( is_array( $classmap ) ) { @@ -279,7 +283,9 @@ static function ( $path ) use ( $path_uses_bundled_package ): bool { if ( $reflection->hasProperty( 'filemap' ) ) { $filemap_property = $reflection->getProperty( 'filemap' ); - $filemap_property->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $filemap_property->setAccessible( true ); + } $filemap = $filemap_property->getValue( $jetpack_autoloader_loader ); if ( is_array( $filemap ) ) { From 3143586344902c21c8c12d2b17353c3cc136f24a Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:42:06 -0500 Subject: [PATCH 09/15] fix: let core handle API key settings to prevent double-masking bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's _wp_register_default_connector_settings() at init:20 auto-discovers all registered providers and registers their API key settings, mask filters, and key-to-registry passing. Our plugin was duplicating this work at init:10, causing two mask filters per option. When core's _wp_connectors_pass_default_keys_to_ai_client() ran, it only removed one mask filter via _wp_connectors_get_real_api_key(), leaving the second active. This meant get_option() returned masked values (e.g. "••••••fj39") which were then set as provider authentication, overwriting correct keys. Changes: - Remove duplicate register_connector_settings() and pass_connector_keys_to_registry() - Replace with register_extra_connector_settings() for Cloudflare Account ID and Ollama endpoint URL (settings core doesn't handle) - Add apply_endpoint_provider_urls() for Ollama base URL filter - Fix is_connectors_supported() to check both trunk and beta2 function names (_wp_connectors_get_connector_settings vs _wp_connectors_get_provider_settings) Co-Authored-By: Claude Opus 4.6 --- .../Extended_Providers/Extended_Providers.php | 154 +++++------------- 1 file changed, 42 insertions(+), 112 deletions(-) diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 6651d988..2cb40269 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -158,14 +158,17 @@ public function register(): void { // the WP AI Client collects provider metadata for the credentials screen. $this->register_providers(); - // On WP 7.0+, register connector settings and enqueue the JS module - // for the Settings > Connectors screen. + // On WP 7.0+, core's _wp_register_default_connector_settings() (init:20) + // auto-discovers all registered providers and handles API key settings, + // mask filters, and key-to-registry passing. We only need to register + // non-standard settings (Cloudflare Account ID, Ollama endpoint) and + // enqueue the JS for custom icons/UI. if ( ! $this->is_enabled() || ! $this->is_connectors_supported() ) { return; } - $this->register_connector_settings(); - $this->pass_connector_keys_to_registry(); + $this->register_extra_connector_settings(); + $this->apply_endpoint_provider_urls(); add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_connectors_script' ) ); } @@ -175,7 +178,9 @@ public function register(): void { * @return bool */ private function is_connectors_supported(): bool { - return function_exists( '_wp_connectors_get_provider_settings' ); + // Trunk uses _wp_connectors_get_connector_settings; beta2 uses _wp_connectors_get_provider_settings. + return function_exists( '_wp_connectors_get_connector_settings' ) + || function_exists( '_wp_connectors_get_provider_settings' ); } /** @@ -205,148 +210,73 @@ private function get_enabled_provider_ids(): array { } /** - * Registers `connectors_ai_{id}_api_key` settings for extended providers. + * Registers non-standard connector settings that core doesn't handle. * - * Mirrors core's `_wp_register_default_connector_settings()` for our providers. + * Core's `_wp_register_default_connector_settings()` (init:20) auto-registers + * `connectors_ai_{id}_api_key` settings, mask filters, and key passing for all + * registered providers. We only register settings core doesn't know about: + * - Cloudflare Account ID (extra field alongside the API key) + * - Ollama endpoint URL (endpoint-based, no API key) */ - private function register_connector_settings(): void { - foreach ( $this->get_enabled_provider_ids() as $provider_id ) { - if ( ! isset( self::CONNECTOR_META[ $provider_id ] ) ) { - continue; - } - - $meta = self::CONNECTOR_META[ $provider_id ]; - $type = $meta['type'] ?? 'api_key'; - - // Ollama is endpoint-based (no API key). - if ( 'endpoint' === $type ) { - register_setting( - 'connectors', - "ai_{$provider_id}_endpoint", - array( - 'type' => 'string', - 'label' => sprintf( - /* translators: %s: AI provider name. */ - __( '%s Endpoint URL', 'ai' ), - $meta['label'] - ), - 'description' => sprintf( - /* translators: %s: AI provider name. */ - __( 'Endpoint URL for the %s provider.', 'ai' ), - $meta['label'] - ), - 'default' => '', - 'show_in_rest' => true, - 'sanitize_callback' => 'sanitize_url', - ) - ); - continue; - } - - $setting_name = "connectors_ai_{$provider_id}_api_key"; + private function register_extra_connector_settings(): void { + $enabled_ids = $this->get_enabled_provider_ids(); + // Cloudflare needs an Account ID in addition to the API key that core handles. + if ( in_array( 'cloudflare', $enabled_ids, true ) ) { register_setting( 'connectors', - $setting_name, + 'ai_cloudflare_account_id', array( 'type' => 'string', - 'label' => sprintf( - /* translators: %s: AI provider name. */ - __( '%s API Key', 'ai' ), - $meta['label'] - ), - 'description' => sprintf( - /* translators: %s: AI provider name. */ - __( 'API key for the %s AI provider.', 'ai' ), - $meta['label'] - ), + 'label' => __( 'Cloudflare Account ID', 'ai' ), + 'description' => __( 'Cloudflare account ID for Workers AI API requests.', 'ai' ), 'default' => '', 'show_in_rest' => true, - 'sanitize_callback' => static function ( $value ): string { - return sanitize_text_field( (string) $value ); - }, + 'sanitize_callback' => 'sanitize_text_field', ) ); + } - // Mask stored keys in option reads. - if ( function_exists( '_wp_connectors_mask_api_key' ) ) { - add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); - } - - // Cloudflare also needs an Account ID field. - if ( 'cloudflare' !== $provider_id ) { - continue; - } - + // Ollama is endpoint-based (local provider, no API key). + if ( in_array( 'ollama', $enabled_ids, true ) ) { register_setting( 'connectors', - 'ai_cloudflare_account_id', + 'ai_ollama_endpoint', array( 'type' => 'string', - 'label' => __( 'Cloudflare Account ID', 'ai' ), - 'description' => __( 'Cloudflare account ID for Workers AI API requests.', 'ai' ), + 'label' => __( 'Ollama Endpoint URL', 'ai' ), + 'description' => __( 'Endpoint URL for the Ollama provider.', 'ai' ), 'default' => '', 'show_in_rest' => true, - 'sanitize_callback' => 'sanitize_text_field', + 'sanitize_callback' => 'sanitize_url', ) ); } } /** - * Passes stored connector API keys to the AI client registry. + * Applies endpoint URLs for providers that use a custom base URL instead of API keys. */ - private function pass_connector_keys_to_registry(): void { - if ( ! class_exists( AiClient::class ) ) { - return; - } - - try { - $registry = AiClient::defaultRegistry(); - } catch ( \Throwable $t ) { - return; - } - + private function apply_endpoint_provider_urls(): void { foreach ( $this->get_enabled_provider_ids() as $provider_id ) { $meta = self::CONNECTOR_META[ $provider_id ] ?? array(); $type = $meta['type'] ?? 'api_key'; - // Ollama: apply endpoint URL via filter, no API key needed. - if ( 'endpoint' === $type ) { - $endpoint = (string) get_option( "ai_{$provider_id}_endpoint", '' ); - if ( '' !== $endpoint ) { - add_filter( - "ai_{$provider_id}_base_url", - static function () use ( $endpoint ): string { - return rtrim( $endpoint, '/' ) . '/api'; - } - ); - } + if ( 'endpoint' !== $type ) { continue; } - $setting_name = "connectors_ai_{$provider_id}_api_key"; - $mask_callback = '_wp_connectors_mask_api_key'; - - // Read the unmasked value. - if ( function_exists( '_wp_connectors_get_real_api_key' ) && function_exists( '_wp_connectors_mask_api_key' ) ) { - $api_key = _wp_connectors_get_real_api_key( $setting_name, $mask_callback ); - } else { - $api_key = (string) get_option( $setting_name, '' ); - } - - if ( '' === $api_key || ! $registry->hasProvider( $provider_id ) ) { + $endpoint = (string) get_option( "ai_{$provider_id}_endpoint", '' ); + if ( '' === $endpoint ) { continue; } - try { - $registry->setProviderRequestAuthentication( - $provider_id, - new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ) - ); - } catch ( \Throwable $t ) { - continue; - } + add_filter( + "ai_{$provider_id}_base_url", + static function () use ( $endpoint ): string { + return rtrim( $endpoint, '/' ) . '/api'; + } + ); } } From 06674b1190dd3d55bfc24c183b8c44958c4d3bb9 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:46:54 -0500 Subject: [PATCH 10/15] fix: register API key settings for extended providers on WP 7.0 beta2 WP 7.0 trunk auto-discovers all registered providers and registers their API key settings, but beta2 only handles the 3 hardcoded core providers. Add maybe_register_api_key_settings() at init:21 that checks which settings core already registered and only fills in the gaps. Also add maybe_pass_keys_to_registry() at init:22 to pass stored keys for providers that core didn't handle. This avoids the double-mask-filter bug by checking get_registered_settings() before registering, ensuring each setting and mask filter is added exactly once. Co-Authored-By: Claude Opus 4.6 --- .../Extended_Providers/Extended_Providers.php | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 2cb40269..4d0a25c8 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -169,6 +169,12 @@ public function register(): void { $this->register_extra_connector_settings(); $this->apply_endpoint_provider_urls(); + + // Register API key settings and pass keys to registry AFTER core's init:20 + // so we only fill in what core didn't handle (beta2 vs trunk differences). + add_action( 'init', array( $this, 'maybe_register_api_key_settings' ), 21 ); + add_action( 'init', array( $this, 'maybe_pass_keys_to_registry' ), 22 ); + add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_connectors_script' ) ); } @@ -280,6 +286,119 @@ static function () use ( $endpoint ): string { } } + /** + * Registers API key settings for extended providers that core didn't handle. + * + * Runs at init:21 (after core's init:20) so we can check which settings + * core already registered and only fill in the gaps. This avoids duplicate + * mask filters that would break key retrieval. + */ + public function maybe_register_api_key_settings(): void { + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + $meta = self::CONNECTOR_META[ $provider_id ] ?? array(); + $type = $meta['type'] ?? 'api_key'; + + if ( 'endpoint' === $type ) { + continue; + } + + $setting_name = "connectors_ai_{$provider_id}_api_key"; + + // Skip if core already registered this setting (trunk behavior). + $registered = get_registered_settings(); + if ( isset( $registered[ $setting_name ] ) ) { + continue; + } + + register_setting( + 'connectors', + $setting_name, + array( + 'type' => 'string', + 'label' => sprintf( + /* translators: %s: AI provider name. */ + __( '%s API Key', 'ai' ), + $meta['label'] ?? ucwords( $provider_id ) + ), + 'description' => sprintf( + /* translators: %s: AI provider name. */ + __( 'API key for the %s AI provider.', 'ai' ), + $meta['label'] ?? ucwords( $provider_id ) + ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => static function ( $value ): string { + return sanitize_text_field( (string) $value ); + }, + ) + ); + + // Add mask filter (only one instance since we checked core didn't register). + if ( function_exists( '_wp_connectors_mask_api_key' ) ) { + add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); + } + } + } + + /** + * Passes stored API keys to the AI client registry for providers that core didn't handle. + * + * Runs at init:22 (after core's init:20 and our settings registration at init:21). + */ + public function maybe_pass_keys_to_registry(): void { + if ( ! class_exists( AiClient::class ) ) { + return; + } + + try { + $registry = AiClient::defaultRegistry(); + } catch ( \Throwable $t ) { + return; + } + + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + $meta = self::CONNECTOR_META[ $provider_id ] ?? array(); + $type = $meta['type'] ?? 'api_key'; + + if ( 'endpoint' === $type ) { + continue; + } + + // Skip if already configured (core handled it at init:20). + if ( $registry->hasProvider( $provider_id ) ) { + try { + if ( $registry->isProviderConfigured( $provider_id ) ) { + continue; + } + } catch ( \Throwable $t ) { + // isProviderConfigured may throw; continue to try setting key. + } + } + + $setting_name = "connectors_ai_{$provider_id}_api_key"; + + // Read unmasked value. + if ( function_exists( '_wp_connectors_get_real_api_key' ) && function_exists( '_wp_connectors_mask_api_key' ) ) { + $api_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); + } else { + $api_key = (string) get_option( $setting_name, '' ); + } + + if ( '' === $api_key || ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + try { + $registry->setProviderRequestAuthentication( + $provider_id, + new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ) + ); + } catch ( \Throwable $t ) { + continue; + } + } + } + /** * Enqueues the connectors script module on the Connectors admin page. * From bf4be08c396a443a853e8f0ba432db51102b8479 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:22:17 -0500 Subject: [PATCH 11/15] fix: move connectors-extended.js out of build/ to survive npm run build The connectors-extended.js script module is hand-written (not webpack-compiled) and was being deleted by webpack's clean output. Moved to assets/js/ which webpack does not manage. Co-Authored-By: Claude Opus 4.6 --- assets/js/connectors-extended.js | 659 ++++++++++++++++++ .../Extended_Providers/Extended_Providers.php | 4 +- 2 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 assets/js/connectors-extended.js diff --git a/assets/js/connectors-extended.js b/assets/js/connectors-extended.js new file mode 100644 index 00000000..9bd6f82f --- /dev/null +++ b/assets/js/connectors-extended.js @@ -0,0 +1,659 @@ +/** + * Extended Providers – WP 7.0 Connectors Integration + * + * Registers additional AI provider connectors on the Settings > Connectors page. + * Provider data is injected by PHP into window.wpAiExtendedConnectors. + * + * @since 0.4.0 + * @package WordPress\AI\Experiments\Extended_Providers + */ + +import { + __experimentalRegisterConnector as registerConnector, + __experimentalConnectorItem as ConnectorItem, + __experimentalDefaultConnectorSettings as DefaultConnectorSettings, +} from '@wordpress/connectors'; + +const { createElement: h, useState, useEffect, useCallback } = window.wp.element; +const { + Button, + TextControl, + __experimentalHStack: HStack, + __experimentalVStack: VStack, + ExternalLink, +} = window.wp.components; +const apiFetch = window.wp.apiFetch; +const { __, sprintf } = window.wp.i18n; + +/** + * Provider data injected from PHP. + * + * @type {Array<{id: string, label: string, description: string, settingName: string, helpUrl: string, helpLabel: string, extraFields: Array}>} + */ +const providers = window.wpAiExtendedConnectors || []; + +/* ────────────────────────────────────────────── + * Provider SVG icons (40×40, matching core style) + * Extracted from feature/providers branch TSX. + * ────────────────────────────────────────────── */ + +function svg( viewBox, ...children ) { + return h( + 'svg', + { + width: '40', + height: '40', + viewBox, + fill: 'currentColor', + xmlns: 'http://www.w3.org/2000/svg', + }, + ...children + ); +} + +function path( d, extra ) { + return h( 'path', { d, ...extra } ); +} + +const ICONS = { + cloudflare: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437', + { fill: '#F38020' } + ), + path( + 'M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777', + { fill: '#FCAD32' } + ) + ), + + cohere: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z', + { fill: '#39594D', fillRule: 'evenodd', clipRule: 'evenodd' } + ), + path( + 'M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z', + { fill: '#D18EE2', fillRule: 'evenodd', clipRule: 'evenodd' } + ), + path( + 'M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z', + { fill: '#FF7759' } + ) + ), + + deepseek: () => + svg( + '0 0 24 24', + path( + 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z' + ) + ), + + fal: () => + svg( + '0 0 24 24', + path( + 'M15.477 0c.415 0 .749.338.788.752a7.775 7.775 0 006.985 6.984c.413.04.752.373.752.788v6.952c0 .415-.338.748-.752.788a7.775 7.775 0 00-6.985 6.984c-.04.414-.373.752-.788.752H8.525c-.416 0-.749-.338-.789-.752a7.775 7.775 0 00-6.984-6.984c-.414-.04-.752-.373-.752-.788V8.524c0-.415.338-.748.752-.788A7.775 7.775 0 007.736.752C7.776.338 8.11 0 8.526 0h6.95zM4.819 11.98a7.226 7.226 0 007.223 7.23 7.226 7.226 0 007.223-7.23c0-3.994-3.234-7.23-7.223-7.23a7.227 7.227 0 00-7.223 7.23z', + { fillRule: 'evenodd', clipRule: 'evenodd' } + ) + ), + + grok: () => + svg( + '0 0 24 24', + path( + 'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815' + ) + ), + + groq: () => + svg( + '0 0 24 24', + path( + 'M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z' + ) + ), + + huggingface: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z', + { fill: '#FF9D0B' } + ), + path( + 'M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z', + { fill: '#FFD21E' } + ), + path( + 'M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z', + { fill: '#FF323D' } + ), + path( + 'M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z', + { fill: '#3A3B45' } + ) + ), + + ollama: () => + svg( + '0 0 24 24', + path( + 'M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z', + { fillRule: 'evenodd' } + ) + ), + + openrouter: () => + svg( + '0 0 24 24', + path( + 'M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z', + { fillRule: 'evenodd' } + ) + ), +}; + +/* ────────────────────────────────────────────── + * Shared UI helpers + * ────────────────────────────────────────────── */ + +function ConnectedBadge() { + return h( + 'span', + { + style: { + color: '#345b37', + backgroundColor: '#eff8f0', + padding: '4px 12px', + borderRadius: '2px', + fontSize: '13px', + fontWeight: 500, + whiteSpace: 'nowrap', + }, + }, + __( 'Connected' ) + ); +} + +/* ────────────────────────────────────────────── + * Cloudflare custom settings (Account ID + API Key) + * ────────────────────────────────────────────── */ + +function CloudflareConnectorSettings( { + onSave, + onRemove, + initialApiKey = '', + initialAccountId = '', + helpUrl, + helpLabel, + readOnly = false, + accountIdSettingName, +} ) { + const [ apiKey, setApiKey ] = useState( initialApiKey ); + const [ accountId, setAccountId ] = useState( initialAccountId ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ saveError, setSaveError ] = useState( null ); + + const helpLinkLabel = helpLabel || helpUrl?.replace( /^https?:\/\//, '' ); + + const handleSave = async () => { + setSaveError( null ); + setIsSaving( true ); + try { + await onSave?.( apiKey, accountId ); + } catch ( error ) { + setSaveError( + error instanceof Error + ? error.message + : __( + 'It was not possible to connect to the provider using this key.' + ) + ); + } finally { + setIsSaving( false ); + } + }; + + const getHelp = () => { + if ( readOnly ) { + return h( + window.wp.element.Fragment, + null, + __( + 'Your API key is stored securely. You can reset it at' + ), + ' ', + helpUrl + ? h( ExternalLink, { href: helpUrl }, helpLinkLabel ) + : null + ); + } + if ( saveError ) { + return h( + 'span', + { style: { color: '#cc1818' } }, + saveError + ); + } + if ( helpUrl ) { + return h( + window.wp.element.Fragment, + null, + __( 'Get your API key at' ), + ' ', + h( ExternalLink, { href: helpUrl }, helpLinkLabel ) + ); + } + return undefined; + }; + + return h( + VStack, + { + spacing: 4, + className: 'connector-settings', + style: readOnly + ? { '--wp-components-color-background': '#f0f0f0' } + : undefined, + }, + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'Account ID' ), + value: accountId, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setAccountId( v ); + } + }, + placeholder: 'YOUR_ACCOUNT_ID', + disabled: readOnly || isSaving, + help: __( + 'Found in the Cloudflare dashboard under Workers & Pages.' + ), + } ), + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'API Key' ), + value: apiKey, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setApiKey( v ); + } + }, + placeholder: 'YOUR_API_KEY', + disabled: readOnly || isSaving, + help: getHelp(), + } ), + readOnly + ? h( + Button, + { + variant: 'link', + isDestructive: true, + onClick: onRemove, + }, + __( 'Remove and replace' ) + ) + : h( + HStack, + { justify: 'flex-start' }, + h( + Button, + { + __next40pxDefaultSize: true, + variant: 'primary', + disabled: ! apiKey || ! accountId || isSaving, + accessibleWhenDisabled: true, + isBusy: isSaving, + onClick: handleSave, + }, + __( 'Save' ) + ) + ) + ); +} + +/* ────────────────────────────────────────────── + * Ollama custom settings (Endpoint URL only, no API key) + * ────────────────────────────────────────────── */ + +function OllamaConnectorSettings( { + onSave, + onRemove, + initialEndpoint = '', + helpUrl, + helpLabel, + readOnly = false, +} ) { + const [ endpoint, setEndpoint ] = useState( initialEndpoint ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ saveError, setSaveError ] = useState( null ); + + const handleSave = async () => { + setSaveError( null ); + setIsSaving( true ); + try { + await onSave?.( endpoint ); + } catch ( error ) { + setSaveError( + error instanceof Error + ? error.message + : __( 'Could not save the endpoint.' ) + ); + } finally { + setIsSaving( false ); + } + }; + + const getHelp = () => { + if ( readOnly ) { + return __( 'Your endpoint is configured.' ); + } + if ( saveError ) { + return h( 'span', { style: { color: '#cc1818' } }, saveError ); + } + return __( + 'Enter the URL where Ollama is running. Default is http://localhost:11434' + ); + }; + + return h( + VStack, + { + spacing: 4, + className: 'connector-settings', + style: readOnly + ? { '--wp-components-color-background': '#f0f0f0' } + : undefined, + }, + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'Endpoint URL' ), + value: endpoint, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setEndpoint( v ); + } + }, + placeholder: 'http://localhost:11434', + disabled: readOnly || isSaving, + help: getHelp(), + } ), + readOnly + ? h( + Button, + { + variant: 'link', + isDestructive: true, + onClick: onRemove, + }, + __( 'Remove and replace' ) + ) + : h( + HStack, + { justify: 'flex-start' }, + h( + Button, + { + __next40pxDefaultSize: true, + variant: 'primary', + disabled: ! endpoint || isSaving, + accessibleWhenDisabled: true, + isBusy: isSaving, + onClick: handleSave, + }, + __( 'Save' ) + ) + ) + ); +} + +/* ────────────────────────────────────────────── + * Generic extended provider connector component + * ────────────────────────────────────────────── */ + +function ExtendedProviderConnector( { label, description, slug } ) { + const provider = providers.find( + ( p ) => 'ai-experiments/' + p.id === slug + ); + if ( ! provider ) { + return null; + } + + const { id, settingName, helpUrl, helpLabel, type } = provider; + const isCloudflare = id === 'cloudflare'; + const isEndpoint = type === 'endpoint'; + const accountIdSetting = isCloudflare ? 'ai_cloudflare_account_id' : null; + + const [ isExpanded, setIsExpanded ] = useState( false ); + const [ currentValue, setCurrentValue ] = useState( '' ); + const [ currentAccountId, setCurrentAccountId ] = useState( '' ); + const [ isLoading, setIsLoading ] = useState( true ); + + const isConnected = + currentValue !== '' && currentValue !== 'invalid_key'; + + const fetchValue = useCallback( async () => { + try { + let fields = settingName; + if ( accountIdSetting ) { + fields += ',' + accountIdSetting; + } + const settings = await apiFetch( { + path: '/wp/v2/settings?_fields=' + fields, + } ); + const val = settings[ settingName ] || ''; + setCurrentValue( val === 'invalid_key' ? '' : val ); + if ( accountIdSetting ) { + setCurrentAccountId( settings[ accountIdSetting ] || '' ); + } + } catch ( e ) { + // Setting may not be registered yet. + } + setIsLoading( false ); + }, [ settingName, accountIdSetting ] ); + + useEffect( () => { + fetchValue(); + }, [ fetchValue ] ); + + const saveValue = async ( value, accountId ) => { + const data = { [ settingName ]: value }; + let fields = settingName; + if ( accountIdSetting && accountId !== undefined ) { + data[ accountIdSetting ] = accountId; + fields += ',' + accountIdSetting; + } + const result = await apiFetch( { + method: 'POST', + path: '/wp/v2/settings?_fields=' + fields, + data, + } ); + // If the key was submitted but the response is empty, the save failed. + if ( ! isEndpoint && value && ! result[ settingName ] ) { + throw new Error( + __( + 'It was not possible to save the API key.' + ) + ); + } + setCurrentValue( result[ settingName ] || '' ); + if ( accountIdSetting ) { + setCurrentAccountId( result[ accountIdSetting ] || '' ); + } + }; + + const removeValue = async () => { + const data = { [ settingName ]: '' }; + let fields = settingName; + if ( accountIdSetting ) { + data[ accountIdSetting ] = ''; + fields += ',' + accountIdSetting; + } + await apiFetch( { + method: 'POST', + path: '/wp/v2/settings?_fields=' + fields, + data, + } ); + setCurrentValue( '' ); + setCurrentAccountId( '' ); + }; + + const handleButtonClick = () => setIsExpanded( ! isExpanded ); + + const getButtonLabel = () => { + if ( isLoading ) { + return __( 'Checking\u2026' ); + } + if ( isExpanded ) { + return __( 'Cancel' ); + } + if ( isConnected ) { + return __( 'Edit' ); + } + return __( 'Set up' ); + }; + + const IconComponent = ICONS[ id ]; + + const renderSettings = () => { + if ( ! isExpanded ) { + return null; + } + + if ( isEndpoint ) { + return h( OllamaConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialEndpoint: currentValue, + helpUrl, + helpLabel, + readOnly: isConnected, + onRemove: removeValue, + onSave: async ( endpoint ) => { + await saveValue( endpoint ); + setIsExpanded( false ); + }, + } ); + } + + if ( isCloudflare ) { + return h( CloudflareConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialApiKey: currentValue, + initialAccountId: currentAccountId, + helpUrl, + helpLabel, + readOnly: isConnected, + accountIdSettingName: accountIdSetting, + onRemove: removeValue, + onSave: async ( apiKey, accountId ) => { + await saveValue( apiKey, accountId ); + setIsExpanded( false ); + }, + } ); + } + + return h( DefaultConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialValue: currentValue, + helpUrl, + helpLabel, + readOnly: isConnected, + onRemove: removeValue, + onSave: async ( apiKey ) => { + await saveValue( apiKey ); + setIsExpanded( false ); + }, + } ); + }; + + return h( + ConnectorItem, + { + icon: IconComponent ? h( IconComponent ) : undefined, + name: label, + description, + actionArea: h( + HStack, + { spacing: 3, expanded: false }, + isConnected && h( ConnectedBadge ), + h( + Button, + { + variant: + isExpanded || isConnected + ? 'tertiary' + : 'secondary', + size: + isExpanded || isConnected + ? undefined + : 'compact', + onClick: handleButtonClick, + disabled: isLoading, + 'aria-expanded': isExpanded, + }, + getButtonLabel() + ) + ), + }, + renderSettings() + ); +} + +/* ────────────────────────────────────────────── + * Registration – deferred so core's 3 defaults register first. + * ────────────────────────────────────────────── */ + +/** + * Defer registration until after core's route modules have loaded. + * + * Core's connectors-home/content.js registers the 3 default connectors + * (OpenAI, Claude, Gemini) during route module initialisation. We use + * wp.domReady + requestAnimationFrame to ensure the Redux store already + * contains the defaults, so our extended providers appear below them + * in insertion order. + */ +window.wp.domReady( () => { + requestAnimationFrame( () => { + providers.forEach( ( provider ) => { + registerConnector( 'ai-experiments/' + provider.id, { + label: provider.label, + description: provider.description, + render: ExtendedProviderConnector, + } ); + } ); + } ); +} ); diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 4d0a25c8..5fc14dd9 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -413,7 +413,7 @@ public function maybe_enqueue_connectors_script( string $hook_suffix ): void { return; } - $script_path = AI_EXPERIMENTS_DIR . 'build/connectors-extended.js'; + $script_path = AI_EXPERIMENTS_DIR . 'assets/js/connectors-extended.js'; if ( ! file_exists( $script_path ) ) { return; } @@ -428,7 +428,7 @@ public function maybe_enqueue_connectors_script( string $hook_suffix ): void { wp_register_script_module( $module_id, - AI_EXPERIMENTS_PLUGIN_URL . 'build/connectors-extended.js', + AI_EXPERIMENTS_PLUGIN_URL . 'assets/js/connectors-extended.js', $deps, (string) filemtime( $script_path ) ); From bfdc8a40dd1adee3d475b3f7997ee21fc72b6ee4 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:42:06 -0500 Subject: [PATCH 12/15] fix: declare auth method on extended providers and pass keys without connectors Two issues prevented extended providers from working for text generation: 1. All extended provider classes created ProviderMetadata without declaring an authentication method. The registry's setRequestAuthenticationForProvider() checks getAuthenticationMethod() and throws if null. Added RequestAuthenticationMethod::from('api_key') to all cloud providers (all except Ollama which is endpoint-based). 2. maybe_pass_keys_to_registry() was gated behind is_connectors_supported(), which returns false on WP versions without the Connectors screen. Restructured register() so key-passing and endpoint URL setup always run when the experiment is enabled, while Connectors-specific UI/settings only run on WP 7.0+. Also adds model preference filter so extended provider models appear in get_preferred_models_for_text_generation() for title generation etc. Co-Authored-By: Claude Opus 4.6 --- .../Extended_Providers/Extended_Providers.php | 64 ++++++++++++++++--- .../CloudflareWorkersAiProvider.php | 5 +- includes/Providers/Cohere/CohereProvider.php | 5 +- .../Providers/DeepSeek/DeepSeekProvider.php | 5 +- includes/Providers/FalAi/FalAiProvider.php | 5 +- includes/Providers/Grok/GrokProvider.php | 5 +- includes/Providers/Groq/GroqProvider.php | 5 +- .../HuggingFace/HuggingFaceProvider.php | 5 +- .../OpenRouter/OpenRouterProvider.php | 5 +- 9 files changed, 87 insertions(+), 17 deletions(-) diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 5fc14dd9..d3999afd 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -158,22 +158,29 @@ public function register(): void { // the WP AI Client collects provider metadata for the credentials screen. $this->register_providers(); - // On WP 7.0+, core's _wp_register_default_connector_settings() (init:20) - // auto-discovers all registered providers and handles API key settings, - // mask filters, and key-to-registry passing. We only need to register - // non-standard settings (Cloudflare Account ID, Ollama endpoint) and - // enqueue the JS for custom icons/UI. - if ( ! $this->is_enabled() || ! $this->is_connectors_supported() ) { + // Add extended provider models to the preferred models list so that + // experiments like title generation can use them. + add_filter( 'ai_experiments_preferred_models_for_text_generation', array( $this, 'add_extended_model_preferences' ) ); + + if ( ! $this->is_enabled() ) { return; } - $this->register_extra_connector_settings(); + // Always pass keys to the registry so text generation works, + // regardless of whether the Connectors screen exists. $this->apply_endpoint_provider_urls(); + add_action( 'init', array( $this, 'maybe_pass_keys_to_registry' ), 22 ); - // Register API key settings and pass keys to registry AFTER core's init:20 + // Connectors-specific UI and settings only on WP 7.0+. + if ( ! $this->is_connectors_supported() ) { + return; + } + + $this->register_extra_connector_settings(); + + // Register API key settings AFTER core's init:20 // so we only fill in what core didn't handle (beta2 vs trunk differences). add_action( 'init', array( $this, 'maybe_register_api_key_settings' ), 21 ); - add_action( 'init', array( $this, 'maybe_pass_keys_to_registry' ), 22 ); add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_connectors_script' ) ); } @@ -189,6 +196,45 @@ private function is_connectors_supported(): bool { || function_exists( '_wp_connectors_get_provider_settings' ); } + /** + * Well-known text generation models for each extended provider. + * + * These are added to the preferred models list so that AI experiments + * (title generation, summarization, etc.) can use extended providers. + * + * @var array> + */ + private const TEXT_GENERATION_MODELS = array( + 'cohere' => array( 'command-r-plus', 'command-r', 'command' ), + 'deepseek' => array( 'deepseek-chat', 'deepseek-reasoner' ), + 'grok' => array( 'grok-2', 'grok-3-mini' ), + 'groq' => array( 'llama-3.3-70b-versatile', 'llama-3.1-8b-instant' ), + 'huggingface' => array( 'meta-llama/Llama-3.3-70B-Instruct' ), + 'openrouter' => array( 'openai/gpt-4o-mini', 'anthropic/claude-3.5-haiku' ), + ); + + /** + * Adds extended provider models to the preferred text generation models list. + * + * @param array $models The current preferred models. + * @return array The filtered preferred models. + */ + public function add_extended_model_preferences( array $models ): array { + $enabled_ids = $this->get_enabled_provider_ids(); + + foreach ( self::TEXT_GENERATION_MODELS as $provider_id => $model_ids ) { + if ( ! in_array( $provider_id, $enabled_ids, true ) ) { + continue; + } + + foreach ( $model_ids as $model_id ) { + $models[] = array( $provider_id, $model_id ); + } + } + + return $models; + } + /** * Returns the provider IDs that are currently enabled in the experiment. * diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php index c5dc8453..eef139e3 100644 --- a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use function apply_filters; @@ -108,7 +109,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'cloudflare', 'Cloudflare Workers AI', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } diff --git a/includes/Providers/Cohere/CohereProvider.php b/includes/Providers/Cohere/CohereProvider.php index 412de4c1..075b0980 100644 --- a/includes/Providers/Cohere/CohereProvider.php +++ b/includes/Providers/Cohere/CohereProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -64,7 +65,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'cohere', 'Cohere', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } diff --git a/includes/Providers/DeepSeek/DeepSeekProvider.php b/includes/Providers/DeepSeek/DeepSeekProvider.php index 8cb6f575..d5d88db7 100644 --- a/includes/Providers/DeepSeek/DeepSeekProvider.php +++ b/includes/Providers/DeepSeek/DeepSeekProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -64,7 +65,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'deepseek', 'DeepSeek', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } diff --git a/includes/Providers/FalAi/FalAiProvider.php b/includes/Providers/FalAi/FalAiProvider.php index 29fe5b4b..c46be023 100644 --- a/includes/Providers/FalAi/FalAiProvider.php +++ b/includes/Providers/FalAi/FalAiProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -77,7 +78,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'fal', 'Fal.ai', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } diff --git a/includes/Providers/Grok/GrokProvider.php b/includes/Providers/Grok/GrokProvider.php index 8635e73f..2543a3d2 100644 --- a/includes/Providers/Grok/GrokProvider.php +++ b/includes/Providers/Grok/GrokProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -64,7 +65,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'grok', 'Grok (xAI)', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } diff --git a/includes/Providers/Groq/GroqProvider.php b/includes/Providers/Groq/GroqProvider.php index 14378fb2..e1846b27 100644 --- a/includes/Providers/Groq/GroqProvider.php +++ b/includes/Providers/Groq/GroqProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -64,7 +65,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'groq', 'Groq', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } diff --git a/includes/Providers/HuggingFace/HuggingFaceProvider.php b/includes/Providers/HuggingFace/HuggingFaceProvider.php index e97f5f66..67316470 100644 --- a/includes/Providers/HuggingFace/HuggingFaceProvider.php +++ b/includes/Providers/HuggingFace/HuggingFaceProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -64,7 +65,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'huggingface', 'Hugging Face', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } diff --git a/includes/Providers/OpenRouter/OpenRouterProvider.php b/includes/Providers/OpenRouter/OpenRouterProvider.php index 80850a70..c69a523b 100644 --- a/includes/Providers/OpenRouter/OpenRouterProvider.php +++ b/includes/Providers/OpenRouter/OpenRouterProvider.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -64,7 +65,9 @@ protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata( 'openrouter', 'OpenRouter', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) ); } From 82d00834ab70ecb3337d2cafc32b43a5f4c1a2d4 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:45:05 -0500 Subject: [PATCH 13/15] fix: update Cohere model IDs to match current API catalog Cohere has renamed/versioned their models. The old IDs (command-r-plus, command-r, command) no longer exist in the API. Updated to current model IDs: command-r-08-2024, command-a-reasoning-08-2025, command-r7b-12-2024. Verified end-to-end: Cohere title generation returns a successful result using command-r-08-2024 with a real API key. Co-Authored-By: Claude Opus 4.6 --- .../Extended_Providers/Extended_Providers.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index d3999afd..3546e905 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -205,12 +205,12 @@ private function is_connectors_supported(): bool { * @var array> */ private const TEXT_GENERATION_MODELS = array( - 'cohere' => array( 'command-r-plus', 'command-r', 'command' ), - 'deepseek' => array( 'deepseek-chat', 'deepseek-reasoner' ), - 'grok' => array( 'grok-2', 'grok-3-mini' ), - 'groq' => array( 'llama-3.3-70b-versatile', 'llama-3.1-8b-instant' ), + 'cohere' => array( 'command-r-08-2024', 'command-a-reasoning-08-2025', 'command-r7b-12-2024' ), + 'deepseek' => array( 'deepseek-chat', 'deepseek-reasoner' ), + 'grok' => array( 'grok-2', 'grok-3-mini' ), + 'groq' => array( 'llama-3.3-70b-versatile', 'llama-3.1-8b-instant' ), 'huggingface' => array( 'meta-llama/Llama-3.3-70B-Instruct' ), - 'openrouter' => array( 'openai/gpt-4o-mini', 'anthropic/claude-3.5-haiku' ), + 'openrouter' => array( 'openai/gpt-4o-mini', 'anthropic/claude-3.5-haiku' ), ); /** From 23acd07c570dc7e8646764a99590961d85937eed Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:18:24 -0500 Subject: [PATCH 14/15] fix: resolve PHPCS, PHPStan, ESLint, and Plugin Check CI failures - Add ABSPATH guards to all 27 provider PHP files - Fix PHPStan type errors: list vs array casts, null coalescing, missing iterable types, undefined method ignores - Fix PHPCS: short ternary, alignment, early exit patterns, empty catch, unused import - Fix ESLint: conditional hooks, unused vars, missing text domains, experimental API imports, prettier formatting - Remove unused bootstrap.php import Co-Authored-By: Claude Opus 4.6 --- assets/js/connectors-extended.js | 96 +-- build/connectors-extended.js | 659 ------------------ includes/Admin/Provider_Metadata_Registry.php | 15 +- .../Extended_Providers/Extended_Providers.php | 63 +- ...udflareWorkersAiModelMetadataDirectory.php | 4 + .../CloudflareWorkersAiProvider.php | 4 + ...CloudflareWorkersAiTextGenerationModel.php | 7 +- .../Cohere/CohereModelMetadataDirectory.php | 8 +- includes/Providers/Cohere/CohereProvider.php | 4 + .../Cohere/CohereTextGenerationModel.php | 7 +- .../DeepSeekModelMetadataDirectory.php | 30 +- .../Providers/DeepSeek/DeepSeekProvider.php | 4 + .../DeepSeek/DeepSeekTextGenerationModel.php | 4 + .../FalAi/FalAiImageGenerationModel.php | 6 +- .../FalAi/FalAiModelMetadataDirectory.php | 6 +- includes/Providers/FalAi/FalAiProvider.php | 4 + .../Grok/GrokModelMetadataDirectory.php | 10 +- includes/Providers/Grok/GrokProvider.php | 4 + .../Grok/GrokTextGenerationModel.php | 4 + .../Groq/GroqModelMetadataDirectory.php | 8 +- includes/Providers/Groq/GroqProvider.php | 4 + .../Groq/GroqTextGenerationModel.php | 4 + .../HuggingFaceModelMetadataDirectory.php | 8 +- .../HuggingFace/HuggingFaceProvider.php | 4 + .../HuggingFaceTextGenerationModel.php | 4 + .../Ollama/OllamaModelMetadataDirectory.php | 8 +- includes/Providers/Ollama/OllamaProvider.php | 4 + .../Ollama/OllamaTextGenerationModel.php | 7 +- .../OpenRouterModelMetadataDirectory.php | 8 +- .../OpenRouter/OpenRouterProvider.php | 4 + .../OpenRouterTextGenerationModel.php | 4 + includes/bootstrap.php | 1 - 32 files changed, 241 insertions(+), 766 deletions(-) delete mode 100644 build/connectors-extended.js diff --git a/assets/js/connectors-extended.js b/assets/js/connectors-extended.js index 9bd6f82f..7fe90e18 100644 --- a/assets/js/connectors-extended.js +++ b/assets/js/connectors-extended.js @@ -5,16 +5,26 @@ * Provider data is injected by PHP into window.wpAiExtendedConnectors. * * @since 0.4.0 - * @package WordPress\AI\Experiments\Extended_Providers + * @package */ +/* eslint-disable @wordpress/no-unsafe-wp-apis, import/no-unresolved */ +/** + * WordPress dependencies + */ import { __experimentalRegisterConnector as registerConnector, __experimentalConnectorItem as ConnectorItem, __experimentalDefaultConnectorSettings as DefaultConnectorSettings, } from '@wordpress/connectors'; +/* eslint-enable @wordpress/no-unsafe-wp-apis, import/no-unresolved */ -const { createElement: h, useState, useEffect, useCallback } = window.wp.element; +const { + createElement: h, + useState, + useEffect, + useCallback, +} = window.wp.element; const { Button, TextControl, @@ -23,7 +33,7 @@ const { ExternalLink, } = window.wp.components; const apiFetch = window.wp.apiFetch; -const { __, sprintf } = window.wp.i18n; +const { __ } = window.wp.i18n; /** * Provider data injected from PHP. @@ -198,7 +208,7 @@ function ConnectedBadge() { whiteSpace: 'nowrap', }, }, - __( 'Connected' ) + __( 'Connected', 'ai' ) ); } @@ -214,7 +224,6 @@ function CloudflareConnectorSettings( { helpUrl, helpLabel, readOnly = false, - accountIdSettingName, } ) { const [ apiKey, setApiKey ] = useState( initialApiKey ); const [ accountId, setAccountId ] = useState( initialAccountId ); @@ -233,7 +242,8 @@ function CloudflareConnectorSettings( { error instanceof Error ? error.message : __( - 'It was not possible to connect to the provider using this key.' + 'It was not possible to connect to the provider using this key.', + 'ai' ) ); } finally { @@ -247,7 +257,8 @@ function CloudflareConnectorSettings( { window.wp.element.Fragment, null, __( - 'Your API key is stored securely. You can reset it at' + 'Your API key is stored securely. You can reset it at', + 'ai' ), ' ', helpUrl @@ -256,17 +267,13 @@ function CloudflareConnectorSettings( { ); } if ( saveError ) { - return h( - 'span', - { style: { color: '#cc1818' } }, - saveError - ); + return h( 'span', { style: { color: '#cc1818' } }, saveError ); } if ( helpUrl ) { return h( window.wp.element.Fragment, null, - __( 'Get your API key at' ), + __( 'Get your API key at', 'ai' ), ' ', h( ExternalLink, { href: helpUrl }, helpLinkLabel ) ); @@ -286,7 +293,7 @@ function CloudflareConnectorSettings( { h( TextControl, { __nextHasNoMarginBottom: true, __next40pxDefaultSize: true, - label: __( 'Account ID' ), + label: __( 'Account ID', 'ai' ), value: accountId, onChange: ( v ) => { if ( ! readOnly ) { @@ -297,13 +304,14 @@ function CloudflareConnectorSettings( { placeholder: 'YOUR_ACCOUNT_ID', disabled: readOnly || isSaving, help: __( - 'Found in the Cloudflare dashboard under Workers & Pages.' + 'Found in the Cloudflare dashboard under Workers & Pages.', + 'ai' ), } ), h( TextControl, { __nextHasNoMarginBottom: true, __next40pxDefaultSize: true, - label: __( 'API Key' ), + label: __( 'API Key', 'ai' ), value: apiKey, onChange: ( v ) => { if ( ! readOnly ) { @@ -323,7 +331,7 @@ function CloudflareConnectorSettings( { isDestructive: true, onClick: onRemove, }, - __( 'Remove and replace' ) + __( 'Remove and replace', 'ai' ) ) : h( HStack, @@ -338,7 +346,7 @@ function CloudflareConnectorSettings( { isBusy: isSaving, onClick: handleSave, }, - __( 'Save' ) + __( 'Save', 'ai' ) ) ) ); @@ -352,8 +360,6 @@ function OllamaConnectorSettings( { onSave, onRemove, initialEndpoint = '', - helpUrl, - helpLabel, readOnly = false, } ) { const [ endpoint, setEndpoint ] = useState( initialEndpoint ); @@ -369,7 +375,7 @@ function OllamaConnectorSettings( { setSaveError( error instanceof Error ? error.message - : __( 'Could not save the endpoint.' ) + : __( 'Could not save the endpoint.', 'ai' ) ); } finally { setIsSaving( false ); @@ -378,13 +384,14 @@ function OllamaConnectorSettings( { const getHelp = () => { if ( readOnly ) { - return __( 'Your endpoint is configured.' ); + return __( 'Your endpoint is configured.', 'ai' ); } if ( saveError ) { return h( 'span', { style: { color: '#cc1818' } }, saveError ); } return __( - 'Enter the URL where Ollama is running. Default is http://localhost:11434' + 'Enter the URL where Ollama is running. Default is http://localhost:11434', + 'ai' ); }; @@ -400,7 +407,7 @@ function OllamaConnectorSettings( { h( TextControl, { __nextHasNoMarginBottom: true, __next40pxDefaultSize: true, - label: __( 'Endpoint URL' ), + label: __( 'Endpoint URL', 'ai' ), value: endpoint, onChange: ( v ) => { if ( ! readOnly ) { @@ -420,7 +427,7 @@ function OllamaConnectorSettings( { isDestructive: true, onClick: onRemove, }, - __( 'Remove and replace' ) + __( 'Remove and replace', 'ai' ) ) : h( HStack, @@ -435,7 +442,7 @@ function OllamaConnectorSettings( { isBusy: isSaving, onClick: handleSave, }, - __( 'Save' ) + __( 'Save', 'ai' ) ) ) ); @@ -449,11 +456,13 @@ function ExtendedProviderConnector( { label, description, slug } ) { const provider = providers.find( ( p ) => 'ai-experiments/' + p.id === slug ); - if ( ! provider ) { - return null; - } - const { id, settingName, helpUrl, helpLabel, type } = provider; + const providerData = provider || {}; + const id = providerData.id || ''; + const settingName = providerData.settingName || ''; + const helpUrl = providerData.helpUrl || ''; + const helpLabel = providerData.helpLabel || ''; + const type = providerData.type || ''; const isCloudflare = id === 'cloudflare'; const isEndpoint = type === 'endpoint'; const accountIdSetting = isCloudflare ? 'ai_cloudflare_account_id' : null; @@ -463,8 +472,7 @@ function ExtendedProviderConnector( { label, description, slug } ) { const [ currentAccountId, setCurrentAccountId ] = useState( '' ); const [ isLoading, setIsLoading ] = useState( true ); - const isConnected = - currentValue !== '' && currentValue !== 'invalid_key'; + const isConnected = currentValue !== '' && currentValue !== 'invalid_key'; const fetchValue = useCallback( async () => { try { @@ -490,6 +498,10 @@ function ExtendedProviderConnector( { label, description, slug } ) { fetchValue(); }, [ fetchValue ] ); + if ( ! provider ) { + return null; + } + const saveValue = async ( value, accountId ) => { const data = { [ settingName ]: value }; let fields = settingName; @@ -505,9 +517,7 @@ function ExtendedProviderConnector( { label, description, slug } ) { // If the key was submitted but the response is empty, the save failed. if ( ! isEndpoint && value && ! result[ settingName ] ) { throw new Error( - __( - 'It was not possible to save the API key.' - ) + __( 'It was not possible to save the API key.', 'ai' ) ); } setCurrentValue( result[ settingName ] || '' ); @@ -536,15 +546,15 @@ function ExtendedProviderConnector( { label, description, slug } ) { const getButtonLabel = () => { if ( isLoading ) { - return __( 'Checking\u2026' ); + return __( 'Checking\u2026', 'ai' ); } if ( isExpanded ) { - return __( 'Cancel' ); + return __( 'Cancel', 'ai' ); } if ( isConnected ) { - return __( 'Edit' ); + return __( 'Edit', 'ai' ); } - return __( 'Set up' ); + return __( 'Set up', 'ai' ); }; const IconComponent = ICONS[ id ]; @@ -558,8 +568,6 @@ function ExtendedProviderConnector( { label, description, slug } ) { return h( OllamaConnectorSettings, { key: isConnected ? 'connected' : 'setup', initialEndpoint: currentValue, - helpUrl, - helpLabel, readOnly: isConnected, onRemove: removeValue, onSave: async ( endpoint ) => { @@ -577,7 +585,6 @@ function ExtendedProviderConnector( { label, description, slug } ) { helpUrl, helpLabel, readOnly: isConnected, - accountIdSettingName: accountIdSetting, onRemove: removeValue, onSave: async ( apiKey, accountId ) => { await saveValue( apiKey, accountId ); @@ -617,10 +624,7 @@ function ExtendedProviderConnector( { label, description, slug } ) { isExpanded || isConnected ? 'tertiary' : 'secondary', - size: - isExpanded || isConnected - ? undefined - : 'compact', + size: isExpanded || isConnected ? undefined : 'compact', onClick: handleButtonClick, disabled: isLoading, 'aria-expanded': isExpanded, diff --git a/build/connectors-extended.js b/build/connectors-extended.js deleted file mode 100644 index 9bd6f82f..00000000 --- a/build/connectors-extended.js +++ /dev/null @@ -1,659 +0,0 @@ -/** - * Extended Providers – WP 7.0 Connectors Integration - * - * Registers additional AI provider connectors on the Settings > Connectors page. - * Provider data is injected by PHP into window.wpAiExtendedConnectors. - * - * @since 0.4.0 - * @package WordPress\AI\Experiments\Extended_Providers - */ - -import { - __experimentalRegisterConnector as registerConnector, - __experimentalConnectorItem as ConnectorItem, - __experimentalDefaultConnectorSettings as DefaultConnectorSettings, -} from '@wordpress/connectors'; - -const { createElement: h, useState, useEffect, useCallback } = window.wp.element; -const { - Button, - TextControl, - __experimentalHStack: HStack, - __experimentalVStack: VStack, - ExternalLink, -} = window.wp.components; -const apiFetch = window.wp.apiFetch; -const { __, sprintf } = window.wp.i18n; - -/** - * Provider data injected from PHP. - * - * @type {Array<{id: string, label: string, description: string, settingName: string, helpUrl: string, helpLabel: string, extraFields: Array}>} - */ -const providers = window.wpAiExtendedConnectors || []; - -/* ────────────────────────────────────────────── - * Provider SVG icons (40×40, matching core style) - * Extracted from feature/providers branch TSX. - * ────────────────────────────────────────────── */ - -function svg( viewBox, ...children ) { - return h( - 'svg', - { - width: '40', - height: '40', - viewBox, - fill: 'currentColor', - xmlns: 'http://www.w3.org/2000/svg', - }, - ...children - ); -} - -function path( d, extra ) { - return h( 'path', { d, ...extra } ); -} - -const ICONS = { - cloudflare: () => - h( - 'svg', - { - width: '40', - height: '40', - viewBox: '0 0 24 24', - fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', - }, - path( - 'M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437', - { fill: '#F38020' } - ), - path( - 'M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777', - { fill: '#FCAD32' } - ) - ), - - cohere: () => - h( - 'svg', - { - width: '40', - height: '40', - viewBox: '0 0 24 24', - fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', - }, - path( - 'M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z', - { fill: '#39594D', fillRule: 'evenodd', clipRule: 'evenodd' } - ), - path( - 'M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z', - { fill: '#D18EE2', fillRule: 'evenodd', clipRule: 'evenodd' } - ), - path( - 'M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z', - { fill: '#FF7759' } - ) - ), - - deepseek: () => - svg( - '0 0 24 24', - path( - 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z' - ) - ), - - fal: () => - svg( - '0 0 24 24', - path( - 'M15.477 0c.415 0 .749.338.788.752a7.775 7.775 0 006.985 6.984c.413.04.752.373.752.788v6.952c0 .415-.338.748-.752.788a7.775 7.775 0 00-6.985 6.984c-.04.414-.373.752-.788.752H8.525c-.416 0-.749-.338-.789-.752a7.775 7.775 0 00-6.984-6.984c-.414-.04-.752-.373-.752-.788V8.524c0-.415.338-.748.752-.788A7.775 7.775 0 007.736.752C7.776.338 8.11 0 8.526 0h6.95zM4.819 11.98a7.226 7.226 0 007.223 7.23 7.226 7.226 0 007.223-7.23c0-3.994-3.234-7.23-7.223-7.23a7.227 7.227 0 00-7.223 7.23z', - { fillRule: 'evenodd', clipRule: 'evenodd' } - ) - ), - - grok: () => - svg( - '0 0 24 24', - path( - 'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815' - ) - ), - - groq: () => - svg( - '0 0 24 24', - path( - 'M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z' - ) - ), - - huggingface: () => - h( - 'svg', - { - width: '40', - height: '40', - viewBox: '0 0 24 24', - fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', - }, - path( - 'M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z', - { fill: '#FF9D0B' } - ), - path( - 'M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z', - { fill: '#FFD21E' } - ), - path( - 'M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z', - { fill: '#FF323D' } - ), - path( - 'M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z', - { fill: '#3A3B45' } - ) - ), - - ollama: () => - svg( - '0 0 24 24', - path( - 'M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z', - { fillRule: 'evenodd' } - ) - ), - - openrouter: () => - svg( - '0 0 24 24', - path( - 'M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z', - { fillRule: 'evenodd' } - ) - ), -}; - -/* ────────────────────────────────────────────── - * Shared UI helpers - * ────────────────────────────────────────────── */ - -function ConnectedBadge() { - return h( - 'span', - { - style: { - color: '#345b37', - backgroundColor: '#eff8f0', - padding: '4px 12px', - borderRadius: '2px', - fontSize: '13px', - fontWeight: 500, - whiteSpace: 'nowrap', - }, - }, - __( 'Connected' ) - ); -} - -/* ────────────────────────────────────────────── - * Cloudflare custom settings (Account ID + API Key) - * ────────────────────────────────────────────── */ - -function CloudflareConnectorSettings( { - onSave, - onRemove, - initialApiKey = '', - initialAccountId = '', - helpUrl, - helpLabel, - readOnly = false, - accountIdSettingName, -} ) { - const [ apiKey, setApiKey ] = useState( initialApiKey ); - const [ accountId, setAccountId ] = useState( initialAccountId ); - const [ isSaving, setIsSaving ] = useState( false ); - const [ saveError, setSaveError ] = useState( null ); - - const helpLinkLabel = helpLabel || helpUrl?.replace( /^https?:\/\//, '' ); - - const handleSave = async () => { - setSaveError( null ); - setIsSaving( true ); - try { - await onSave?.( apiKey, accountId ); - } catch ( error ) { - setSaveError( - error instanceof Error - ? error.message - : __( - 'It was not possible to connect to the provider using this key.' - ) - ); - } finally { - setIsSaving( false ); - } - }; - - const getHelp = () => { - if ( readOnly ) { - return h( - window.wp.element.Fragment, - null, - __( - 'Your API key is stored securely. You can reset it at' - ), - ' ', - helpUrl - ? h( ExternalLink, { href: helpUrl }, helpLinkLabel ) - : null - ); - } - if ( saveError ) { - return h( - 'span', - { style: { color: '#cc1818' } }, - saveError - ); - } - if ( helpUrl ) { - return h( - window.wp.element.Fragment, - null, - __( 'Get your API key at' ), - ' ', - h( ExternalLink, { href: helpUrl }, helpLinkLabel ) - ); - } - return undefined; - }; - - return h( - VStack, - { - spacing: 4, - className: 'connector-settings', - style: readOnly - ? { '--wp-components-color-background': '#f0f0f0' } - : undefined, - }, - h( TextControl, { - __nextHasNoMarginBottom: true, - __next40pxDefaultSize: true, - label: __( 'Account ID' ), - value: accountId, - onChange: ( v ) => { - if ( ! readOnly ) { - setSaveError( null ); - setAccountId( v ); - } - }, - placeholder: 'YOUR_ACCOUNT_ID', - disabled: readOnly || isSaving, - help: __( - 'Found in the Cloudflare dashboard under Workers & Pages.' - ), - } ), - h( TextControl, { - __nextHasNoMarginBottom: true, - __next40pxDefaultSize: true, - label: __( 'API Key' ), - value: apiKey, - onChange: ( v ) => { - if ( ! readOnly ) { - setSaveError( null ); - setApiKey( v ); - } - }, - placeholder: 'YOUR_API_KEY', - disabled: readOnly || isSaving, - help: getHelp(), - } ), - readOnly - ? h( - Button, - { - variant: 'link', - isDestructive: true, - onClick: onRemove, - }, - __( 'Remove and replace' ) - ) - : h( - HStack, - { justify: 'flex-start' }, - h( - Button, - { - __next40pxDefaultSize: true, - variant: 'primary', - disabled: ! apiKey || ! accountId || isSaving, - accessibleWhenDisabled: true, - isBusy: isSaving, - onClick: handleSave, - }, - __( 'Save' ) - ) - ) - ); -} - -/* ────────────────────────────────────────────── - * Ollama custom settings (Endpoint URL only, no API key) - * ────────────────────────────────────────────── */ - -function OllamaConnectorSettings( { - onSave, - onRemove, - initialEndpoint = '', - helpUrl, - helpLabel, - readOnly = false, -} ) { - const [ endpoint, setEndpoint ] = useState( initialEndpoint ); - const [ isSaving, setIsSaving ] = useState( false ); - const [ saveError, setSaveError ] = useState( null ); - - const handleSave = async () => { - setSaveError( null ); - setIsSaving( true ); - try { - await onSave?.( endpoint ); - } catch ( error ) { - setSaveError( - error instanceof Error - ? error.message - : __( 'Could not save the endpoint.' ) - ); - } finally { - setIsSaving( false ); - } - }; - - const getHelp = () => { - if ( readOnly ) { - return __( 'Your endpoint is configured.' ); - } - if ( saveError ) { - return h( 'span', { style: { color: '#cc1818' } }, saveError ); - } - return __( - 'Enter the URL where Ollama is running. Default is http://localhost:11434' - ); - }; - - return h( - VStack, - { - spacing: 4, - className: 'connector-settings', - style: readOnly - ? { '--wp-components-color-background': '#f0f0f0' } - : undefined, - }, - h( TextControl, { - __nextHasNoMarginBottom: true, - __next40pxDefaultSize: true, - label: __( 'Endpoint URL' ), - value: endpoint, - onChange: ( v ) => { - if ( ! readOnly ) { - setSaveError( null ); - setEndpoint( v ); - } - }, - placeholder: 'http://localhost:11434', - disabled: readOnly || isSaving, - help: getHelp(), - } ), - readOnly - ? h( - Button, - { - variant: 'link', - isDestructive: true, - onClick: onRemove, - }, - __( 'Remove and replace' ) - ) - : h( - HStack, - { justify: 'flex-start' }, - h( - Button, - { - __next40pxDefaultSize: true, - variant: 'primary', - disabled: ! endpoint || isSaving, - accessibleWhenDisabled: true, - isBusy: isSaving, - onClick: handleSave, - }, - __( 'Save' ) - ) - ) - ); -} - -/* ────────────────────────────────────────────── - * Generic extended provider connector component - * ────────────────────────────────────────────── */ - -function ExtendedProviderConnector( { label, description, slug } ) { - const provider = providers.find( - ( p ) => 'ai-experiments/' + p.id === slug - ); - if ( ! provider ) { - return null; - } - - const { id, settingName, helpUrl, helpLabel, type } = provider; - const isCloudflare = id === 'cloudflare'; - const isEndpoint = type === 'endpoint'; - const accountIdSetting = isCloudflare ? 'ai_cloudflare_account_id' : null; - - const [ isExpanded, setIsExpanded ] = useState( false ); - const [ currentValue, setCurrentValue ] = useState( '' ); - const [ currentAccountId, setCurrentAccountId ] = useState( '' ); - const [ isLoading, setIsLoading ] = useState( true ); - - const isConnected = - currentValue !== '' && currentValue !== 'invalid_key'; - - const fetchValue = useCallback( async () => { - try { - let fields = settingName; - if ( accountIdSetting ) { - fields += ',' + accountIdSetting; - } - const settings = await apiFetch( { - path: '/wp/v2/settings?_fields=' + fields, - } ); - const val = settings[ settingName ] || ''; - setCurrentValue( val === 'invalid_key' ? '' : val ); - if ( accountIdSetting ) { - setCurrentAccountId( settings[ accountIdSetting ] || '' ); - } - } catch ( e ) { - // Setting may not be registered yet. - } - setIsLoading( false ); - }, [ settingName, accountIdSetting ] ); - - useEffect( () => { - fetchValue(); - }, [ fetchValue ] ); - - const saveValue = async ( value, accountId ) => { - const data = { [ settingName ]: value }; - let fields = settingName; - if ( accountIdSetting && accountId !== undefined ) { - data[ accountIdSetting ] = accountId; - fields += ',' + accountIdSetting; - } - const result = await apiFetch( { - method: 'POST', - path: '/wp/v2/settings?_fields=' + fields, - data, - } ); - // If the key was submitted but the response is empty, the save failed. - if ( ! isEndpoint && value && ! result[ settingName ] ) { - throw new Error( - __( - 'It was not possible to save the API key.' - ) - ); - } - setCurrentValue( result[ settingName ] || '' ); - if ( accountIdSetting ) { - setCurrentAccountId( result[ accountIdSetting ] || '' ); - } - }; - - const removeValue = async () => { - const data = { [ settingName ]: '' }; - let fields = settingName; - if ( accountIdSetting ) { - data[ accountIdSetting ] = ''; - fields += ',' + accountIdSetting; - } - await apiFetch( { - method: 'POST', - path: '/wp/v2/settings?_fields=' + fields, - data, - } ); - setCurrentValue( '' ); - setCurrentAccountId( '' ); - }; - - const handleButtonClick = () => setIsExpanded( ! isExpanded ); - - const getButtonLabel = () => { - if ( isLoading ) { - return __( 'Checking\u2026' ); - } - if ( isExpanded ) { - return __( 'Cancel' ); - } - if ( isConnected ) { - return __( 'Edit' ); - } - return __( 'Set up' ); - }; - - const IconComponent = ICONS[ id ]; - - const renderSettings = () => { - if ( ! isExpanded ) { - return null; - } - - if ( isEndpoint ) { - return h( OllamaConnectorSettings, { - key: isConnected ? 'connected' : 'setup', - initialEndpoint: currentValue, - helpUrl, - helpLabel, - readOnly: isConnected, - onRemove: removeValue, - onSave: async ( endpoint ) => { - await saveValue( endpoint ); - setIsExpanded( false ); - }, - } ); - } - - if ( isCloudflare ) { - return h( CloudflareConnectorSettings, { - key: isConnected ? 'connected' : 'setup', - initialApiKey: currentValue, - initialAccountId: currentAccountId, - helpUrl, - helpLabel, - readOnly: isConnected, - accountIdSettingName: accountIdSetting, - onRemove: removeValue, - onSave: async ( apiKey, accountId ) => { - await saveValue( apiKey, accountId ); - setIsExpanded( false ); - }, - } ); - } - - return h( DefaultConnectorSettings, { - key: isConnected ? 'connected' : 'setup', - initialValue: currentValue, - helpUrl, - helpLabel, - readOnly: isConnected, - onRemove: removeValue, - onSave: async ( apiKey ) => { - await saveValue( apiKey ); - setIsExpanded( false ); - }, - } ); - }; - - return h( - ConnectorItem, - { - icon: IconComponent ? h( IconComponent ) : undefined, - name: label, - description, - actionArea: h( - HStack, - { spacing: 3, expanded: false }, - isConnected && h( ConnectedBadge ), - h( - Button, - { - variant: - isExpanded || isConnected - ? 'tertiary' - : 'secondary', - size: - isExpanded || isConnected - ? undefined - : 'compact', - onClick: handleButtonClick, - disabled: isLoading, - 'aria-expanded': isExpanded, - }, - getButtonLabel() - ) - ), - }, - renderSettings() - ); -} - -/* ────────────────────────────────────────────── - * Registration – deferred so core's 3 defaults register first. - * ────────────────────────────────────────────── */ - -/** - * Defer registration until after core's route modules have loaded. - * - * Core's connectors-home/content.js registers the 3 default connectors - * (OpenAI, Claude, Gemini) during route module initialisation. We use - * wp.domReady + requestAnimationFrame to ensure the Redux store already - * contains the defaults, so our extended providers appear below them - * in insertion order. - */ -window.wp.domReady( () => { - requestAnimationFrame( () => { - providers.forEach( ( provider ) => { - registerConnector( 'ai-experiments/' + provider.id, { - label: provider.label, - description: provider.description, - render: ExtendedProviderConnector, - } ); - } ); - } ); -} ); diff --git a/includes/Admin/Provider_Metadata_Registry.php b/includes/Admin/Provider_Metadata_Registry.php index b455d1cc..cacf6b0f 100644 --- a/includes/Admin/Provider_Metadata_Registry.php +++ b/includes/Admin/Provider_Metadata_Registry.php @@ -79,24 +79,29 @@ public static function get_metadata(): array { private static function get_initials( string $name ): string { $parts = preg_split( '/\s+/', trim( $name ) ); if ( empty( $parts ) ) { - return strtoupper( substr( $name, 0, 2 ) ); + $fallback = substr( $name, 0, 2 ); + return strtoupper( false !== $fallback ? $fallback : '' ); } $initials = ''; foreach ( $parts as $part ) { - $initials .= strtoupper( substr( $part, 0, 1 ) ); + $char = substr( $part, 0, 1 ); + $initials .= strtoupper( false !== $char ? $char : '' ); if ( strlen( $initials ) >= 2 ) { break; } } - return substr( $initials, 0, 2 ); + $result = substr( $initials, 0, 2 ); + return false !== $result ? $result : ''; } /** * Retrieves model metadata for a provider. * - * @param string $provider_class Provider class name. + * @param string $provider_class Provider class name. + * @param string $provider_id Provider identifier. + * @param array $credentials Provider credentials. * @return array> */ private static function get_models_for_provider( string $provider_class, string $provider_id, array $credentials ): array { @@ -107,7 +112,7 @@ private static function get_models_for_provider( string $provider_class, string $cache_key = self::get_models_cache_key( $provider_id, $credentials[ $provider_id ] ?? '' ); if ( $cache_key ) { $cached = get_transient( $cache_key ); - if ( false !== $cached ) { + if ( false !== $cached && is_array( $cached ) ) { return $cached; } } diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php index 3546e905..ef080f51 100644 --- a/includes/Experiments/Extended_Providers/Extended_Providers.php +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -81,6 +81,7 @@ protected function load_experiment_metadata(): array { 'id' => 'extended-providers', 'label' => __( 'Extended Providers', 'ai' ), 'description' => __( 'Registers additional AI providers for experimentation without affecting the core set.', 'ai' ), + 'category' => '', ); } @@ -204,6 +205,7 @@ private function is_connectors_supported(): bool { * * @var array> */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive with array constant. private const TEXT_GENERATION_MODELS = array( 'cohere' => array( 'command-r-08-2024', 'command-a-reasoning-08-2025', 'command-r7b-12-2024' ), 'deepseek' => array( 'deepseek-chat', 'deepseek-reasoner' ), @@ -290,20 +292,22 @@ private function register_extra_connector_settings(): void { } // Ollama is endpoint-based (local provider, no API key). - if ( in_array( 'ollama', $enabled_ids, true ) ) { - register_setting( - 'connectors', - 'ai_ollama_endpoint', - array( - 'type' => 'string', - 'label' => __( 'Ollama Endpoint URL', 'ai' ), - 'description' => __( 'Endpoint URL for the Ollama provider.', 'ai' ), - 'default' => '', - 'show_in_rest' => true, - 'sanitize_callback' => 'sanitize_url', - ) - ); + if ( ! in_array( 'ollama', $enabled_ids, true ) ) { + return; } + + register_setting( + 'connectors', + 'ai_ollama_endpoint', + array( + 'type' => 'string', + 'label' => __( 'Ollama Endpoint URL', 'ai' ), + 'description' => __( 'Endpoint URL for the Ollama provider.', 'ai' ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => 'sanitize_url', + ) + ); } /** @@ -380,9 +384,12 @@ public function maybe_register_api_key_settings(): void { ); // Add mask filter (only one instance since we checked core didn't register). - if ( function_exists( '_wp_connectors_mask_api_key' ) ) { - add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); + if ( ! function_exists( '_wp_connectors_mask_api_key' ) ) { + continue; } + + // @phpstan-ignore-next-line -- Function name string is a valid callable for add_filter. + add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } @@ -411,14 +418,16 @@ public function maybe_pass_keys_to_registry(): void { } // Skip if already configured (core handled it at init:20). - if ( $registry->hasProvider( $provider_id ) ) { - try { - if ( $registry->isProviderConfigured( $provider_id ) ) { - continue; - } - } catch ( \Throwable $t ) { - // isProviderConfigured may throw; continue to try setting key. + if ( ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + try { + if ( $registry->isProviderConfigured( $provider_id ) ) { + continue; } + } catch ( \Throwable $t ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- isProviderConfigured may throw; continue to try setting key. + unset( $t ); } $setting_name = "connectors_ai_{$provider_id}_api_key"; @@ -430,7 +439,7 @@ public function maybe_pass_keys_to_registry(): void { $api_key = (string) get_option( $setting_name, '' ); } - if ( '' === $api_key || ! $registry->hasProvider( $provider_id ) ) { + if ( '' === $api_key ) { continue; } @@ -545,7 +554,7 @@ public function register_providers(): void { $registry = AiClient::defaultRegistry(); foreach ( $provider_classes as $class_name ) { - if ( ! is_string( $class_name ) || '' === $class_name ) { + if ( '' === $class_name ) { continue; } @@ -569,6 +578,7 @@ public function register_providers(): void { } try { + /** @var class-string<\WordPress\AiClient\Providers\Contracts\ProviderInterface> $class_name */ $registry->registerProvider( $class_name ); } catch ( \Throwable $t ) { _doing_it_wrong( @@ -658,6 +668,8 @@ public function has_settings(): bool { /** * {@inheritDoc} + * + * @return array */ public function get_entry_points(): array { return array( @@ -691,7 +703,7 @@ private function get_provider_classes(): array { array_filter( array_map( static function ( $class_name ) { - return is_string( $class_name ) ? trim( $class_name ) : ''; + return is_string( $class_name ) ? trim( $class_name ) : ''; // @phpstan-ignore function.alreadyNarrowedType }, (array) $providers ) @@ -789,6 +801,7 @@ public function sanitize_provider_selection( $value ): array { continue; } + // @phpstan-ignore-next-line -- Template type T in rest_sanitize_boolean cannot be resolved statically. $sanitized[ $class ] = rest_sanitize_boolean( $enabled ); } diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php b/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php index 8b2ba030..b8fc35f4 100644 --- a/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Cloudflare; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php index eef139e3..e0a0fe9c 100644 --- a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Cloudflare; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php index b9221577..00fd3a83 100644 --- a/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Cloudflare; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -53,6 +57,7 @@ public function generateTextResult( array $prompt ): GenerativeAiResult { /** * {@inheritDoc} */ + /** @phpstan-ignore missingType.iterableValue */ public function streamGenerateTextResult( array $prompt ): \Generator { throw ResponseException::fromInvalidData( 'Cloudflare Workers AI', 'stream', 'Streaming is not implemented.' ); } @@ -174,7 +179,7 @@ private function extractTextFromMessage( Message $message ): string { * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponse( Response $response ): GenerativeAiResult { - $data = $response->getData(); + $data = $response->getData() ?? array(); if ( ! isset( $data['result']['response'] ) || ! is_string( $data['result']['response'] ) ) { throw ResponseException::fromMissingData( 'Cloudflare Workers AI', 'result.response' ); } diff --git a/includes/Providers/Cohere/CohereModelMetadataDirectory.php b/includes/Providers/Cohere/CohereModelMetadataDirectory.php index 2abcebcc..45365027 100644 --- a/includes/Providers/Cohere/CohereModelMetadataDirectory.php +++ b/includes/Providers/Cohere/CohereModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Cohere; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory; use WordPress\AiClient\Providers\Http\DTO\Request; @@ -47,7 +51,7 @@ protected function sendListModelsRequest(): array { * @return array */ private function parseResponseToModelMetadataMap( Response $response ): array { - $data = $response->getData(); + $data = $response->getData() ?? array(); if ( ! isset( $data['models'] ) || ! is_array( $data['models'] ) ) { throw ResponseException::fromMissingData( 'Cohere', 'models' ); } @@ -78,7 +82,7 @@ private function parseResponseToModelMetadataMap( Response $response ): array { $model_id, $model_name, $capabilities, - $options + $options // @phpstan-ignore argument.type ); } diff --git a/includes/Providers/Cohere/CohereProvider.php b/includes/Providers/Cohere/CohereProvider.php index 075b0980..021ea80a 100644 --- a/includes/Providers/Cohere/CohereProvider.php +++ b/includes/Providers/Cohere/CohereProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Cohere; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/Cohere/CohereTextGenerationModel.php b/includes/Providers/Cohere/CohereTextGenerationModel.php index d9a357e6..8a41a70c 100644 --- a/includes/Providers/Cohere/CohereTextGenerationModel.php +++ b/includes/Providers/Cohere/CohereTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Cohere; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -58,6 +62,7 @@ public function generateTextResult( array $prompt ): GenerativeAiResult { /** * {@inheritDoc} */ + /** @phpstan-ignore missingType.iterableValue */ public function streamGenerateTextResult( array $prompt ): \Generator { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw ResponseException::fromInvalidData( @@ -193,7 +198,7 @@ private function extractTextFromMessage( Message $message ): string { * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponseToResult( Response $response ): GenerativeAiResult { - $data = $response->getData(); + $data = $response->getData() ?? array(); $text_candidates = $this->extractTextCandidates( $data ); if ( empty( $text_candidates ) ) { diff --git a/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php b/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php index ff011245..72bad124 100644 --- a/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php +++ b/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\DeepSeek; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; @@ -42,7 +46,7 @@ protected function createRequest( HttpMethodEnum $method, string $path, array $h * {@inheritDoc} */ protected function parseResponseToModelMetadataList( Response $response ): array { - $data = $response->getData(); + $data = $response->getData() ?? array(); if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { throw ResponseException::fromMissingData( 'DeepSeek', 'data' ); } @@ -67,17 +71,19 @@ protected function parseResponseToModelMetadataList( Response $response ): array new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), ); - return array_map( - static function ( array $model ) use ( $capabilities, $options ): ModelMetadata { - $model_id = (string) $model['id']; - return new ModelMetadata( - $model_id, - $model['id'], - $capabilities, - $options - ); - }, - $data['data'] + return array_values( + array_map( + static function ( array $model ) use ( $capabilities, $options ): ModelMetadata { + $model_id = (string) $model['id']; + return new ModelMetadata( + $model_id, + $model['id'], + $capabilities, + $options + ); + }, + $data['data'] + ) ); } } diff --git a/includes/Providers/DeepSeek/DeepSeekProvider.php b/includes/Providers/DeepSeek/DeepSeekProvider.php index d5d88db7..c4bb122d 100644 --- a/includes/Providers/DeepSeek/DeepSeekProvider.php +++ b/includes/Providers/DeepSeek/DeepSeekProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\DeepSeek; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php b/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php index 240bef66..7309e773 100644 --- a/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php +++ b/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\DeepSeek; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; diff --git a/includes/Providers/FalAi/FalAiImageGenerationModel.php b/includes/Providers/FalAi/FalAiImageGenerationModel.php index 5a6f5d67..37d83b09 100644 --- a/includes/Providers/FalAi/FalAiImageGenerationModel.php +++ b/includes/Providers/FalAi/FalAiImageGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\FalAi; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; @@ -99,7 +103,7 @@ private function buildPayload( array $prompt ): array { * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponseToResult( Response $response ): GenerativeAiResult { - $response_data = $response->getData(); + $response_data = $response->getData() ?? array(); if ( ! isset( $response_data['images'] ) || ! is_array( $response_data['images'] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), 'images' ); diff --git a/includes/Providers/FalAi/FalAiModelMetadataDirectory.php b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php index 6b943669..e742779b 100644 --- a/includes/Providers/FalAi/FalAiModelMetadataDirectory.php +++ b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\FalAi; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory; @@ -77,7 +81,7 @@ protected function sendListModelsRequest(): array { $model['id'], $model['name'], $capabilities, - $this->merge_options_with_mime( $options, $model['mime'] ) + $this->merge_options_with_mime( $options, $model['mime'] ) // @phpstan-ignore argument.type ); } diff --git a/includes/Providers/FalAi/FalAiProvider.php b/includes/Providers/FalAi/FalAiProvider.php index c46be023..4dd60d15 100644 --- a/includes/Providers/FalAi/FalAiProvider.php +++ b/includes/Providers/FalAi/FalAiProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\FalAi; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/Grok/GrokModelMetadataDirectory.php b/includes/Providers/Grok/GrokModelMetadataDirectory.php index 4cd583a8..c515acc1 100644 --- a/includes/Providers/Grok/GrokModelMetadataDirectory.php +++ b/includes/Providers/Grok/GrokModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Grok; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -56,7 +60,7 @@ protected function createRequest( HttpMethodEnum $method, string $path, array $h * {@inheritDoc} */ protected function parseResponseToModelMetadataList( Response $response ): array { - $response_data = $response->getData(); + $response_data = $response->getData() ?? array(); $models_data = array(); if ( isset( $response_data['data'] ) && is_array( $response_data['data'] ) ) { @@ -79,8 +83,8 @@ protected function parseResponseToModelMetadataList( Response $response ): array $metadata[] = new ModelMetadata( $model_id, $this->format_model_name( $model_id ), - $this->determine_capabilities( $model_id ), - $this->determine_supported_options( $model_id ) + $this->determine_capabilities( $model_id ), // @phpstan-ignore argument.type + $this->determine_supported_options( $model_id ) // @phpstan-ignore argument.type ); } diff --git a/includes/Providers/Grok/GrokProvider.php b/includes/Providers/Grok/GrokProvider.php index 2543a3d2..da148672 100644 --- a/includes/Providers/Grok/GrokProvider.php +++ b/includes/Providers/Grok/GrokProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Grok; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/Grok/GrokTextGenerationModel.php b/includes/Providers/Grok/GrokTextGenerationModel.php index a2217f1e..faa3ff8a 100644 --- a/includes/Providers/Grok/GrokTextGenerationModel.php +++ b/includes/Providers/Grok/GrokTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Grok; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; diff --git a/includes/Providers/Groq/GroqModelMetadataDirectory.php b/includes/Providers/Groq/GroqModelMetadataDirectory.php index df7dbfa2..b806d28f 100644 --- a/includes/Providers/Groq/GroqModelMetadataDirectory.php +++ b/includes/Providers/Groq/GroqModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Groq; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; @@ -42,7 +46,7 @@ protected function createRequest( HttpMethodEnum $method, string $path, array $h * {@inheritDoc} */ protected function parseResponseToModelMetadataList( Response $response ): array { - $response_data = $response->getData(); + $response_data = $response->getData() ?? array(); if ( ! isset( $response_data['data'] ) || ! is_array( $response_data['data'] ) ) { throw ResponseException::fromMissingData( 'Groq', 'data' ); } @@ -68,7 +72,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array $model_id, $model_name, $capabilities, - $options + $options // @phpstan-ignore argument.type ); } diff --git a/includes/Providers/Groq/GroqProvider.php b/includes/Providers/Groq/GroqProvider.php index e1846b27..c882d058 100644 --- a/includes/Providers/Groq/GroqProvider.php +++ b/includes/Providers/Groq/GroqProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Groq; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/Groq/GroqTextGenerationModel.php b/includes/Providers/Groq/GroqTextGenerationModel.php index 5b72fa9f..a12d2605 100644 --- a/includes/Providers/Groq/GroqTextGenerationModel.php +++ b/includes/Providers/Groq/GroqTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Groq; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; diff --git a/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php b/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php index 9db06c62..a095861e 100644 --- a/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php +++ b/includes/Providers/HuggingFace/HuggingFaceModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\HuggingFace; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; @@ -42,7 +46,7 @@ protected function createRequest( HttpMethodEnum $method, string $path, array $h * {@inheritDoc} */ protected function parseResponseToModelMetadataList( Response $response ): array { - $data = $response->getData(); + $data = $response->getData() ?? array(); if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { throw ResponseException::fromMissingData( 'Hugging Face', 'data' ); } @@ -63,7 +67,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array $model['id'], $model['id'], $capabilities, - $options + $options // @phpstan-ignore argument.type ); } diff --git a/includes/Providers/HuggingFace/HuggingFaceProvider.php b/includes/Providers/HuggingFace/HuggingFaceProvider.php index 67316470..3b2b6636 100644 --- a/includes/Providers/HuggingFace/HuggingFaceProvider.php +++ b/includes/Providers/HuggingFace/HuggingFaceProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\HuggingFace; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php b/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php index 454fb8cf..f57a2826 100644 --- a/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php +++ b/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\HuggingFace; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; diff --git a/includes/Providers/Ollama/OllamaModelMetadataDirectory.php b/includes/Providers/Ollama/OllamaModelMetadataDirectory.php index 085375ca..2b1d257d 100644 --- a/includes/Providers/Ollama/OllamaModelMetadataDirectory.php +++ b/includes/Providers/Ollama/OllamaModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Ollama; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; @@ -35,7 +39,7 @@ protected function sendListModelsRequest(): array { ); $response = $this->getHttpTransporter()->send( $request ); - $this->throwIfNotSuccessful( $response ); + $this->throwIfNotSuccessful( $response ); // @phpstan-ignore method.notFound return $this->parseResponse( $response ); } @@ -48,7 +52,7 @@ protected function sendListModelsRequest(): array { * @return array */ private function parseResponse( Response $response ): array { - $data = $response->getData(); + $data = $response->getData() ?? array(); if ( ! isset( $data['models'] ) || ! is_array( $data['models'] ) ) { throw ResponseException::fromMissingData( 'Ollama', 'models' ); } diff --git a/includes/Providers/Ollama/OllamaProvider.php b/includes/Providers/Ollama/OllamaProvider.php index 258bd0d3..5ee17f88 100644 --- a/includes/Providers/Ollama/OllamaProvider.php +++ b/includes/Providers/Ollama/OllamaProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Ollama; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/Ollama/OllamaTextGenerationModel.php b/includes/Providers/Ollama/OllamaTextGenerationModel.php index fb23967e..3675fa5b 100644 --- a/includes/Providers/Ollama/OllamaTextGenerationModel.php +++ b/includes/Providers/Ollama/OllamaTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\Ollama; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -53,6 +57,7 @@ public function generateTextResult( array $prompt ): GenerativeAiResult { /** * {@inheritDoc} */ + /** @phpstan-ignore missingType.iterableValue */ public function streamGenerateTextResult( array $prompt ): \Generator { throw ResponseException::fromInvalidData( 'Ollama', 'stream', 'Streaming not implemented.' ); } @@ -150,7 +155,7 @@ private function extractTextFromMessage( Message $message ): string { * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult */ private function parseResponse( Response $response ): GenerativeAiResult { - $data = $response->getData(); + $data = $response->getData() ?? array(); if ( ! isset( $data['message']['content'] ) || ! is_string( $data['message']['content'] ) ) { throw ResponseException::fromMissingData( 'Ollama', 'message.content' ); } diff --git a/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php index 4599b586..0966bdee 100644 --- a/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php +++ b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\OpenRouter; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; @@ -41,7 +45,7 @@ protected function createRequest( HttpMethodEnum $method, string $path, array $h * {@inheritDoc} */ protected function parseResponseToModelMetadataList( Response $response ): array { - $data = $response->getData(); + $data = $response->getData() ?? array(); if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { throw ResponseException::fromMissingData( 'OpenRouter', 'data' ); } @@ -62,7 +66,7 @@ protected function parseResponseToModelMetadataList( Response $response ): array $model['id'], $model['name'] ?? $model['id'], $capabilities, - $options + $options // @phpstan-ignore argument.type ); } diff --git a/includes/Providers/OpenRouter/OpenRouterProvider.php b/includes/Providers/OpenRouter/OpenRouterProvider.php index c69a523b..730d2b09 100644 --- a/includes/Providers/OpenRouter/OpenRouterProvider.php +++ b/includes/Providers/OpenRouter/OpenRouterProvider.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\OpenRouter; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; diff --git a/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php b/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php index 4c5cabc0..6c7a276e 100644 --- a/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php +++ b/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php @@ -9,6 +9,10 @@ namespace WordPress\AI\Providers\OpenRouter; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; diff --git a/includes/bootstrap.php b/includes/bootstrap.php index da9bd19c..e75b22a5 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -16,7 +16,6 @@ use WordPress\AI\Settings\Settings_Page; use WordPress\AI\Settings\Settings_Registration; use WordPress\AI_Client\AI_Client; -use WordPress\AI_Client\HTTP\WP_AI_Client_Discovery_Strategy; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { From c1a152b57d04a5bf2ad5cd3be7995d255345c8ad Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:01:03 -0500 Subject: [PATCH 15/15] fix: use window.requestAnimationFrame to satisfy no-undef lint rule Co-Authored-By: Claude Opus 4.6 --- assets/js/connectors-extended.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/connectors-extended.js b/assets/js/connectors-extended.js index 7fe90e18..894e0fd6 100644 --- a/assets/js/connectors-extended.js +++ b/assets/js/connectors-extended.js @@ -651,7 +651,7 @@ function ExtendedProviderConnector( { label, description, slug } ) { * in insertion order. */ window.wp.domReady( () => { - requestAnimationFrame( () => { + window.requestAnimationFrame( () => { providers.forEach( ( provider ) => { registerConnector( 'ai-experiments/' + provider.id, { label: provider.label,