-
Notifications
You must be signed in to change notification settings - Fork 44
Add Extended Providers experiment #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Jameswlepage
wants to merge
5
commits into
WordPress:develop
Choose a base branch
from
Jameswlepage:feature/providers
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
684d5ca
Add Extended Providers experiment
Jameswlepage 27fe36a
Fix extended providers not appearing on credentials screen
Jameswlepage 190ae22
Fix partial linting issues in Extended_Providers experiment
Jameswlepage 36311f4
Fix PHPCS lint errors across Extended Providers
Jameswlepage f99595c
Merge branch 'develop' into feature/providers
jeffpaul File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| <?php | ||
| /** | ||
| * Enhances the AI credentials settings screen. | ||
| * | ||
| * @package WordPress\AI | ||
| */ | ||
|
|
||
| namespace WordPress\AI\Admin; | ||
|
|
||
| use WordPress\AI\Asset_Loader; | ||
|
|
||
| use function add_action; | ||
| use function get_option; | ||
| use function wp_localize_script; | ||
|
|
||
| /** | ||
| * Adds icons and tooltips to the credentials UI without modifying the upstream package. | ||
| */ | ||
| class Provider_Credentials_UI { | ||
| private const SCREEN_HOOK = 'settings_page_wp-ai-client'; | ||
|
|
||
| /** | ||
| * Bootstraps the enhancements. | ||
| */ | ||
| public static function init(): void { | ||
| add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_assets' ) ); | ||
| } | ||
|
|
||
| /** | ||
| * Enqueues inline styles/scripts for the credentials table. | ||
| * | ||
| * @param string $hook Current admin page hook. | ||
| */ | ||
| public static function enqueue_assets( string $hook ): void { | ||
| if ( self::SCREEN_HOOK !== $hook ) { | ||
| return; | ||
| } | ||
|
|
||
| Asset_Loader::enqueue_style( | ||
| 'provider_credentials', | ||
| 'admin/style-provider-credentials' | ||
| ); | ||
|
|
||
| Asset_Loader::enqueue_script( | ||
| 'provider_credentials', | ||
| 'admin/provider-credentials' | ||
| ); | ||
|
|
||
| wp_localize_script( | ||
| 'ai_provider_credentials', | ||
| 'aiProviderCredentialsConfig', | ||
| array( | ||
| 'providers' => Provider_Metadata_Registry::get_metadata(), | ||
| 'cloudflareAccountId' => (string) get_option( 'ai_cloudflare_account_id', '' ), | ||
| ) | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,287 @@ | ||
| <?php | ||
| /** | ||
| * Shared provider metadata registry for admin UIs. | ||
| * | ||
| * @package WordPress\AI\Admin | ||
| */ | ||
|
|
||
| namespace WordPress\AI\Admin; | ||
|
|
||
| use WordPress\AiClient\AiClient; | ||
| use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; | ||
| use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; | ||
| use function esc_html__; | ||
| use function get_option; | ||
| use function get_transient; | ||
| use function is_array; | ||
| use function is_string; | ||
| use function md5; | ||
| use function set_transient; | ||
| use function sprintf; | ||
| use function trim; | ||
| use function wp_json_encode; | ||
|
|
||
| /** | ||
| * Provides a single source of truth for provider metadata and branding. | ||
| */ | ||
| class Provider_Metadata_Registry { | ||
| /** | ||
| * Cache TTL for provider model metadata. | ||
| */ | ||
| private const MODEL_CACHE_TTL = 6 * HOUR_IN_SECONDS; | ||
|
|
||
| /** | ||
| * Returns structured metadata for all registered providers. | ||
| * | ||
| * @return array<string, array<string, mixed>> | ||
| */ | ||
| 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<int, array<string, mixed>> | ||
| */ | ||
| private static function get_models_for_provider( string $provider_class, string $provider_id, array $credentials ): array { | ||
|
Check failure on line 102 in includes/Admin/Provider_Metadata_Registry.php
|
||
| 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<string, mixed> $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<mixed> $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<string, array<string, mixed>> | ||
| */ | ||
| 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' ), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The preg_split function should have its return value validated. If preg_split fails (returns false), it should be handled to prevent type errors. Consider adding error handling or using an alternative approach with explode.