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..9c036230 --- /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..b455d1cc --- /dev/null +++ b/includes/Admin/Provider_Metadata_Registry.php @@ -0,0 +1,287 @@ +> + */ + 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 \WordPress\AiClient\Providers\DTO\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 { + /* 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( + '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..b223e973 --- /dev/null +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -0,0 +1,387 @@ + + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive with ::class array. + private const DEFAULT_PROVIDER_CLASSES = array( + CloudflareWorkersAiProvider::class, + CohereProvider::class, + DeepSeekProvider::class, + FalAiProvider::class, + GrokProvider::class, + GroqProvider::class, + HuggingFaceProvider::class, + OllamaProvider::class, + OpenRouterProvider::class, + ); + + /** + * Field name for provider selection setting. + * + * @var string + */ + private const FIELD_PROVIDERS = 'providers'; + + /** + * {@inheritDoc} + */ + protected function load_experiment_metadata(): array { + return array( + 'id' => 'extended-providers', + 'label' => __( 'Extended Providers', 'ai' ), + 'description' => __( 'Registers additional AI providers for experimentation without affecting the core set.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + // Register providers immediately so they're available when + // the WP AI Client collects provider metadata for the credentials screen. + $this->register_providers(); + } + + /** + * 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__, + 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' + ); + continue; + } + + if ( $registry->hasProvider( $class_name ) ) { + continue; + } + + try { + $registry->registerProvider( $class_name ); + } catch ( \Throwable $t ) { + _doing_it_wrong( + __METHOD__, + 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' + ); + } + } + } + + /** + * {@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_name ) { + return is_string( $class_name ) ? trim( $class_name ) : ''; + }, + (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_name ) use ( $selection ): bool { + return ! isset( $selection[ $class_name ] ) || true === $selection[ $class_name ]; + } + ) + ); + } + + /** + * 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 to class name below. + unset( $t ); + } + } + + 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..c5dc8453 --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php @@ -0,0 +1,130 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new CloudflareWorkersAiTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Cloudflare Workers AI model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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..b9221577 --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php @@ -0,0 +1,211 @@ +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<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $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( + '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 ] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + sprintf( + /* translators: %s: custom option key. */ + __( 'The custom option "%s" conflicts with an existing Cloudflare Workers AI parameter.', 'ai' ), + $key + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $payload[ $key ] = $value; + } + + return $payload; + } + + /** + * Converts the WP AI Client prompt into Cloudflare message objects. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $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 \WordPress\AiClient\Messages\DTO\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 \WordPress\AiClient\Providers\Http\DTO\Response $response HTTP response. + * + * @return \WordPress\AiClient\Results\DTO\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 \WordPress\AiClient\Providers\Http\DTO\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..2abcebcc --- /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 \WordPress\AiClient\Providers\Http\DTO\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..412de4c1 --- /dev/null +++ b/includes/Providers/Cohere/CohereProvider.php @@ -0,0 +1,86 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new CohereTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Cohere model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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..d9a357e6 --- /dev/null +++ b/includes/Providers/Cohere/CohereTextGenerationModel.php @@ -0,0 +1,349 @@ +buildPayload( $prompt ); + + $request = new Request( + HttpMethodEnum::POST(), + CohereProvider::url( 'chat' ), + array( 'Content-Type' => 'application/json' ), + $payload + ); + + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $http_transport = $this->getHttpTransporter(); + $response = $http_transport->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponseToResult( $response ); + } + + /** + * {@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<\WordPress\AiClient\Messages\DTO\Message> $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 ) ) { + // 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, + ); + + 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 ] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + sprintf( + /* translators: %s: custom option key. */ + __( 'The custom option "%s" conflicts with an existing Cohere parameter.', 'ai' ), + $key + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + $payload[ $key ] = $value; + } + + return $payload; + } + + /** + * Converts the WP AI Client prompt into Cohere's messages array. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $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 \WordPress\AiClient\Messages\DTO\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 \WordPress\AiClient\Providers\Http\DTO\Response $response Cohere response. + * + * @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' ); + } + + $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'] ) ) { + continue; + } + + $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'] ) ) { + 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'] ) ) { + continue; + } + + $candidates[] = $generation['text']; + } + } + + return $candidates; + } + + /** + * Ensures Cohere returned a successful response. + * + * @param \WordPress\AiClient\Providers\Http\DTO\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; + } + + // 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 + } + + /** + * 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..8cb6f575 --- /dev/null +++ b/includes/Providers/DeepSeek/DeepSeekProvider.php @@ -0,0 +1,86 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new DeepSeekTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported DeepSeek model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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 \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 \WordPress\AiClient\Providers\Http\DTO\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<\WordPress\AiClient\Messages\DTO\Message> $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 \WordPress\AiClient\Providers\Http\DTO\Response $response Fal.ai response. + * + * @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( + 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<\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' ) + ); + } + + $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' ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * Throws an exception if the response indicates failure. + * + * @param \WordPress\AiClient\Providers\Http\DTO\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 \WordPress\AiClient\Providers\Http\DTO\Request $request Authenticated request. + * + * @return \WordPress\AiClient\Providers\Http\DTO\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..6b943669 --- /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..29fe5b4b --- /dev/null +++ b/includes/Providers/FalAi/FalAiProvider.php @@ -0,0 +1,99 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isImageGeneration() ) { + return new FalAiImageGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Fal.ai model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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..4cd583a8 --- /dev/null +++ b/includes/Providers/Grok/GrokModelMetadataDirectory.php @@ -0,0 +1,219 @@ +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..8635e73f --- /dev/null +++ b/includes/Providers/Grok/GrokProvider.php @@ -0,0 +1,86 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new GrokTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Grok model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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..14378fb2 --- /dev/null +++ b/includes/Providers/Groq/GroqProvider.php @@ -0,0 +1,86 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new GroqTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Groq model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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..e97f5f66 --- /dev/null +++ b/includes/Providers/HuggingFace/HuggingFaceProvider.php @@ -0,0 +1,86 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new HuggingFaceTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Hugging Face model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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 \WordPress\AiClient\Providers\Http\DTO\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..258bd0d3 --- /dev/null +++ b/includes/Providers/Ollama/OllamaProvider.php @@ -0,0 +1,102 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new OllamaTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Ollama model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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..9e934d93 --- /dev/null +++ b/includes/Providers/Ollama/OllamaTextGenerationModel.php @@ -0,0 +1,188 @@ + '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<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $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, + ); + + 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<\WordPress\AiClient\Messages\DTO\Message> $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 \WordPress\AiClient\Messages\DTO\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 \WordPress\AiClient\Providers\Http\DTO\Response $response Response instance. + * + * @return \WordPress\AiClient\Results\DTO\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 \WordPress\AiClient\Providers\Http\DTO\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..4599b586 --- /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..80850a70 --- /dev/null +++ b/includes/Providers/OpenRouter/OpenRouterProvider.php @@ -0,0 +1,86 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new OpenRouterTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported OpenRouter model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@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/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. 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: [