Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/experiments/extended-providers.md
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.
58 changes: 58 additions & 0 deletions includes/Admin/Provider_Credentials_UI.php
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', '' ),
)
);
}
}
287 changes: 287 additions & 0 deletions includes/Admin/Provider_Metadata_Registry.php
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 ) ) {
Copy link

Copilot AI Dec 19, 2025

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.

Suggested change
if ( empty( $parts ) ) {
if ( false === $parts || empty( $parts ) ) {

Copilot uses AI. Check for mistakes.
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 );

Check failure on line 93 in includes/Admin/Provider_Metadata_Registry.php

View workflow job for this annotation

GitHub Actions / Run PHP static analysis

Method WordPress\AI\Admin\Provider_Metadata_Registry::get_initials() should return string but returns string|false.
}

/**
* 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

View workflow job for this annotation

GitHub Actions / Run PHP static analysis

Method WordPress\AI\Admin\Provider_Metadata_Registry::get_models_for_provider() has parameter $credentials with no value type specified in iterable type 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<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' ),
),
);
}
}
1 change: 1 addition & 0 deletions includes/Experiment_Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
*
* @param \WordPress\AI\Experiment_Registry $registry The experiment registry instance.
*/
do_action( 'ai_experiments_register_experiments', $this->registry );

Check failure on line 94 in includes/Experiment_Loader.php

View workflow job for this annotation

GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_register_experiments".
}

/**
Expand All @@ -107,6 +107,7 @@
\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,
);

/**
Expand Down
Loading
Loading