diff --git a/ai.php b/ai.php index 4514c0c8a..1a5dd8c83 100644 --- a/ai.php +++ b/ai.php @@ -75,13 +75,6 @@ function constants(): void { require_once WPAI_PLUGIN_DIR . 'includes/autoload.php'; // Register the vendored PHP AI Client SDK overlay before any AI operation runs. -/** - * Comment out the loading for now as upstream changes are being made to - * embeddings which will need to be pulled in to this plugin. This will contain - * some breaking changes and as such, we don't want anyone to start building - * on top of things. - */ -// phpcs:ignore Squiz.PHP.CommentedOutCode.Found -// \WordPress\AI\SDK_Overlay::register(); +\WordPress\AI\SDK_Overlay::register(); \WordPress\AI\Main::get_instance(); diff --git a/includes/CLI/Embeddings_Command.php b/includes/CLI/Embeddings_Command.php new file mode 100644 index 000000000..b9963204c --- /dev/null +++ b/includes/CLI/Embeddings_Command.php @@ -0,0 +1,385 @@ + + */ + private const ALLOWED_PROVIDERS = array( 'openai', 'google', 'ollama' ); // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition + + /** + * Generates embeddings for text. + * + * A provider and model are always required. Embedding vectors are only comparable to other + * vectors from the same model, so nothing is chosen on your behalf — see + * {@see \WordPress\AI\generate_embeddings()}. + * + * ## OPTIONS + * + * [] + * : Text to generate embeddings for. Required unless --post-id is set. + * + * --provider= + * : AI provider that offers the model. One of: openai, google, ollama. + * + * --model= + * : Embedding model ID to generate with, for example text-embedding-3-small. + * + * [--dry-run] + * : Show what would be processed without making API calls. + * + * [--post-id=] + * : Post ID whose content should be embedded. + * + * [--chunk] + * : Split content into overlapping chunks and embed each chunk. + * + * ## EXAMPLES + * + * # Generate embeddings for specific text + * $ wp ai embeddings generate 'This is some text' --provider=openai --model=text-embedding-3-small --dry-run=false + * + * # Dry run to see what would be processed + * $ wp ai embeddings generate 'This is some text' --provider=openai --model=text-embedding-3-small --dry-run=true + * + * # Generate embeddings for specific post content + * $ wp ai embeddings generate --post-id=42 --provider=openai --model=text-embedding-3-small --dry-run=false + * + * # Chunk post content and generate embeddings for the chunks + * $ wp ai embeddings generate --post-id=42 --chunk --provider=openai --model=text-embedding-3-small --dry-run=false + * + * # Use a different provider and model + * $ wp ai embeddings generate 'This is some text' --provider=google --model=gemini-embedding-001 --dry-run=false + * + * @when after_wp_load + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function generate( $args, $assoc_args ): void { + $text = $this->resolve_text( $args, $assoc_args ); + $dry_run = filter_var( Utils\get_flag_value( $assoc_args, 'dry-run', true ), FILTER_VALIDATE_BOOLEAN ); + $chunk = (bool) Utils\get_flag_value( $assoc_args, 'chunk', false ); + $provider = $this->resolve_provider( $assoc_args ); + $model = $this->resolve_model( $assoc_args ); + + $pieces = $chunk ? $this->chunk_text( $text ) : array( $text ); + + if ( empty( $pieces ) ) { + WP_CLI::error( 'No content to embed.' ); + return; + } + + if ( $dry_run ) { + WP_CLI::log( sprintf( 'Dry run: would use model "%s" from provider "%s".', $model, $provider ) ); + + if ( $chunk ) { + WP_CLI::log( sprintf( 'Dry run: would embed %d chunk(s).', count( $pieces ) ) ); + + foreach ( $pieces as $index => $piece ) { + $char_count = mb_strlen( $piece ); + $preview = $char_count > 80 ? mb_substr( $piece, 0, 80 ) . '...' : $piece; + WP_CLI::log( sprintf( ' [%d] (%d chars) %s', $index + 1, $char_count, $preview ) ); + } + } else { + WP_CLI::log( 'Dry run: would have generated embeddings for text: ' . $text ); + } + + return; + } + + $total = count( $pieces ); + WP_CLI::log( + $chunk + ? sprintf( 'Generating embeddings for %d chunk(s) using model "%s" from provider "%s".', $total, $model, $provider ) + : sprintf( 'Generating embeddings using model "%s" from provider "%s" for text: %s', $model, $provider, $text ) + ); + + $result = generate_embeddings( + $chunk ? $pieces : $pieces[0], + array( + 'provider' => $provider, + 'model' => $model, + ) + ); + + if ( is_wp_error( $result ) ) { + WP_CLI::error( sprintf( 'Error generating embeddings: %s', $result->get_error_message() ) ); + return; + } + + $embeddings = $result->getEmbeddings(); + + WP_CLI::log( sprintf( 'Provider: %s', $result->getProviderMetadata()->getId() ) ); + WP_CLI::log( sprintf( 'Model: %s', $result->getModelMetadata()->getId() ) ); + WP_CLI::log( sprintf( 'Token Usage: %s', $result->getTokenUsage()->getTotalTokens() ) ); + + foreach ( $embeddings as $embedding ) { + WP_CLI::log( sprintf( 'Embedding: %s', $this->preview_vector( $embedding->getValues() ) ) ); + } + + WP_CLI::success( + $chunk + ? sprintf( 'Embeddings generated successfully for %d chunk(s).', $total ) + : 'Embeddings generated successfully.' + ); + } + + /** + * Resolves and validates the required --provider flag. + * + * WP-CLI enforces the flag's presence from the command synopsis; the empty check here covers + * the class being invoked directly. + * + * @since x.x.x + * + * @param array $assoc_args Associative arguments. + * @return string Provider ID. + */ + private function resolve_provider( array $assoc_args ): string { + $provider = (string) Utils\get_flag_value( $assoc_args, 'provider', '' ); + $provider = strtolower( trim( $provider ) ); + + if ( '' === $provider ) { + WP_CLI::error( + sprintf( + 'A --provider is required. Allowed values: %s.', + implode( ', ', self::ALLOWED_PROVIDERS ) + ) + ); + return ''; // WP_CLI::error() exits, but this satisfies static analysis. + } + + if ( ! in_array( $provider, self::ALLOWED_PROVIDERS, true ) ) { + WP_CLI::error( + sprintf( + 'Invalid --provider "%s". Allowed values: %s.', + $provider, + implode( ', ', self::ALLOWED_PROVIDERS ) + ) + ); + return ''; // WP_CLI::error() exits, but this satisfies static analysis. + } + + return $provider; + } + + /** + * Resolves the required --model flag. + * + * Model IDs are not validated against a list here: which models a provider offers changes + * independently of this plugin, so the provider is left to reject an unknown ID. + * + * @since x.x.x + * + * @param array $assoc_args Associative arguments. + * @return string Embedding model ID. + */ + private function resolve_model( array $assoc_args ): string { + $model = trim( (string) Utils\get_flag_value( $assoc_args, 'model', '' ) ); + + if ( '' === $model ) { + WP_CLI::error( + 'A --model is required. Embedding vectors are only comparable to other vectors from ' + . 'the same model, so no model is selected automatically.' + ); + return ''; // WP_CLI::error() exits, but this satisfies static analysis. + } + + return $model; + } + + /** + * Resolves the text to embed from positional args or --post-id. + * + * Exactly one source is required. Post content is normalized to plain text. + * + * @since x.x.x + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return string Non-empty text to embed. + */ + private function resolve_text( array $args, array $assoc_args ): string { + $text = isset( $args[0] ) ? (string) $args[0] : ''; + $post_id = (int) Utils\get_flag_value( $assoc_args, 'post-id', 0 ); + $has_text = '' !== trim( $text ); + $has_post = $post_id > 0; + + if ( $has_text && $has_post ) { + WP_CLI::error( 'Provide either positional text or --post-id, not both.' ); + return ''; // WP_CLI::error() exits, but this satisfies static analysis. + } + + if ( ! $has_text && ! $has_post ) { + WP_CLI::error( 'Provide positional text or --post-id.' ); + return ''; // WP_CLI::error() exits, but this satisfies static analysis. + } + + if ( $has_post ) { + $post = get_post( $post_id ); + + if ( ! $post instanceof \WP_Post ) { + WP_CLI::error( sprintf( 'Post %d not found.', $post_id ) ); + return ''; // WP_CLI::error() exits, but this satisfies static analysis. + } + + $text = normalize_content( (string) $post->post_content ); + } + + $text = trim( $text ); + if ( '' === $text ) { + WP_CLI::error( 'Resolved content is empty.' ); + return ''; // WP_CLI::error() exits, but this satisfies static analysis. + } + + return $text; + } + + /** + * Splits text into overlapping character chunks. + * + * Prefers ending a chunk at whitespace or sentence punctuation near the + * window end. Consecutive chunks overlap by CHUNK_OVERLAP characters. + * + * @since x.x.x + * + * @param string $text Text to chunk. + * @return list Chunks (empty if input is empty/whitespace-only). + */ + private function chunk_text( string $text ): array { + $text = trim( $text ); + if ( '' === $text ) { + return array(); + } + + $length = mb_strlen( $text ); + if ( $length <= self::CHUNK_SIZE ) { + return array( $text ); + } + + $chunks = array(); + $start = 0; + + while ( $start < $length ) { + $remaining = $length - $start; + if ( $remaining <= self::CHUNK_SIZE ) { + $chunk = trim( mb_substr( $text, $start ) ); + if ( '' !== $chunk ) { + $chunks[] = $chunk; + } + break; + } + + $window = mb_substr( $text, $start, self::CHUNK_SIZE ); + $end_offset = $this->find_natural_break( $window ); + $chunk = trim( mb_substr( $text, $start, $end_offset ) ); + if ( '' !== $chunk ) { + $chunks[] = $chunk; + } + + $advance = $end_offset - self::CHUNK_OVERLAP; + if ( $advance < 1 ) { + $advance = 1; + } + $start += $advance; + } + + return $chunks; + } + + /** + * Finds a preferred end offset within a chunk window. + * + * Looks in the last ~25% of the window for whitespace or sentence + * punctuation. Falls back to the full window length. + * + * @since x.x.x + * + * @param string $window Candidate chunk window (length <= CHUNK_SIZE). + * @return int End offset relative to the window start (1..mb_strlen( $window )). + */ + private function find_natural_break( string $window ): int { + $window_length = mb_strlen( $window ); + if ( $window_length <= 1 ) { + return max( 1, $window_length ); + } + + $search_from = (int) floor( $window_length * 0.75 ); + $best = 0; + + for ( $i = $window_length - 1; $i >= $search_from; $i-- ) { + $char = mb_substr( $window, $i, 1 ); + if ( ctype_space( $char ) || in_array( $char, array( '.', '!', '?', ';', ':' ), true ) ) { + $best = $i + 1; + break; + } + } + + return $best > 0 ? $best : $window_length; + } + + /** + * Formats an embedding vector for concise display. + * + * @param array $values Vector values. + * @return string The formatted preview. + */ + private function preview_vector( array $values ): string { + $head = array_slice( $values, 0, 5 ); + $head = array_map( + static function ( $value ): string { + return rtrim( rtrim( sprintf( '%.5f', $value ), '0' ), '.' ); + }, + $head + ); + return '[' . implode( ', ', $head ) . ', ...] (' . count( $values ) . ' dims)'; + } +} diff --git a/includes/Main.php b/includes/Main.php index 1032601de..5fb9688b3 100644 --- a/includes/Main.php +++ b/includes/Main.php @@ -16,6 +16,7 @@ use WordPress\AI\Admin\Deactivation; use WordPress\AI\Admin\Site_Health; use WordPress\AI\Admin\Upgrades; +use WordPress\AI\CLI\Embeddings_Command; use WordPress\AI\Experiments\Experiments; use WordPress\AI\Features\Loader; use WordPress\AI\Features\Registry; @@ -137,6 +138,13 @@ public function initialize_features(): void { if ( is_admin() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { ( new Site_Health() )->init(); } + + // Register any needed global WP-CLI commands. + if ( ! defined( 'WP_CLI' ) || ! \WP_CLI ) { + return; + } + + \WP_CLI::add_command( 'ai embeddings', Embeddings_Command::class ); } catch ( \Throwable $e ) { _doing_it_wrong( __METHOD__, diff --git a/includes/SDK_Overlay.php b/includes/SDK_Overlay.php index b257d08c2..1e862e899 100644 --- a/includes/SDK_Overlay.php +++ b/includes/SDK_Overlay.php @@ -92,11 +92,11 @@ final class SDK_Overlay { 'embeddings' => array( 'sentinel' => 'WordPress\\AiClient\\Builders\\EmbeddingBuilder', 'guards' => array( - 'WordPress\\AiClient\\Providers\\Models\\DTO\\ModelRequirements' => 'fromEmbeddingData', + 'WordPress\\AiClient\\Providers\\Models\\DTO\\ModelRequirements' => 'getUnmetRequirements', ), 'classes' => array( 'WordPress\\AiClient\\Builders\\EmbeddingBuilder', - 'WordPress\\AiClient\\Builders\\Traits\\ModelResolutionTrait', + 'WordPress\\AiClient\\Builders\\Traits\\ModelConfigurationTrait', 'WordPress\\AiClient\\Providers\\ModelResolver', 'WordPress\\AiClient\\Providers\\Models\\DTO\\ModelRequirements', 'WordPress\\AiClient\\Providers\\Models\\DTO\\ModelConfig', diff --git a/includes/Vendor/AiClient/README.md b/includes/Vendor/AiClient/README.md index 03ddaa0f7..cfb164065 100644 --- a/includes/Vendor/AiClient/README.md +++ b/includes/Vendor/AiClient/README.md @@ -18,18 +18,18 @@ embeddings and, later, streaming activate or defer on their own. ### `embeddings` -- **Vendored commit:** `593a04cd18d22670f1186fb13f715987671d330a` (merge of [PR #244](https://github.com/WordPress/php-ai-client/pull/244)) +- **Vendored commit:** `20a1a6d33a11d3f2955e9c5b7389af7ff51209bc` (upstream `trunk` merge commit of [PR #274](https://github.com/WordPress/php-ai-client/pull/274), which builds on [PR #244](https://github.com/WordPress/php-ai-client/pull/244)) - **Sentinel:** `WordPress\AiClient\Builders\EmbeddingBuilder` (present only if the environment already ships embeddings) -The new classes introduced by PR #244, plus the two existing classes it modified that lie on the -embedding execution path: +The new classes introduced by PR #244 and PR #274, plus the existing classes those PRs modified +that lie on the embedding execution path: | Vendored file | Kind | | --- | --- | | `src/Builders/EmbeddingBuilder.php` | new | -| `src/Builders/Traits/ModelResolutionTrait.php` | new | +| `src/Builders/Traits/ModelConfigurationTrait.php` | new | | `src/Providers/ModelResolver.php` | new | -| `src/Providers/Models/DTO/ModelRequirements.php` | modified (adds `fromEmbeddingData()`) | +| `src/Providers/Models/DTO/ModelRequirements.php` | modified (adds `fromEmbeddingData()` and `getUnmetRequirements()`) | | `src/Providers/Models/DTO/ModelConfig.php` | modified (adds `dimensions` support) | | `src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php` | new | | `src/Results/DTO/Embedding.php` | new | @@ -37,10 +37,18 @@ embedding execution path: | `src/Events/BeforeGenerateEmbeddingEvent.php` | new | | `src/Events/AfterGenerateEmbeddingEvent.php` | new | +**Pinned to a merged commit.** PR #274 merged upstream on 2026-08-31; these files are vendored +from the resulting `trunk` merge commit. Re-check this table against +upstream whenever a later PR touches the embedding execution path. + ## What was intentionally NOT copied -- `src/AiClient.php` — its PR #244 changes are only static convenience wrappers; we build - `EmbeddingBuilder` directly and use the environment's unmodified `AiClient::defaultRegistry()`. +- `src/AiClient.php` — its PR #244 and PR #274 changes are only static convenience wrappers; we + build `EmbeddingBuilder` directly and use the environment's unmodified + `AiClient::defaultRegistry()`. Vendoring it is also not an option: it is the overlay's + base-SDK precondition class. +- `src/Builders/Traits/ModelResolutionTrait.php` — vendored for PR #244, dropped for PR #274; see + above. - `src/Builders/PromptBuilder.php` — refactored by PR #244, but the embedding path does not use it. - `src/Providers/Models/Enums/OptionEnum.php` — the PR #244 diff is docblock-only; behavior is driven dynamically off `ModelConfig`'s `KEY_*` constants. diff --git a/includes/Vendor/AiClient/src/Builders/EmbeddingBuilder.php b/includes/Vendor/AiClient/src/Builders/EmbeddingBuilder.php index c368eae9f..bd1108cd1 100644 --- a/includes/Vendor/AiClient/src/Builders/EmbeddingBuilder.php +++ b/includes/Vendor/AiClient/src/Builders/EmbeddingBuilder.php @@ -5,16 +5,22 @@ namespace WordPress\AiClient\Builders; use Psr\EventDispatcher\EventDispatcherInterface; -use WordPress\AiClient\Builders\Traits\ModelResolutionTrait; +use WordPress\AiClient\Builders\Traits\ModelConfigurationTrait; +use WordPress\AiClient\Common\AbstractEnum; +use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateEmbeddingEvent; use WordPress\AiClient\Events\BeforeGenerateEmbeddingEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; +use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\ModelResolver; +use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; +use WordPress\AiClient\Providers\Models\DTO\RequiredOption; use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\ProviderRegistry; @@ -26,24 +32,59 @@ * * Embeddings transform inputs into vector representations rather than generating a conversational * response. Each input is embedded independently, and the builder produces one embedding vector per - * input. Model selection and configuration are shared with {@see PromptBuilder} via the - * {@see ModelResolutionTrait}. + * input. * - * @since n.e.x.t + * Unlike {@see PromptBuilder}, this builder never selects a model on the caller's behalf. Embedding + * vectors are only comparable to other vectors produced by the same model, so a stored corpus is + * permanently bound to the model that created it. Automatically choosing a model could therefore + * silently invalidate existing vectors, for example when the registered providers change. The model + * must be specified via {@see self::usingModel()} or {@see self::usingProviderModel()}; the builder + * verifies that the given model can fulfill the request instead of searching for one that can. + * + * @since 1.4.0 * * @phpstan-import-type MessagePartArrayShape from MessagePart + * @phpstan-import-type UnmetModelRequirementsShape from ModelRequirements + * @phpstan-import-type ProviderModelTuple from ModelResolver * * @phpstan-type EmbeddingInput string|MessagePart|File|MessagePartArrayShape */ class EmbeddingBuilder { - use ModelResolutionTrait; + use ModelConfigurationTrait; + + /** + * @var string Message used when no model was specified. + */ + private const NO_MODEL_MESSAGE = 'An embedding model must be specified. Embeddings are only comparable to ' + . 'other embeddings from the same model, so no model is selected automatically. ' + . 'Use usingModel() or usingProviderModel().'; + + /** + * @var ProviderRegistry The provider registry used to prepare the model. + */ + protected ProviderRegistry $registry; /** * @var list The inputs to embed. */ protected array $inputs = []; + /** + * @var ModelInterface|null The explicitly provided model, if any. + */ + protected ?ModelInterface $model = null; + + /** + * @var ProviderModelTuple|null The provider ID or class name and model ID, if any. + */ + protected ?array $providerModel = null; + + /** + * @var RequestOptions|null The request options for HTTP transport. + */ + protected ?RequestOptions $requestOptions = null; + /** * @var EventDispatcherInterface|null The event dispatcher for embedding lifecycle events. */ @@ -52,9 +93,9 @@ class EmbeddingBuilder /** * Constructor. * - * @since n.e.x.t + * @since 1.4.0 * - * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param ProviderRegistry $registry The provider registry used to prepare the model. * @param EmbeddingInput|list|null $input Optional initial input(s) to embed. * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. */ @@ -63,8 +104,8 @@ public function __construct( $input = null, ?EventDispatcherInterface $eventDispatcher = null ) { + $this->registry = $registry; $this->modelConfig = new ModelConfig(); - $this->modelResolver = new ModelResolver($registry); $this->eventDispatcher = $eventDispatcher; if ($input === null) { @@ -84,10 +125,10 @@ public function __construct( /** * Creates a deep clone of this builder. * - * Clones the inputs and model configuration. Service objects (registry, event dispatcher) are - * intentionally NOT cloned as they are shared dependencies. + * Clones the inputs, model configuration, and request options. Service objects (registry, model, + * event dispatcher) are intentionally NOT cloned as they are shared dependencies. * - * @since n.e.x.t + * @since 1.4.0 */ public function __clone() { @@ -98,16 +139,19 @@ public function __clone() $this->inputs = $clonedInputs; $this->modelConfig = clone $this->modelConfig; - $this->modelResolver = clone $this->modelResolver; - // Note: $eventDispatcher is a service object and is intentionally NOT - // cloned - it should be a shared reference. + if ($this->requestOptions !== null) { + $this->requestOptions = clone $this->requestOptions; + } + + // Note: $registry, $model, and $eventDispatcher are service objects and are intentionally + // NOT cloned - they should be shared references. } /** * Adds one or more inputs to embed. * - * @since n.e.x.t + * @since 1.4.0 * * @param EmbeddingInput ...$input The inputs to embed, each treated as an independent input. * @return self @@ -127,10 +171,76 @@ public function withInput(...$input): self } /** - * Sets the embedding dimensions. + * Sets the model to use for embedding generation. + * + * The model's configuration will be merged with the builder's configuration, + * with the builder's configuration taking precedence for any overlapping settings. The merge + * happens when the model is used rather than here, so replacing the model replaces its + * configuration along with it. + * + * @since 1.4.0 + * + * @param ModelInterface $model The model to use. + * @return self + */ + public function usingModel(ModelInterface $model): self + { + $this->model = $model; + $this->providerModel = null; + + return $this; + } + + /** + * Sets the model to use for embedding generation, by provider and model identifier. + * + * This is an alternative to {@see self::usingModel()} that does not require the caller to + * reference a provider class directly. The model is retrieved from the registry when the + * embeddings are generated, so it receives any configuration set afterwards. * * @since n.e.x.t * + * @param string $providerIdOrClassName The provider ID or class name. + * @param string $modelId The model identifier. + * @return self + * @throws InvalidArgumentException If the provider or model identifier is empty. + */ + public function usingProviderModel(string $providerIdOrClassName, string $modelId): self + { + if (trim($providerIdOrClassName) === '') { + throw new InvalidArgumentException('Provider identifier cannot be empty.'); + } + + if (trim($modelId) === '') { + throw new InvalidArgumentException('Model identifier cannot be empty.'); + } + + $this->providerModel = [$providerIdOrClassName, $modelId]; + $this->model = null; + + return $this; + } + + /** + * Sets the request options for HTTP transport. + * + * @since 1.4.0 + * + * @param RequestOptions $requestOptions The request options. + * @return self + */ + public function usingRequestOptions(RequestOptions $requestOptions): self + { + $this->requestOptions = $requestOptions; + + return $this; + } + + /** + * Sets the embedding dimensions. + * + * @since 1.4.0 + * * @param int $dimensions The embedding dimensions. * @return self */ @@ -141,28 +251,59 @@ public function usingDimensions(int $dimensions): self } /** - * Checks whether the current inputs and configuration are supported by an available model. + * Checks whether the specified model supports the current inputs and configuration. * - * @since n.e.x.t + * This reports whether the model set via {@see self::usingModel()} or + * {@see self::usingProviderModel()} can fulfill the request, rather than whether any available + * model can. + * + * Any reason the specified model cannot fulfill the request is reported as `false`, including an + * unregistered or unconfigured provider, a model ID the provider does not offer, and a provider + * that could not be reached. Only failing to specify a model at all is treated as a programming + * error and throws. * - * @return bool True if a suitable embedding model is available. + * A model instance provided via {@see self::usingModel()} is left untouched, so that checking + * for support does not alter it. Determining whether the model's provider is configured may + * require a request to the provider, which is cached for the remainder of the request. + * + * @since 1.4.0 + * + * @return bool True if the specified model supports embedding generation for the current + * inputs and configuration. + * @throws InvalidArgumentException If no model was specified. */ public function isSupported(): bool { - $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); + if ($this->providerModel === null && $this->model === null) { + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + + try { + $model = $this->locateModel(); + } catch (AiClientExceptionInterface $e) { + // The model is unusable: its provider is not registered or configured, the provider has + // no model with the given ID, or the provider could not be reached. Either way it cannot + // fulfill the request. + return false; + } + + if (!$model instanceof EmbeddingGenerationModelInterface) { + return false; + } - return $this->modelResolver->isSupported($requirements); + return $this->describeUnmetRequirements($model) === null; } /** * Generates an embedding result from the configured inputs. * - * @since n.e.x.t + * @since 1.4.0 * * @return EmbeddingResult The generated embedding result. - * @throws InvalidArgumentException If no inputs are configured or model validation fails. - * @throws RuntimeException If the model doesn't support embedding generation, or returns an - * embedding count that does not match the number of inputs. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the + * specified model cannot fulfill the request. + * @throws RuntimeException If the model returns an embedding count that does not match the + * number of inputs. */ public function generateEmbeddingResult(): EmbeddingResult { @@ -173,17 +314,7 @@ public function generateEmbeddingResult(): EmbeddingResult } $capability = CapabilityEnum::embeddingGeneration(); - $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); - $model = $this->modelResolver->resolve($requirements, $this->modelConfig); - - if (!$model instanceof EmbeddingGenerationModelInterface) { - throw new RuntimeException( - sprintf( - 'Model "%s" does not support embedding generation.', - $model->metadata()->getId() - ) - ); - } + $model = $this->resolveModel(); $this->dispatchEvent(new BeforeGenerateEmbeddingEvent($this->inputs, $model, $capability)); @@ -208,11 +339,12 @@ public function generateEmbeddingResult(): EmbeddingResult /** * Generates a single embedding from the configured input. * - * @since n.e.x.t + * @since 1.4.0 * * @return Embedding The generated embedding vector. - * @throws InvalidArgumentException If no inputs are configured, model validation fails, or - * multiple inputs were provided. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, the + * specified model cannot fulfill the request, or multiple + * inputs were provided. */ public function generateEmbedding(): Embedding { @@ -228,12 +360,13 @@ public function generateEmbedding(): Embedding /** * Generates embeddings from the configured inputs. * - * @since n.e.x.t + * @since 1.4.0 * * @return list The generated embedding vectors. - * @throws InvalidArgumentException If no inputs are configured or model validation fails. - * @throws RuntimeException If the model doesn't support embedding generation, or returns an - * embedding count that does not match the number of inputs. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the + * specified model cannot fulfill the request. + * @throws RuntimeException If the model returns an embedding count that does not match the + * number of inputs. */ public function generateEmbeddings(): array { @@ -241,10 +374,239 @@ public function generateEmbeddings(): array } /** - * Parses a single input into a message part. + * Resolves the specified model and verifies it can fulfill the request. + * + * @since n.e.x.t + * + * @return ModelInterface&EmbeddingGenerationModelInterface The verified model. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model cannot fulfill the request. + */ + private function resolveModel(): ModelInterface + { + $model = $this->prepareModel(); + + if (!$model instanceof EmbeddingGenerationModelInterface) { + throw new InvalidArgumentException( + sprintf( + 'Model "%s" from provider "%s" does not support embedding generation.', + $model->metadata()->getId(), + $model->providerMetadata()->getId() + ) + ); + } + + $unmetRequirements = $this->describeUnmetRequirements($model); + if ($unmetRequirements !== null) { + throw new InvalidArgumentException( + sprintf( + 'Model "%s" from provider "%s" cannot fulfill this embedding request. %s', + $model->metadata()->getId(), + $model->providerMetadata()->getId(), + $unmetRequirements + ) + ); + } + + return $model; + } + + /** + * Locates the specified model, without preparing it for use. + * + * Unlike {@see self::prepareModel()}, this leaves a model instance provided by the caller + * untouched, so that it can be inspected without altering it. * * @since n.e.x.t * + * @return ModelInterface The located model. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model could not be retrieved. + */ + private function locateModel(): ModelInterface + { + if ($this->providerModel !== null) { + [$providerIdOrClassName, $modelId] = $this->providerModel; + + $this->assertProviderConfigured($providerIdOrClassName); + + // Retrieving the model also binds its provider dependencies. + return $this->registry->getProviderModel($providerIdOrClassName, $modelId, $this->modelConfig); + } + + if ($this->model !== null) { + $model = $this->model; + + $this->assertProviderConfigured($model->providerMetadata()->getId()); + + return $model; + } + + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + + /** + * Prepares the specified model for use, without verifying that it can fulfill the request. + * + * @since n.e.x.t + * + * @return ModelInterface The prepared model, with its dependencies and configuration bound. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model could not be retrieved. + */ + private function prepareModel(): ModelInterface + { + $model = $this->locateModel(); + + // A model retrieved from the registry is already bound and configured, so only a model + // instance provided by the caller needs its dependencies and configuration bound here. + if ($model === $this->model) { + $model->setConfig($this->effectiveModelConfig($model)); + $this->registry->bindModelDependencies($model); + } + + // Request options are only applicable to API-based models that make HTTP requests. + if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { + $model->setRequestOptions($this->requestOptions); + } + + return $model; + } + + /** + * Combines a model's own configuration with the configuration set on this builder. + * + * The builder's configuration takes precedence for any overlapping settings. Combining happens + * here, when the model is used, rather than when the model is set, so that the configuration of + * a model that was subsequently replaced is not applied to its replacement. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model whose configuration to combine with the builder's. + * @return ModelConfig The effective configuration for the given model. + */ + private function effectiveModelConfig(ModelInterface $model): ModelConfig + { + return ModelConfig::fromArray(array_merge( + $model->getConfig()->toArray(), + $this->modelConfig->toArray() + )); + } + + /** + * Asserts that the given provider is configured and therefore usable. + * + * @since n.e.x.t + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return void + * @throws InvalidArgumentException If the provider is not registered or not usable. + */ + private function assertProviderConfigured(string $providerIdOrClassName): void + { + // A provider reports itself unconfigured for any reason it cannot be used, including + // credentials that are missing, invalid, or rejected, so the message covers all of them. + if (!$this->registry->isProviderConfigured($providerIdOrClassName)) { + throw new InvalidArgumentException( + sprintf( + 'Provider "%s" is not registered, or is not configured with valid credentials. ' + . 'Ensure the provider is registered and its credentials are present and valid ' + . 'before generating embeddings.', + $providerIdOrClassName + ) + ); + } + } + + /** + * Describes the requirements of the current request that the given model does not meet. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to check. + * @return string|null A description of the unmet requirements, or null if the model meets them all. + */ + private function describeUnmetRequirements(ModelInterface $model): ?string + { + $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->effectiveModelConfig($model)); + + /** @var UnmetModelRequirementsShape $unmetRequirements */ + $unmetRequirements = $requirements->getUnmetRequirements($model->metadata()); + + $descriptions = []; + + if ($unmetRequirements['capabilities'] !== []) { + $descriptions[] = sprintf( + 'Unsupported capabilities: %s.', + implode(', ', array_map( + static fn (CapabilityEnum $capability): string => $capability->value, + $unmetRequirements['capabilities'] + )) + ); + } + + if ($unmetRequirements['options'] !== []) { + $descriptions[] = sprintf( + 'Unsupported options: %s.', + implode(', ', array_map( + fn (RequiredOption $option): string => sprintf( + '%s (%s)', + $option->getName()->value, + $this->describeOptionValue($option->getValue()) + ), + $unmetRequirements['options'] + )) + ); + } + + if ($descriptions === []) { + return null; + } + + return implode(' ', $descriptions); + } + + /** + * Describes an option value for use in an error message. + * + * @since n.e.x.t + * + * @param mixed $value The option value to describe. + * @return string The human readable description. + */ + private function describeOptionValue($value): string + { + if ($value === null) { + return 'null'; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value instanceof AbstractEnum) { + return $value->value; + } + + if (is_array($value)) { + return '[' . implode(', ', array_map( + fn ($item): string => $this->describeOptionValue($item), + $value + )) . ']'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + return is_object($value) ? get_class($value) : gettype($value); + } + + /** + * Parses a single input into a message part. + * + * @since 1.4.0 + * * @param mixed $input The input to parse. Accepts a string, MessagePart, File, or * MessagePartArrayShape. * @return MessagePart The parsed message part. @@ -279,7 +641,7 @@ private function parseInput($input): MessagePart /** * Validates that a message part is a valid embedding input. * - * @since n.e.x.t + * @since 1.4.0 * * @param MessagePart $part The part to validate. * @return MessagePart The validated part. @@ -298,7 +660,7 @@ private function validatePart(MessagePart $part): MessagePart /** * Dispatches an event if an event dispatcher is registered. * - * @since n.e.x.t + * @since 1.4.0 * * @param object $event The event to dispatch. * @return void diff --git a/includes/Vendor/AiClient/src/Builders/Traits/ModelConfigurationTrait.php b/includes/Vendor/AiClient/src/Builders/Traits/ModelConfigurationTrait.php new file mode 100644 index 000000000..242bcd26f --- /dev/null +++ b/includes/Vendor/AiClient/src/Builders/Traits/ModelConfigurationTrait.php @@ -0,0 +1,62 @@ +mergeModelConfig($config); + + return $this; + } + + /** + * Merges the given configuration into the builder's configuration. + * + * The builder's own configuration takes precedence for any overlapping settings, so that + * explicitly configured values are never overwritten by defaults from another source. + * + * @since n.e.x.t + * + * @param ModelConfig $config The model configuration to merge. + * @return void + */ + protected function mergeModelConfig(ModelConfig $config): void + { + // Merge arrays with builder config taking precedence + $mergedArray = array_merge($config->toArray(), $this->modelConfig->toArray()); + + // Create new config from merged array + $this->modelConfig = ModelConfig::fromArray($mergedArray); + } +} diff --git a/includes/Vendor/AiClient/src/Builders/Traits/ModelResolutionTrait.php b/includes/Vendor/AiClient/src/Builders/Traits/ModelResolutionTrait.php deleted file mode 100644 index 463fcae77..000000000 --- a/includes/Vendor/AiClient/src/Builders/Traits/ModelResolutionTrait.php +++ /dev/null @@ -1,133 +0,0 @@ -modelResolver->setModel($model); - - // Merge model's config with builder's config, with builder's config taking precedence - $modelConfigArray = $model->getConfig()->toArray(); - $builderConfigArray = $this->modelConfig->toArray(); - $mergedConfigArray = array_merge($modelConfigArray, $builderConfigArray); - - $this->modelConfig = ModelConfig::fromArray($mergedConfigArray); - - return $this; - } - - /** - * Sets preferred models to evaluate in order. - * - * @since 0.2.0 - * - * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, - * model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify - * only model IDs or model instances, as that will allow for different providers that expose the same model to be - * considered. - * @return self - * - * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. - */ - public function usingModelPreference(...$preferredModels): self - { - $this->modelResolver->setModelPreferences(...$preferredModels); - - return $this; - } - - /** - * Sets the model configuration. - * - * Merges the provided configuration with the builder's configuration, - * with builder configuration taking precedence. - * - * @since 0.1.0 - * - * @param ModelConfig $config The model configuration to merge. - * @return self - */ - public function usingModelConfig(ModelConfig $config): self - { - // Convert both configs to arrays - $builderConfigArray = $this->modelConfig->toArray(); - $providedConfigArray = $config->toArray(); - - // Merge arrays with builder config taking precedence - $mergedArray = array_merge($providedConfigArray, $builderConfigArray); - - // Create new config from merged array - $this->modelConfig = ModelConfig::fromArray($mergedArray); - - return $this; - } - - /** - * Sets the provider to use for generation. - * - * @since 0.1.0 - * - * @param string $providerIdOrClassName The provider ID or class name. - * @return self - */ - public function usingProvider(string $providerIdOrClassName): self - { - $this->modelResolver->setProvider($providerIdOrClassName); - return $this; - } - - /** - * Sets the request options for HTTP transport. - * - * @since 0.3.0 - * - * @param RequestOptions $requestOptions The request options. - * @return self - */ - public function usingRequestOptions(RequestOptions $requestOptions): self - { - $this->modelResolver->setRequestOptions($requestOptions); - return $this; - } -} diff --git a/includes/Vendor/AiClient/src/Events/AfterGenerateEmbeddingEvent.php b/includes/Vendor/AiClient/src/Events/AfterGenerateEmbeddingEvent.php index b1fbbce26..2cabf4c4f 100644 --- a/includes/Vendor/AiClient/src/Events/AfterGenerateEmbeddingEvent.php +++ b/includes/Vendor/AiClient/src/Events/AfterGenerateEmbeddingEvent.php @@ -12,7 +12,7 @@ /** * Event dispatched after embeddings have been generated. * - * @since n.e.x.t + * @since 1.4.0 */ class AfterGenerateEmbeddingEvent { @@ -39,7 +39,7 @@ class AfterGenerateEmbeddingEvent /** * Constructor. * - * @since n.e.x.t + * @since 1.4.0 * * @param list $inputs The inputs that were sent to the model. * @param ModelInterface $model The model that generated embeddings. @@ -61,7 +61,7 @@ public function __construct( /** * Gets the inputs that were sent to the model. * - * @since n.e.x.t + * @since 1.4.0 * * @return list The inputs. */ @@ -73,7 +73,7 @@ public function getInputs(): array /** * Gets the model that generated embeddings. * - * @since n.e.x.t + * @since 1.4.0 * * @return ModelInterface The model. */ @@ -85,7 +85,7 @@ public function getModel(): ModelInterface /** * Gets the capability that was used for generation. * - * @since n.e.x.t + * @since 1.4.0 * * @return CapabilityEnum The capability. */ @@ -97,7 +97,7 @@ public function getCapability(): CapabilityEnum /** * Gets the result from the model. * - * @since n.e.x.t + * @since 1.4.0 * * @return EmbeddingResult The result. */ @@ -109,7 +109,7 @@ public function getResult(): EmbeddingResult /** * Performs a deep clone of the event. * - * @since n.e.x.t + * @since 1.4.0 */ public function __clone() { diff --git a/includes/Vendor/AiClient/src/Events/BeforeGenerateEmbeddingEvent.php b/includes/Vendor/AiClient/src/Events/BeforeGenerateEmbeddingEvent.php index 9b02aeca3..954be2d42 100644 --- a/includes/Vendor/AiClient/src/Events/BeforeGenerateEmbeddingEvent.php +++ b/includes/Vendor/AiClient/src/Events/BeforeGenerateEmbeddingEvent.php @@ -11,7 +11,7 @@ /** * Event dispatched before inputs are sent to an embedding generation model. * - * @since n.e.x.t + * @since 1.4.0 */ class BeforeGenerateEmbeddingEvent { @@ -33,7 +33,7 @@ class BeforeGenerateEmbeddingEvent /** * Constructor. * - * @since n.e.x.t + * @since 1.4.0 * * @param list $inputs The inputs to be sent to the model. * @param ModelInterface $model The model that will generate embeddings. @@ -49,7 +49,7 @@ public function __construct(array $inputs, ModelInterface $model, CapabilityEnum /** * Gets the inputs to be sent to the model. * - * @since n.e.x.t + * @since 1.4.0 * * @return list The inputs. */ @@ -61,7 +61,7 @@ public function getInputs(): array /** * Gets the model that will generate embeddings. * - * @since n.e.x.t + * @since 1.4.0 * * @return ModelInterface The model. */ @@ -73,7 +73,7 @@ public function getModel(): ModelInterface /** * Gets the capability being used for generation. * - * @since n.e.x.t + * @since 1.4.0 * * @return CapabilityEnum The capability. */ @@ -85,7 +85,7 @@ public function getCapability(): CapabilityEnum /** * Performs a deep clone of the event. * - * @since n.e.x.t + * @since 1.4.0 */ public function __clone() { diff --git a/includes/Vendor/AiClient/src/Providers/ModelResolver.php b/includes/Vendor/AiClient/src/Providers/ModelResolver.php index 8c4c3a74f..33a7557e1 100644 --- a/includes/Vendor/AiClient/src/Providers/ModelResolver.php +++ b/includes/Vendor/AiClient/src/Providers/ModelResolver.php @@ -20,7 +20,13 @@ * is shared between the builders (via the model resolution trait) so that model * selection behaves identically regardless of what is being generated. * - * @since n.e.x.t + * @since 1.4.0 + * + * A ProviderModelTuple is a two-element list identifying a model by its provider: index 0 is + * the provider ID or provider class name, index 1 is the model ID. For example, + * ['google', 'gemini-embedding-001']. + * + * @phpstan-type ProviderModelTuple array{0: string, 1: string} */ class ModelResolver { @@ -52,7 +58,7 @@ class ModelResolver /** * Constructor. * - * @since n.e.x.t + * @since 1.4.0 * * @param ProviderRegistry $registry The provider registry for finding suitable models. */ @@ -68,7 +74,7 @@ public function __construct(ProviderRegistry $registry) * explicitly set model are service objects and are intentionally NOT cloned, as * they should be shared references. * - * @since n.e.x.t + * @since 1.4.0 */ public function __clone() { @@ -80,7 +86,7 @@ public function __clone() /** * Sets the model to use for generation. * - * @since n.e.x.t + * @since 1.4.0 * * @param ModelInterface $model The model to use. * @return void @@ -93,7 +99,7 @@ public function setModel(ModelInterface $model): void /** * Gets the explicitly set model, if any. * - * @since n.e.x.t + * @since 1.4.0 * * @return ModelInterface|null The explicitly set model, or null if none was set. */ @@ -105,9 +111,9 @@ public function getModel(): ?ModelInterface /** * Sets preferred models to evaluate in order. * - * @since n.e.x.t + * @since 1.4.0 * - * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, + * @param string|ModelInterface|ProviderModelTuple ...$preferredModels The preferred models as model IDs, * model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify * only model IDs or model instances, as that will allow for different providers that expose the same model to be * considered. @@ -169,7 +175,7 @@ public function setModelPreferences(...$preferredModels): void /** * Sets the provider to use for generation. * - * @since n.e.x.t + * @since 1.4.0 * * @param string $providerIdOrClassName The provider ID or class name. * @return void @@ -182,7 +188,7 @@ public function setProvider(string $providerIdOrClassName): void /** * Sets the request options for HTTP transport. * - * @since n.e.x.t + * @since 1.4.0 * * @param RequestOptions $requestOptions The request options. * @return void @@ -199,7 +205,7 @@ public function setRequestOptions(RequestOptions $requestOptions): void * and returns it. Otherwise, finds a suitable model based on the requirements, * honoring any configured model preferences and provider constraint. * - * @since n.e.x.t + * @since 1.4.0 * * @param ModelRequirements $requirements The requirements the model must satisfy. * @param ModelConfig $modelConfig The model configuration to apply. @@ -304,7 +310,7 @@ public function resolve( /** * Checks whether any model can satisfy the given requirements. * - * @since n.e.x.t + * @since 1.4.0 * * @param ModelRequirements $requirements The requirements to check support for. * @return bool True if the set model or any registered model meets the requirements. @@ -331,7 +337,7 @@ public function isSupported(ModelRequirements $requirements): bool * * Request options are only applicable to API-based models that make HTTP requests. * - * @since n.e.x.t + * @since 1.4.0 * * @param ModelInterface $model The model to bind request options to. * @return void @@ -346,10 +352,10 @@ private function bindModelRequestOptions(ModelInterface $model): void /** * Builds a map of candidate models that satisfy the requirements for efficient lookup. * - * @since n.e.x.t + * @since 1.4.0 * * @param ModelRequirements $requirements The requirements derived from the prompt. - * @return array Map of preference keys to [providerId, modelId] tuples. + * @return array Map of preference keys to [providerId, modelId] tuples. */ private function getCandidateModelsMap(ModelRequirements $requirements): array { @@ -384,11 +390,11 @@ private function getCandidateModelsMap(ModelRequirements $requirements): array /** * Generates a candidate map from model metadata with both provider-specific and model-only keys. * - * @since n.e.x.t + * @since 1.4.0 * * @param string $providerId The provider ID. * @param list $modelsMetadata The models metadata to map. - * @return array Map of preference keys to [providerId, modelId] tuples. + * @return array Map of preference keys to [providerId, modelId] tuples. */ private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array { @@ -412,7 +418,7 @@ private function generateMapFromCandidates(string $providerId, array $modelsMeta /** * Normalizes and validates a preference identifier string. * - * @since n.e.x.t + * @since 1.4.0 * * @param mixed $value The value to normalize. * @param string $emptyMessage The message for empty or invalid values. @@ -439,7 +445,7 @@ private function normalizePreferenceIdentifier( /** * Creates a preference key for a provider/model combination. * - * @since n.e.x.t + * @since 1.4.0 * * @param string $providerId The provider identifier. * @param string $modelId The model identifier. @@ -453,7 +459,7 @@ private function createProviderModelPreferenceKey(string $providerId, string $mo /** * Creates a preference key for a model identifier. * - * @since n.e.x.t + * @since 1.4.0 * * @param string $modelId The model identifier. * @return string The generated preference key. diff --git a/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelConfig.php b/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelConfig.php index d046a0885..fad995d0f 100644 --- a/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelConfig.php +++ b/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelConfig.php @@ -781,7 +781,7 @@ public function getOutputSpeechVoice(): ?string /** * Sets the embedding dimensions. * - * @since n.e.x.t + * @since 1.4.0 * * @param int $dimensions The embedding dimensions. */ @@ -797,7 +797,7 @@ public function setDimensions(int $dimensions): void /** * Gets the embedding dimensions. * - * @since n.e.x.t + * @since 1.4.0 * * @return int|null The embedding dimensions. */ diff --git a/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelRequirements.php b/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelRequirements.php index 2bc2b9627..2441b2e4f 100644 --- a/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelRequirements.php +++ b/includes/Vendor/AiClient/src/Providers/Models/DTO/ModelRequirements.php @@ -27,6 +27,11 @@ * requiredOptions: list * } * + * @phpstan-type UnmetModelRequirementsShape array{ + * capabilities: list, + * options: list + * } + * * @extends AbstractDataTransferObject */ class ModelRequirements extends AbstractDataTransferObject @@ -101,6 +106,25 @@ public function getRequiredOptions(): array * @return bool True if the model meets all requirements, false otherwise. */ public function areMetBy(ModelMetadata $metadata): bool + { + $unmetRequirements = $this->getUnmetRequirements($metadata); + + return $unmetRequirements['capabilities'] === [] && $unmetRequirements['options'] === []; + } + + /** + * Determines which of these requirements the given model metadata does not meet. + * + * Unlike {@see self::areMetBy()}, this method reports the specific capabilities and options that + * are unsupported, so that calling code can explain why a model is unsuitable. + * + * @since n.e.x.t + * + * @param ModelMetadata $metadata The model metadata to check against. + * @return UnmetModelRequirementsShape The unsupported capabilities and options. Both lists are empty if the + * model meets all requirements. + */ + public function getUnmetRequirements(ModelMetadata $metadata): array { // Create lookup maps for better performance (instead of nested foreach loops) $capabilitiesMap = []; @@ -113,29 +137,29 @@ public function areMetBy(ModelMetadata $metadata): bool $optionsMap[$option->getName()->value] = $option; } - // Check if all required capabilities are supported using map lookup + // Collect required capabilities that are not supported, using map lookup + $unmetCapabilities = []; foreach ($this->requiredCapabilities as $requiredCapability) { if (!isset($capabilitiesMap[$requiredCapability->value])) { - return false; + $unmetCapabilities[] = $requiredCapability; } } - // Check if all required options are supported with the specified values + // Collect required options that are either unsupported or unsupported with the required value + $unmetOptions = []; foreach ($this->requiredOptions as $requiredOption) { // Use map lookup instead of linear search - if (!isset($optionsMap[$requiredOption->getName()->value])) { - return false; - } - - $supportedOption = $optionsMap[$requiredOption->getName()->value]; + $supportedOption = $optionsMap[$requiredOption->getName()->value] ?? null; - // Check if the required value is supported by this option - if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { - return false; + if ($supportedOption === null || !$supportedOption->isSupportedValue($requiredOption->getValue())) { + $unmetOptions[] = $requiredOption; } } - return true; + return [ + 'capabilities' => $unmetCapabilities, + 'options' => $unmetOptions, + ]; } /** @@ -207,7 +231,7 @@ public static function fromPromptData(CapabilityEnum $capability, array $message * conversation, so no chat history capability is inferred. Each input contributes its input * modality (text or file) to the requirements. * - * @since n.e.x.t + * @since 1.4.0 * * @param list $inputs The embedding inputs. * @param ModelConfig $modelConfig The model configuration. @@ -246,7 +270,7 @@ public static function fromEmbeddingData(array $inputs, ModelConfig $modelConfig /** * Determines the input modality contributed by a message part, if any. * - * @since n.e.x.t + * @since 1.4.0 * * @param MessagePart $part The message part to analyze. * @return ModalityEnum|null The input modality, or null if the part contributes none. diff --git a/includes/Vendor/AiClient/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php b/includes/Vendor/AiClient/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php index 783a7bb08..b18644d19 100644 --- a/includes/Vendor/AiClient/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php +++ b/includes/Vendor/AiClient/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php @@ -10,14 +10,14 @@ /** * Interface for models that support embedding generation. * - * @since n.e.x.t + * @since 1.4.0 */ interface EmbeddingGenerationModelInterface { /** * Generates embeddings from one or more inputs. * - * @since n.e.x.t + * @since 1.4.0 * * @param list $inputs The inputs to embed, one embedding generated per input. * @return EmbeddingResult Result containing one embedding per input, in input order. diff --git a/includes/Vendor/AiClient/src/Results/DTO/Embedding.php b/includes/Vendor/AiClient/src/Results/DTO/Embedding.php index 1b3c59688..076109c63 100644 --- a/includes/Vendor/AiClient/src/Results/DTO/Embedding.php +++ b/includes/Vendor/AiClient/src/Results/DTO/Embedding.php @@ -14,7 +14,7 @@ /** * Represents a single generated embedding vector. * - * @since n.e.x.t + * @since 1.4.0 * * @phpstan-type EmbeddingList list * @@ -35,7 +35,7 @@ final class Embedding implements Countable, IteratorAggregate, JsonSerializable /** * Constructor. * - * @since n.e.x.t + * @since 1.4.0 * * @param EmbeddingList $values The embedding vector values. * @param int $dimensions The vector dimension count. @@ -65,7 +65,7 @@ public function __construct(array $values, int $dimensions) /** * Checks whether the value is a list of integers or floats. * - * @since n.e.x.t + * @since 1.4.0 * * @param mixed $values The value to check. * @return bool True if the value is an embedding list. @@ -90,7 +90,7 @@ private static function isEmbeddingList($values): bool /** * Gets the vector values. * - * @since n.e.x.t + * @since 1.4.0 * * @return EmbeddingList The embedding vector values. */ @@ -102,7 +102,7 @@ public function getValues(): array /** * Gets the vector dimension count. * - * @since n.e.x.t + * @since 1.4.0 * * @return int The vector dimension count. */ @@ -114,7 +114,7 @@ public function getDimensions(): int /** * Gets the number of vector values. * - * @since n.e.x.t + * @since 1.4.0 * * @return int The number of vector values. */ @@ -126,7 +126,7 @@ public function count(): int /** * Gets an iterator for the vector values. * - * @since n.e.x.t + * @since 1.4.0 * * @return Traversable The vector value iterator. */ @@ -138,7 +138,7 @@ public function getIterator(): Traversable /** * Gets the JSON schema for embedding vectors. * - * @since n.e.x.t + * @since 1.4.0 * * @return array The JSON schema. */ @@ -156,7 +156,7 @@ public static function getJsonSchema(): array /** * Converts the embedding to an array. * - * @since n.e.x.t + * @since 1.4.0 * * @return EmbeddingList The embedding vector values. */ @@ -168,7 +168,7 @@ public function toArray(): array /** * Creates an embedding from an array. * - * @since n.e.x.t + * @since 1.4.0 * * @param EmbeddingList $array The embedding vector values. * @param int $dimensions The vector dimension count. @@ -182,7 +182,7 @@ public static function fromArray(array $array, int $dimensions): self /** * Converts the embedding to a JSON-serializable value. * - * @since n.e.x.t + * @since 1.4.0 * * @return EmbeddingList The embedding vector values. */ diff --git a/includes/Vendor/AiClient/src/Results/DTO/EmbeddingResult.php b/includes/Vendor/AiClient/src/Results/DTO/EmbeddingResult.php index 6b8d8f8e1..d5ebbed12 100644 --- a/includes/Vendor/AiClient/src/Results/DTO/EmbeddingResult.php +++ b/includes/Vendor/AiClient/src/Results/DTO/EmbeddingResult.php @@ -13,7 +13,7 @@ /** * Represents the result of an embedding generation operation. * - * @since n.e.x.t + * @since 1.4.0 * * @phpstan-import-type TokenUsageArrayShape from TokenUsage * @phpstan-import-type ProviderMetadataArrayShape from ProviderMetadata @@ -80,7 +80,7 @@ class EmbeddingResult extends AbstractDataTransferObject implements ResultInterf /** * Constructor. * - * @since n.e.x.t + * @since 1.4.0 * * @param string $id Unique identifier for this result. * @param list $embeddings The generated embedding vectors. @@ -130,7 +130,7 @@ public function __construct( /** * {@inheritDoc} * - * @since n.e.x.t + * @since 1.4.0 */ public function getId(): string { @@ -140,7 +140,7 @@ public function getId(): string /** * Gets the generated embedding vectors. * - * @since n.e.x.t + * @since 1.4.0 * * @return list The embeddings. */ @@ -152,7 +152,7 @@ public function getEmbeddings(): array /** * Gets the first generated embedding vector. * - * @since n.e.x.t + * @since 1.4.0 * * @return Embedding The first embedding. */ @@ -164,7 +164,7 @@ public function getEmbedding(): Embedding /** * Gets the vector dimension count. * - * @since n.e.x.t + * @since 1.4.0 * * @return int The vector dimension count. */ @@ -176,7 +176,7 @@ public function getDimensions(): int /** * {@inheritDoc} * - * @since n.e.x.t + * @since 1.4.0 */ public function getTokenUsage(): TokenUsage { @@ -186,7 +186,7 @@ public function getTokenUsage(): TokenUsage /** * {@inheritDoc} * - * @since n.e.x.t + * @since 1.4.0 */ public function getProviderMetadata(): ProviderMetadata { @@ -196,7 +196,7 @@ public function getProviderMetadata(): ProviderMetadata /** * {@inheritDoc} * - * @since n.e.x.t + * @since 1.4.0 */ public function getModelMetadata(): ModelMetadata { @@ -206,7 +206,7 @@ public function getModelMetadata(): ModelMetadata /** * {@inheritDoc} * - * @since n.e.x.t + * @since 1.4.0 */ public function getAdditionalData(): array { @@ -216,7 +216,7 @@ public function getAdditionalData(): array /** * Gets the JSON schema for embedding results. * - * @since n.e.x.t + * @since 1.4.0 * * @return array The JSON schema. */ @@ -262,7 +262,7 @@ public static function getJsonSchema(): array /** * Converts the embedding result to an array. * - * @since n.e.x.t + * @since 1.4.0 * * @return EmbeddingResultArrayShape The embedding result array. */ @@ -290,7 +290,7 @@ public function toArray(): array /** * Creates an embedding result from an array. * - * @since n.e.x.t + * @since 1.4.0 * * @param EmbeddingResultArrayShape $array The embedding result array. * @return self The embedding result instance. diff --git a/includes/helpers.php b/includes/helpers.php index 5a2ae1703..90692d21a 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -18,6 +18,7 @@ use WordPress\AI\Services\Guidelines; use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\EmbeddingBuilder; +use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** @@ -827,14 +828,19 @@ function supports_embedding_generation(): bool { * Generates embeddings for one or more text inputs. * * @since 1.3.0 + * @since x.x.x Requires a specific model. * * @param string|list $input The text input, or a list of inputs for batch embedding. * @param array $args { - * Optional. Generation options. - * - * @type string $provider Connector/provider ID to use. - * @type list $model_preference Ordered model preferences. - * @type int $dimensions Requested embedding vector dimensions. + * Generation options. + * + * @type \WordPress\AiClient\Providers\Models\Contracts\ModelInterface|string $model Required. The + * embedding model to use, either a model instance or a model + * ID. A model ID also requires `$provider`. + * @type string $provider Required when `$model` is a model ID. The connector/provider + * ID or class name that offers the model. Ignored when + * `$model` is a model instance. + * @type int $dimensions Optional. Requested embedding vector dimensions. * } * @return \WordPress\AiClient\Results\DTO\EmbeddingResult|\WP_Error The result, or WP_Error on failure. */ @@ -846,15 +852,31 @@ function generate_embeddings( $input, array $args = array() ) { ); } + $model = $args['model'] ?? null; + + if ( ! $model instanceof ModelInterface && ( ! is_string( $model ) || '' === trim( $model ) ) ) { + return new \WP_Error( + 'ai_embeddings_missing_model', + __( 'An embedding model must be specified. Embeddings are only comparable to other embeddings from the same model, so no model is selected automatically.', 'ai' ) + ); + } + + $provider = isset( $args['provider'] ) && is_string( $args['provider'] ) ? trim( $args['provider'] ) : ''; + + if ( is_string( $model ) && '' === $provider ) { + return new \WP_Error( + 'ai_embeddings_missing_provider', + __( 'A provider must be specified when the embedding model is given as a model ID.', 'ai' ) + ); + } + try { $builder = new EmbeddingBuilder( AiClient::defaultRegistry(), $input ); - if ( isset( $args['provider'] ) && is_string( $args['provider'] ) && '' !== $args['provider'] ) { - $builder->usingProvider( $args['provider'] ); - } - - if ( ! empty( $args['model_preference'] ) && is_array( $args['model_preference'] ) ) { - $builder->usingModelPreference( ...array_values( $args['model_preference'] ) ); + if ( $model instanceof ModelInterface ) { + $builder->usingModel( $model ); + } else { + $builder->usingProviderModel( $provider, $model ); } if ( isset( $args['dimensions'] ) ) { diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index 3fad6fcfc..31b87ddf3 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -1950,7 +1950,6 @@ public function test_post_type_supports_bulk_ai_summarization_returns_false_for_ * builder on environments whose bundled SDK predates it. */ public function test_supports_embedding_generation_is_true_with_base_sdk(): void { - $this->markTestSkipped( 'Embedding support is not available in this environment.' ); if ( ! class_exists( 'WordPress\\AiClient\\AiClient' ) ) { $this->markTestSkipped( 'Base PHP AI Client SDK not present in this environment.' ); } @@ -1961,32 +1960,90 @@ public function test_supports_embedding_generation_is_true_with_base_sdk(): void /** * Invalid input is converted to a WP_Error rather than escaping as an SDK exception. * - * An empty string is rejected by the builder before any model resolution or HTTP call, so this - * exercises the try/catch conversion deterministically, whatever connectors are configured. + * An empty string is rejected by the builder's constructor, before the model is applied or any + * HTTP call is made, so this exercises the try/catch conversion deterministically, whatever + * connectors are configured. */ public function test_generate_embeddings_converts_invalid_input_to_wp_error(): void { if ( ! \WordPress\AI\supports_embedding_generation() ) { $this->markTestSkipped( 'Embeddings not supported in this environment.' ); } - $result = \WordPress\AI\generate_embeddings( '' ); + $result = \WordPress\AI\generate_embeddings( '', self::embedding_model_args() ); $this->assertInstanceOf( \WP_Error::class, $result ); $this->assertSame( 'ai_embeddings_failed', $result->get_error_code() ); } + /** + * A model is required, because embeddings are only comparable within a single model. + */ + public function test_generate_embeddings_requires_a_model(): void { + if ( ! \WordPress\AI\supports_embedding_generation() ) { + $this->markTestSkipped( 'Embeddings not supported in this environment.' ); + } + + $result = \WordPress\AI\generate_embeddings( 'hello world' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'ai_embeddings_missing_model', $result->get_error_code() ); + } + + /** + * An empty or non-string model is rejected the same way a missing one is. + */ + public function test_generate_embeddings_rejects_an_unusable_model_value(): void { + if ( ! \WordPress\AI\supports_embedding_generation() ) { + $this->markTestSkipped( 'Embeddings not supported in this environment.' ); + } + + foreach ( array( '', ' ', 123, array( 'openai', 'text-embedding-3-small' ) ) as $model ) { + $result = \WordPress\AI\generate_embeddings( + 'hello world', + array( + 'provider' => 'openai', + 'model' => $model, + ) + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( + 'ai_embeddings_missing_model', + $result->get_error_code(), + sprintf( 'Model value %s should be rejected as missing.', var_export( $model, true ) ) + ); + } + } + + /** + * A model given as an ID needs a provider to look it up in. + */ + public function test_generate_embeddings_requires_a_provider_for_a_model_id(): void { + if ( ! \WordPress\AI\supports_embedding_generation() ) { + $this->markTestSkipped( 'Embeddings not supported in this environment.' ); + } + + $result = \WordPress\AI\generate_embeddings( + 'hello world', + array( 'model' => 'text-embedding-3-small' ) + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'ai_embeddings_missing_provider', $result->get_error_code() ); + } + /** * The helper always returns one of its two documented types, never a fatal. * - * Deliberately does not pin the error code: whether a real embedding model resolves depends on - * which connectors the environment has configured. + * Deliberately does not pin the error code: whether the named model is usable depends on which + * connectors the environment has configured. */ public function test_generate_embeddings_returns_a_documented_type(): void { if ( ! \WordPress\AI\supports_embedding_generation() ) { $this->markTestSkipped( 'Embeddings not supported in this environment.' ); } - $result = \WordPress\AI\generate_embeddings( 'hello world' ); + $result = \WordPress\AI\generate_embeddings( 'hello world', self::embedding_model_args() ); $this->assertTrue( is_wp_error( $result ) || $result instanceof \WordPress\AiClient\Results\DTO\EmbeddingResult, @@ -1994,6 +2051,21 @@ public function test_generate_embeddings_returns_a_documented_type(): void { ); } + /** + * Returns a provider/model pair that satisfies the helper's required-model check. + * + * The pair only has to get past argument validation; these tests never assert that the model + * resolves, so no connector needs to be configured. + * + * @return array + */ + private static function embedding_model_args(): array { + return array( + 'provider' => 'openai', + 'model' => 'text-embedding-3-small', + ); + } + /** * Embeddings are unsupported without the base SDK, and that is reported as a WP_Error. */ diff --git a/tests/Integration/Includes/SDK_OverlayTest.php b/tests/Integration/Includes/SDK_OverlayTest.php index b9c9257ea..f40d7da4a 100644 --- a/tests/Integration/Includes/SDK_OverlayTest.php +++ b/tests/Integration/Includes/SDK_OverlayTest.php @@ -71,7 +71,6 @@ public function test_class_to_file_ignores_foreign_prefix(): void { * After bootstrap, the sentinel embedding class is loadable (from overlay or environment). */ public function test_embedding_classes_are_available_after_bootstrap(): void { - $this->markTestSkipped( 'Embedding support is not available in this environment.' ); $this->assertTrue( class_exists( 'WordPress\\AiClient\\Builders\\EmbeddingBuilder' ), 'EmbeddingBuilder should be loadable after the plugin bootstraps.' @@ -79,16 +78,65 @@ class_exists( 'WordPress\\AiClient\\Builders\\EmbeddingBuilder' ), } /** - * The required new member on the override-race class is present (our copy won, or env has it). + * The required new members on the override-race class are present (our copy won, or env has it). */ public function test_model_requirements_has_embedding_factory(): void { - $this->markTestSkipped( 'Embedding support is not available in this environment.' ); $this->assertTrue( method_exists( 'WordPress\\AiClient\\Providers\\Models\\DTO\\ModelRequirements', 'fromEmbeddingData' ), - 'ModelRequirements::fromEmbeddingData() must be available for embedding model resolution.' + 'ModelRequirements::fromEmbeddingData() must be available to derive embedding requirements.' + ); + + $this->assertTrue( + method_exists( + 'WordPress\\AiClient\\Providers\\Models\\DTO\\ModelRequirements', + 'getUnmetRequirements' + ), + 'ModelRequirements::getUnmetRequirements() must be available to explain why a model is unsuitable.' + ); + } + + /** + * The builder exposes the model-required API, not the superseded model-resolution API. + * + * Embedding vectors are only comparable within a single model, so the builder must make the + * caller name one. A builder that still accepted a preference list would silently pick a + * different model as connectors change, invalidating any stored corpus. + */ + public function test_embedding_builder_requires_an_explicit_model(): void { + $builder = 'WordPress\\AiClient\\Builders\\EmbeddingBuilder'; + + $this->assertTrue( + method_exists( $builder, 'usingProviderModel' ), + 'EmbeddingBuilder::usingProviderModel() must be available to name a model explicitly.' + ); + $this->assertTrue( + method_exists( $builder, 'usingModel' ), + 'EmbeddingBuilder::usingModel() must be available to pass a model instance.' + ); + $this->assertFalse( + method_exists( $builder, 'usingModelPreference' ), + 'EmbeddingBuilder must no longer resolve a model from a preference list.' + ); + $this->assertFalse( + method_exists( $builder, 'usingProvider' ), + 'EmbeddingBuilder must no longer resolve a model from a provider alone.' + ); + } + + /** + * The configuration trait the builder composes is served, and the superseded one is not shipped. + */ + public function test_overlay_ships_the_configuration_trait_not_the_resolution_trait(): void { + $this->assertNotNull( + SDK_Overlay::class_to_file( 'WordPress\\AiClient\\Builders\\Traits\\ModelConfigurationTrait' ), + 'ModelConfigurationTrait must be vendored; EmbeddingBuilder composes it.' + ); + $this->assertNull( + SDK_Overlay::class_to_file( 'WordPress\\AiClient\\Builders\\Traits\\ModelResolutionTrait' ), + 'ModelResolutionTrait must not be vendored; nothing on the embedding path uses it.' ); }