Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/), and will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased] - TBD
### Fixed
- Connectors that provide only non-text capabilities, such as speech or image generation, are no longer reported as possibly invalid. The settings screen and the `wp ai alt-text` command now explain that a text-generating connector is required and name what the configured connectors do provide ([#933](https://github.com/WordPress/ai/issues/933)).

## [1.3.0] - 2026-08-18
### Added
Expand Down
7 changes: 6 additions & 1 deletion includes/CLI/Alt_Text_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use WP_CLI;
use WP_CLI\Utils;
use function WordPress\AI\has_ai_credentials;
use function WordPress\AI\has_valid_ai_credentials;

// Exit if accessed directly.
Expand Down Expand Up @@ -92,7 +93,11 @@ public function generate( $args, $assoc_args ): void {
}

if ( ! has_valid_ai_credentials() ) {
WP_CLI::error( 'No valid AI credentials found. Configure a provider in Settings > Connectors.' );
if ( has_ai_credentials() ) {
WP_CLI::error( 'Alt text generation needs an AI Connector that can generate text, and none of the AI Connectors you have configured provide it. Configure a text-capable provider in Settings > Connectors.' );
} else {
WP_CLI::error( 'No AI credentials found. Configure a provider in Settings > Connectors.' );
}
return; // WP_CLI::error() exits, but this satisfies static analysis.
}

Expand Down
36 changes: 29 additions & 7 deletions includes/Settings/Settings_Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use WordPress\AI\Features\Feature_Category;
use WordPress\AI\Features\Registry;

use function WordPress\AI\get_supported_capabilities;
use function WordPress\AI\has_ai_credentials;
use function WordPress\AI\has_valid_ai_credentials;

Expand Down Expand Up @@ -79,13 +80,7 @@ static function () {
add_filter(
'script_module_data_' . self::PAGE_SLUG,
static function ( array $data ) use ( $registry ): array {
$feature_metadata = self::get_settings_feature_metadata( $registry );
$data['hasCredentials'] = has_ai_credentials();
$data['hasValidCredentials'] = has_valid_ai_credentials();
$data['connectorsUrl'] = admin_url( 'options-connectors.php' );
$data['featureGroups'] = $feature_metadata['groups'] ?? array();
$data['features'] = $feature_metadata['features'] ?? array();
return $data;
return self::get_script_module_data( $data, $registry );
}
);
} else {
Expand Down Expand Up @@ -197,6 +192,33 @@ private static function get_settings_feature_groups(): array {
return is_array( $filtered_groups ) ? $filtered_groups : $default_groups;
}

/**
* Builds the data exposed to the settings page script module.
*
* `hasValidCredentials` reports only whether the configured connectors can generate
* text, which is what the plugin's text-based features need. `capabilities` reports
* what those connectors can actually do, so the settings screen can distinguish a
* missing capability from a misconfigured connector.
*
* @since x.x.x
*
* @param array<string, mixed> $data The existing script module data.
* @param \WordPress\AI\Features\Registry $registry The feature registry.
* @return array<string, mixed> The script module data.
*/
private static function get_script_module_data( array $data, Registry $registry ): array {
$feature_metadata = self::get_settings_feature_metadata( $registry );

$data['hasCredentials'] = has_ai_credentials();
$data['hasValidCredentials'] = has_valid_ai_credentials();
$data['capabilities'] = get_supported_capabilities();
$data['connectorsUrl'] = admin_url( 'options-connectors.php' );
$data['featureGroups'] = $feature_metadata['groups'] ?? array();
$data['features'] = $feature_metadata['features'] ?? array();

return $data;
}

/**
* Builds feature metadata used by the settings route UI.
*
Expand Down
116 changes: 99 additions & 17 deletions includes/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -509,18 +509,27 @@ function has_ai_credentials(): bool {
}

/**
* Checks whether any configured connector exposes an image-generation-capable model.
* Checks whether any configured connector exposes a model with the given capability.
*
* @since 1.0.2
* Scans the model metadata advertised by each authenticated connector, so it does not
* trigger a live API request.
*
* @param bool $reset_cache Whether to bypass the static cache and recompute. Default false.
* @return bool True if at least one connector supports image generation.
* @since x.x.x
*
* @param string $capability The capability to check for, as a `CapabilityEnum` constant value.
* @param bool $reset_cache Whether to bypass the static cache and recompute. Default false.
* @return bool True if at least one connector supports the capability.
*/
function has_image_generation_support( bool $reset_cache = false ): bool {
static $result = null;
function has_capability_support( string $capability, bool $reset_cache = false ): bool {
/**
* Memoized results, keyed by capability value.
*
* @var array<string, bool> $results
*/
static $results = array();

if ( ! $reset_cache && null !== $result ) {
return $result;
if ( ! $reset_cache && isset( $results[ $capability ] ) ) {
return $results[ $capability ];
}

$connectors = array();
Expand All @@ -542,8 +551,8 @@ function has_image_generation_support( bool $reset_cache = false ): bool {
$models = $provider_class::modelMetadataDirectory()->listModelMetadata();

foreach ( $models as $model ) {
foreach ( $model->getSupportedCapabilities() as $capability ) {
if ( CapabilityEnum::IMAGE_GENERATION === $capability->value ) {
foreach ( $model->getSupportedCapabilities() as $model_capability ) {
if ( $capability === $model_capability->value ) {
$has_support = true;
break 3;
}
Expand All @@ -555,6 +564,76 @@ function has_image_generation_support( bool $reset_cache = false ): bool {
}
}

/**
* Filters whether a given model capability is supported by the configured connectors.
*
* Allows third-party plugins to declare capability support for connectors that do not
* rely on API key settings (e.g. OAuth), without triggering a live API request.
*
* Note that `has_image_generation_support()` applies the more specific
* `wpai_has_image_generation_support` filter after this one, so that filter remains
* authoritative for image generation.
*
* @since x.x.x
*
* @param bool $has_support Whether the capability is supported.
* @param string $capability The capability being checked, as a `CapabilityEnum` constant value.
* @param array $connectors The registered connectors.
*/
$results[ $capability ] = (bool) apply_filters( 'wpai_has_capability_support', $has_support, $capability, $connectors );

return $results[ $capability ];
}

/**
* Returns the capabilities the configured connectors can actually provide.
*
* Lets callers explain what a site's connectors do offer, rather than only reporting
* which capability is missing.
*
* @since x.x.x
*
* @param bool $reset_cache Whether to bypass the static cache and recompute. Default false.
* @return list<string> The supported capability values, as `CapabilityEnum` constant values.
*/
function get_supported_capabilities( bool $reset_cache = false ): array {
if ( ! class_exists( CapabilityEnum::class ) ) {
return array();
}

$supported = array();

foreach ( CapabilityEnum::getValues() as $capability ) {
if ( ! has_capability_support( $capability, $reset_cache ) ) {
continue;
}

$supported[] = $capability;
}

return $supported;
}

/**
* Checks whether any configured connector exposes an image-generation-capable model.
*
* @since 1.0.2
*
* @param bool $reset_cache Whether to bypass the static cache and recompute. Default false.
* @return bool True if at least one connector supports image generation.
*/
function has_image_generation_support( bool $reset_cache = false ): bool {
static $result = null;

if ( ! $reset_cache && null !== $result ) {
return $result;
}

$has_support = has_capability_support( CapabilityEnum::IMAGE_GENERATION, $reset_cache );

// Mirror the historical contract: no connectors are passed to the filter without the AI client.
$connectors = class_exists( AiClient::class ) ? get_ai_connectors() : array();

/**
* Filters whether image generation is supported.
*
Expand Down Expand Up @@ -587,11 +666,18 @@ function get_provider_availability_data(): array {
}

/**
* Checks if we have valid AI credentials.
* Checks if we have credentials that can perform the plugin's text-based work.
*
* This is a capability-specific gate, not a judgement about whether the configured
* connectors are correctly set up. A connector that only provides a non-text modality
* (for example speech or image generation) is valid and working, but will return false
* here because the plugin's text features cannot use it. Callers surfacing this to users
* should say that a text-generation connector is missing rather than that the configured
* connectors are invalid.
*
* @since 0.1.0
*
* @return bool True if we have valid AI credentials, false otherwise.
* @return bool True if a configured connector can generate text, false otherwise.
*/
function has_valid_ai_credentials(): bool {
// If we have no AI credentials, return false.
Expand All @@ -615,11 +701,7 @@ function has_valid_ai_credentials(): bool {
}

// See if we have credentials that give us access to generate text.
try {
return wp_ai_client_prompt( 'Test' )->is_supported_for_text_generation();
} catch ( Throwable $t ) {
return false;
}
return has_capability_support( CapabilityEnum::TEXT_GENERATION );
}

/**
Expand Down
83 changes: 79 additions & 4 deletions routes/ai-home/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ interface FeatureData {
interface PageData {
hasCredentials: boolean;
hasValidCredentials: boolean;
capabilities: string[];
connectorsUrl: string;
featureGroups: FeatureGroupData[];
features: FeatureData[];
Expand Down Expand Up @@ -203,10 +204,72 @@ function buildFallbackFeatureGroups(
} ) );
}

/**
* Human-readable labels for the AI Client capability values.
*
* Built on call rather than at module scope so the strings are resolved after the
* route's translations are registered.
*/
function getCapabilityLabels(): Record< string, string > {
return {
text_generation: __( 'text generation', 'ai' ),
image_generation: __( 'image generation', 'ai' ),
text_to_speech_conversion: __( 'text-to-speech conversion', 'ai' ),
speech_generation: __( 'speech generation', 'ai' ),
music_generation: __( 'music generation', 'ai' ),
video_generation: __( 'video generation', 'ai' ),
embedding_generation: __( 'embedding generation', 'ai' ),
chat_history: __( 'chat history', 'ai' ),
};
}

/**
* Describes the capabilities the configured connectors provide.
*
* Text generation is excluded because this is only used when it is absent, and
* unrecognized values are dropped so a raw capability identifier never reaches the UI.
*/
function describeAvailableCapabilities( capabilities: string[] ): string {
const labels = getCapabilityLabels();

return capabilities
.filter(
( capability ) =>
capability !== 'text_generation' &&
labels[ capability ] !== undefined
)
.map( ( capability ) => labels[ capability ] )
.join( ', ' );
}

/**
* Builds the notice shown when connectors are configured but none can generate text.
*/
function getMissingTextGenerationMessage( capabilities: string[] ): string {
const available = describeAvailableCapabilities( capabilities );

if ( available === '' ) {
return __(
'These AI features need an AI Connector that can generate text. None of the AI Connectors you have configured provide text generation.',
'ai'
);
}

return sprintf(
/* translators: %s: Comma-separated list of the capabilities the configured connectors provide, e.g. "speech generation, text-to-speech conversion". */
__(
'These AI features need an AI Connector that can generate text. The AI Connectors you have configured provide %s.',
'ai'
),
available
);
}

function getPageData(): PageData {
const fallback: PageData = {
hasCredentials: false,
hasValidCredentials: false,
capabilities: [],
connectorsUrl: '',
featureGroups: [],
features: [],
Expand All @@ -233,9 +296,16 @@ function getPageData(): PageData {
? pageData.features.map( parseFeature ).filter( isDefined )
: [];

const capabilities = Array.isArray( pageData.capabilities )
? pageData.capabilities
.map( toStringValue )
.filter( ( capability ) => capability !== '' )
: [];

return {
hasCredentials: Boolean( pageData.hasCredentials ),
hasValidCredentials: Boolean( pageData.hasValidCredentials ),
capabilities,
connectorsUrl: toStringValue( pageData.connectorsUrl ),
featureGroups,
features,
Expand Down Expand Up @@ -1150,16 +1220,21 @@ function AISettingsPage() {
gap="md"
>
{ ! PAGE_DATA.hasValidCredentials && (
<Notice.Root intent="error">
<Notice.Root
intent={
PAGE_DATA.hasCredentials
? 'warning'
: 'error'
}
>
<Notice.Description>
{ ! PAGE_DATA.hasCredentials
? __(
'The AI plugin requires a valid AI Connector to function properly. Verify you have one or more AI Connectors configured.',
'ai'
)
: __(
'The AI plugin requires a valid AI Connector to function properly. Please review the AI Connectors you have configured to ensure they are valid.',
'ai'
: getMissingTextGenerationMessage(
PAGE_DATA.capabilities
) }
</Notice.Description>
{ PAGE_DATA.connectorsUrl && (
Expand Down
Loading
Loading