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 1/4] 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 2/4] 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 3/4] 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 4/4] 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 } /**