[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/php-ai-client/src/Builders/ -> EmbeddingBuilder.php (source)

   1  <?php
   2  
   3  declare (strict_types=1);
   4  namespace WordPress\AiClient\Builders;
   5  
   6  use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface;
   7  use WordPress\AiClient\Builders\Traits\ModelConfigurationTrait;
   8  use WordPress\AiClient\Common\AbstractEnum;
   9  use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface;
  10  use WordPress\AiClient\Common\Exception\InvalidArgumentException;
  11  use WordPress\AiClient\Common\Exception\RuntimeException;
  12  use WordPress\AiClient\Events\AfterGenerateEmbeddingEvent;
  13  use WordPress\AiClient\Events\BeforeGenerateEmbeddingEvent;
  14  use WordPress\AiClient\Files\DTO\File;
  15  use WordPress\AiClient\Messages\DTO\MessagePart;
  16  use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface;
  17  use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
  18  use WordPress\AiClient\Providers\ModelResolver;
  19  use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
  20  use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
  21  use WordPress\AiClient\Providers\Models\DTO\ModelRequirements;
  22  use WordPress\AiClient\Providers\Models\DTO\RequiredOption;
  23  use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface;
  24  use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
  25  use WordPress\AiClient\Providers\ProviderRegistry;
  26  use WordPress\AiClient\Results\DTO\Embedding;
  27  use WordPress\AiClient\Results\DTO\EmbeddingResult;
  28  /**
  29   * Fluent builder for generating embeddings.
  30   *
  31   * Embeddings transform inputs into vector representations rather than generating a conversational
  32   * response. Each input is embedded independently, and the builder produces one embedding vector per
  33   * input.
  34   *
  35   * Unlike {@see PromptBuilder}, this builder never selects a model on the caller's behalf. Embedding
  36   * vectors are only comparable to other vectors produced by the same model, so a stored corpus is
  37   * permanently bound to the model that created it. Automatically choosing a model could therefore
  38   * silently invalidate existing vectors, for example when the registered providers change. The model
  39   * must be specified via {@see self::usingModel()} or {@see self::usingProviderModel()}; the builder
  40   * verifies that the given model can fulfill the request instead of searching for one that can.
  41   *
  42   * @since 1.4.0
  43   *
  44   * @phpstan-import-type MessagePartArrayShape from MessagePart
  45   * @phpstan-import-type UnmetModelRequirementsShape from ModelRequirements
  46   * @phpstan-import-type ProviderModelTuple from ModelResolver
  47   *
  48   * @phpstan-type EmbeddingInput string|MessagePart|File|MessagePartArrayShape
  49   */
  50  class EmbeddingBuilder
  51  {
  52      use ModelConfigurationTrait;
  53      /**
  54       * @var string Message used when no model was specified.
  55       */
  56      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().';
  57      /**
  58       * @var ProviderRegistry The provider registry used to prepare the model.
  59       */
  60      protected ProviderRegistry $registry;
  61      /**
  62       * @var list<MessagePart> The inputs to embed.
  63       */
  64      protected array $inputs = [];
  65      /**
  66       * @var ModelInterface|null The explicitly provided model, if any.
  67       */
  68      protected ?ModelInterface $model = null;
  69      /**
  70       * @var ProviderModelTuple|null The provider ID or class name and model ID, if any.
  71       */
  72      protected ?array $providerModel = null;
  73      /**
  74       * @var RequestOptions|null The request options for HTTP transport.
  75       */
  76      protected ?RequestOptions $requestOptions = null;
  77      /**
  78       * @var EventDispatcherInterface|null The event dispatcher for embedding lifecycle events.
  79       */
  80      private ?EventDispatcherInterface $eventDispatcher;
  81      /**
  82       * Constructor.
  83       *
  84       * @since 1.4.0
  85       *
  86       * @param ProviderRegistry $registry The provider registry used to prepare the model.
  87       * @param EmbeddingInput|list<EmbeddingInput>|null $input Optional initial input(s) to embed.
  88       * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events.
  89       */
  90      public function __construct(ProviderRegistry $registry, $input = null, ?EventDispatcherInterface $eventDispatcher = null)
  91      {
  92          $this->registry = $registry;
  93          $this->modelConfig = new ModelConfig();
  94          $this->eventDispatcher = $eventDispatcher;
  95          if ($input === null) {
  96              return;
  97          }
  98          if (is_array($input) && array_is_list($input)) {
  99              /** @var list<EmbeddingInput> $input */
 100              $this->withInput(...$input);
 101              return;
 102          }
 103          /** @var EmbeddingInput $input */
 104          $this->withInput($input);
 105      }
 106      /**
 107       * Creates a deep clone of this builder.
 108       *
 109       * Clones the inputs, model configuration, and request options. Service objects (registry, model,
 110       * event dispatcher) are intentionally NOT cloned as they are shared dependencies.
 111       *
 112       * @since 1.4.0
 113       */
 114      public function __clone()
 115      {
 116          $clonedInputs = [];
 117          foreach ($this->inputs as $input) {
 118              $clonedInputs[] = clone $input;
 119          }
 120          $this->inputs = $clonedInputs;
 121          $this->modelConfig = clone $this->modelConfig;
 122          if ($this->requestOptions !== null) {
 123              $this->requestOptions = clone $this->requestOptions;
 124          }
 125          // Note: $registry, $model, and $eventDispatcher are service objects and are intentionally
 126          // NOT cloned - they should be shared references.
 127      }
 128      /**
 129       * Adds one or more inputs to embed.
 130       *
 131       * @since 1.4.0
 132       *
 133       * @param EmbeddingInput ...$input The inputs to embed, each treated as an independent input.
 134       * @return self
 135       * @throws InvalidArgumentException If no inputs are provided or an input is invalid.
 136       */
 137      public function withInput(...$input): self
 138      {
 139          if ($input === []) {
 140              throw new InvalidArgumentException('At least one input must be provided.');
 141          }
 142          foreach ($input as $singleInput) {
 143              $this->inputs[] = $this->parseInput($singleInput);
 144          }
 145          return $this;
 146      }
 147      /**
 148       * Sets the model to use for embedding generation.
 149       *
 150       * The model's configuration will be merged with the builder's configuration,
 151       * with the builder's configuration taking precedence for any overlapping settings. The merge
 152       * happens when the model is used rather than here, so replacing the model replaces its
 153       * configuration along with it.
 154       *
 155       * @since 1.4.0
 156       *
 157       * @param ModelInterface $model The model to use.
 158       * @return self
 159       */
 160      public function usingModel(ModelInterface $model): self
 161      {
 162          $this->model = $model;
 163          $this->providerModel = null;
 164          return $this;
 165      }
 166      /**
 167       * Sets the model to use for embedding generation, by provider and model identifier.
 168       *
 169       * This is an alternative to {@see self::usingModel()} that does not require the caller to
 170       * reference a provider class directly. The model is retrieved from the registry when the
 171       * embeddings are generated, so it receives any configuration set afterwards.
 172       *
 173       * @since 1.5.0
 174       *
 175       * @param string $providerIdOrClassName The provider ID or class name.
 176       * @param string $modelId The model identifier.
 177       * @return self
 178       * @throws InvalidArgumentException If the provider or model identifier is empty.
 179       */
 180      public function usingProviderModel(string $providerIdOrClassName, string $modelId): self
 181      {
 182          if (trim($providerIdOrClassName) === '') {
 183              throw new InvalidArgumentException('Provider identifier cannot be empty.');
 184          }
 185          if (trim($modelId) === '') {
 186              throw new InvalidArgumentException('Model identifier cannot be empty.');
 187          }
 188          $this->providerModel = [$providerIdOrClassName, $modelId];
 189          $this->model = null;
 190          return $this;
 191      }
 192      /**
 193       * Sets the request options for HTTP transport.
 194       *
 195       * @since 1.4.0
 196       *
 197       * @param RequestOptions $requestOptions The request options.
 198       * @return self
 199       */
 200      public function usingRequestOptions(RequestOptions $requestOptions): self
 201      {
 202          $this->requestOptions = $requestOptions;
 203          return $this;
 204      }
 205      /**
 206       * Sets the embedding dimensions.
 207       *
 208       * @since 1.4.0
 209       *
 210       * @param int $dimensions The embedding dimensions.
 211       * @return self
 212       */
 213      public function usingDimensions(int $dimensions): self
 214      {
 215          $this->modelConfig->setDimensions($dimensions);
 216          return $this;
 217      }
 218      /**
 219       * Checks whether the specified model supports the current inputs and configuration.
 220       *
 221       * This reports whether the model set via {@see self::usingModel()} or
 222       * {@see self::usingProviderModel()} can fulfill the request, rather than whether any available
 223       * model can.
 224       *
 225       * Any reason the specified model cannot fulfill the request is reported as `false`, including an
 226       * unregistered or unconfigured provider, a model ID the provider does not offer, and a provider
 227       * that could not be reached. Only failing to specify a model at all is treated as a programming
 228       * error and throws.
 229       *
 230       * A model instance provided via {@see self::usingModel()} is left untouched, so that checking
 231       * for support does not alter it. Determining whether the model's provider is configured may
 232       * require a request to the provider, which is cached for the remainder of the request.
 233       *
 234       * @since 1.4.0
 235       *
 236       * @return bool True if the specified model supports embedding generation for the current
 237       *              inputs and configuration.
 238       * @throws InvalidArgumentException If no model was specified.
 239       */
 240      public function isSupported(): bool
 241      {
 242          if ($this->providerModel === null && $this->model === null) {
 243              throw new InvalidArgumentException(self::NO_MODEL_MESSAGE);
 244          }
 245          try {
 246              $model = $this->locateModel();
 247          } catch (AiClientExceptionInterface $e) {
 248              // The model is unusable: its provider is not registered or configured, the provider has
 249              // no model with the given ID, or the provider could not be reached. Either way it cannot
 250              // fulfill the request.
 251              return \false;
 252          }
 253          if (!$model instanceof EmbeddingGenerationModelInterface) {
 254              return \false;
 255          }
 256          return $this->describeUnmetRequirements($model) === null;
 257      }
 258      /**
 259       * Generates an embedding result from the configured inputs.
 260       *
 261       * @since 1.4.0
 262       *
 263       * @return EmbeddingResult The generated embedding result.
 264       * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the
 265       *                                  specified model cannot fulfill the request.
 266       * @throws RuntimeException If the model returns an embedding count that does not match the
 267       *                          number of inputs.
 268       */
 269      public function generateEmbeddingResult(): EmbeddingResult
 270      {
 271          if ($this->inputs === []) {
 272              throw new InvalidArgumentException('Cannot generate embeddings from empty input. Add content using withInput().');
 273          }
 274          $capability = CapabilityEnum::embeddingGeneration();
 275          $model = $this->resolveModel();
 276          $this->dispatchEvent(new BeforeGenerateEmbeddingEvent($this->inputs, $model, $capability));
 277          $result = $model->generateEmbeddingResult($this->inputs);
 278          // Embeddings map positionally to inputs, so the counts must match.
 279          if (count($result->getEmbeddings()) !== count($this->inputs)) {
 280              throw new RuntimeException(sprintf('Expected %d embedding(s) from the model, but received %d.', count($this->inputs), count($result->getEmbeddings())));
 281          }
 282          $this->dispatchEvent(new AfterGenerateEmbeddingEvent($this->inputs, $model, $capability, $result));
 283          return $result;
 284      }
 285      /**
 286       * Generates a single embedding from the configured input.
 287       *
 288       * @since 1.4.0
 289       *
 290       * @return Embedding The generated embedding vector.
 291       * @throws InvalidArgumentException If no inputs are configured, no model was specified, the
 292       *                                  specified model cannot fulfill the request, or multiple
 293       *                                  inputs were provided.
 294       */
 295      public function generateEmbedding(): Embedding
 296      {
 297          if (count($this->inputs) > 1) {
 298              throw new InvalidArgumentException('generateEmbedding() returns a single vector; use generateEmbeddings() for multiple inputs.');
 299          }
 300          return $this->generateEmbeddingResult()->getEmbedding();
 301      }
 302      /**
 303       * Generates embeddings from the configured inputs.
 304       *
 305       * @since 1.4.0
 306       *
 307       * @return list<Embedding> The generated embedding vectors.
 308       * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the
 309       *                                  specified model cannot fulfill the request.
 310       * @throws RuntimeException If the model returns an embedding count that does not match the
 311       *                          number of inputs.
 312       */
 313      public function generateEmbeddings(): array
 314      {
 315          return $this->generateEmbeddingResult()->getEmbeddings();
 316      }
 317      /**
 318       * Resolves the specified model and verifies it can fulfill the request.
 319       *
 320       * @since 1.5.0
 321       *
 322       * @return ModelInterface&EmbeddingGenerationModelInterface The verified model.
 323       * @throws InvalidArgumentException If no model was specified, the model's provider is not
 324       *                                  configured, or the model cannot fulfill the request.
 325       */
 326      private function resolveModel(): ModelInterface
 327      {
 328          $model = $this->prepareModel();
 329          if (!$model instanceof EmbeddingGenerationModelInterface) {
 330              throw new InvalidArgumentException(sprintf('Model "%s" from provider "%s" does not support embedding generation.', $model->metadata()->getId(), $model->providerMetadata()->getId()));
 331          }
 332          $unmetRequirements = $this->describeUnmetRequirements($model);
 333          if ($unmetRequirements !== null) {
 334              throw new InvalidArgumentException(sprintf('Model "%s" from provider "%s" cannot fulfill this embedding request. %s', $model->metadata()->getId(), $model->providerMetadata()->getId(), $unmetRequirements));
 335          }
 336          return $model;
 337      }
 338      /**
 339       * Locates the specified model, without preparing it for use.
 340       *
 341       * Unlike {@see self::prepareModel()}, this leaves a model instance provided by the caller
 342       * untouched, so that it can be inspected without altering it.
 343       *
 344       * @since 1.5.0
 345       *
 346       * @return ModelInterface The located model.
 347       * @throws InvalidArgumentException If no model was specified, the model's provider is not
 348       *                                  configured, or the model could not be retrieved.
 349       */
 350      private function locateModel(): ModelInterface
 351      {
 352          if ($this->providerModel !== null) {
 353              [$providerIdOrClassName, $modelId] = $this->providerModel;
 354              $this->assertProviderConfigured($providerIdOrClassName);
 355              // Retrieving the model also binds its provider dependencies.
 356              return $this->registry->getProviderModel($providerIdOrClassName, $modelId, $this->modelConfig);
 357          }
 358          if ($this->model !== null) {
 359              $model = $this->model;
 360              $this->assertProviderConfigured($model->providerMetadata()->getId());
 361              return $model;
 362          }
 363          throw new InvalidArgumentException(self::NO_MODEL_MESSAGE);
 364      }
 365      /**
 366       * Prepares the specified model for use, without verifying that it can fulfill the request.
 367       *
 368       * @since 1.5.0
 369       *
 370       * @return ModelInterface The prepared model, with its dependencies and configuration bound.
 371       * @throws InvalidArgumentException If no model was specified, the model's provider is not
 372       *                                  configured, or the model could not be retrieved.
 373       */
 374      private function prepareModel(): ModelInterface
 375      {
 376          $model = $this->locateModel();
 377          // A model retrieved from the registry is already bound and configured, so only a model
 378          // instance provided by the caller needs its dependencies and configuration bound here.
 379          if ($model === $this->model) {
 380              $model->setConfig($this->effectiveModelConfig($model));
 381              $this->registry->bindModelDependencies($model);
 382          }
 383          // Request options are only applicable to API-based models that make HTTP requests.
 384          if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) {
 385              $model->setRequestOptions($this->requestOptions);
 386          }
 387          return $model;
 388      }
 389      /**
 390       * Combines a model's own configuration with the configuration set on this builder.
 391       *
 392       * The builder's configuration takes precedence for any overlapping settings. Combining happens
 393       * here, when the model is used, rather than when the model is set, so that the configuration of
 394       * a model that was subsequently replaced is not applied to its replacement.
 395       *
 396       * @since 1.5.0
 397       *
 398       * @param ModelInterface $model The model whose configuration to combine with the builder's.
 399       * @return ModelConfig The effective configuration for the given model.
 400       */
 401      private function effectiveModelConfig(ModelInterface $model): ModelConfig
 402      {
 403          return ModelConfig::fromArray(array_merge($model->getConfig()->toArray(), $this->modelConfig->toArray()));
 404      }
 405      /**
 406       * Asserts that the given provider is configured and therefore usable.
 407       *
 408       * @since 1.5.0
 409       *
 410       * @param string $providerIdOrClassName The provider ID or class name.
 411       * @return void
 412       * @throws InvalidArgumentException If the provider is not registered or not usable.
 413       */
 414      private function assertProviderConfigured(string $providerIdOrClassName): void
 415      {
 416          // A provider reports itself unconfigured for any reason it cannot be used, including
 417          // credentials that are missing, invalid, or rejected, so the message covers all of them.
 418          if (!$this->registry->isProviderConfigured($providerIdOrClassName)) {
 419              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));
 420          }
 421      }
 422      /**
 423       * Describes the requirements of the current request that the given model does not meet.
 424       *
 425       * @since 1.5.0
 426       *
 427       * @param ModelInterface $model The model to check.
 428       * @return string|null A description of the unmet requirements, or null if the model meets them all.
 429       */
 430      private function describeUnmetRequirements(ModelInterface $model): ?string
 431      {
 432          $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->effectiveModelConfig($model));
 433          /** @var UnmetModelRequirementsShape $unmetRequirements */
 434          $unmetRequirements = $requirements->getUnmetRequirements($model->metadata());
 435          $descriptions = [];
 436          if ($unmetRequirements['capabilities'] !== []) {
 437              $descriptions[] = sprintf('Unsupported capabilities: %s.', implode(', ', array_map(static fn(CapabilityEnum $capability): string => $capability->value, $unmetRequirements['capabilities'])));
 438          }
 439          if ($unmetRequirements['options'] !== []) {
 440              $descriptions[] = sprintf('Unsupported options: %s.', implode(', ', array_map(fn(RequiredOption $option): string => sprintf('%s (%s)', $option->getName()->value, $this->describeOptionValue($option->getValue())), $unmetRequirements['options'])));
 441          }
 442          if ($descriptions === []) {
 443              return null;
 444          }
 445          return implode(' ', $descriptions);
 446      }
 447      /**
 448       * Describes an option value for use in an error message.
 449       *
 450       * @since 1.5.0
 451       *
 452       * @param mixed $value The option value to describe.
 453       * @return string The human readable description.
 454       */
 455      private function describeOptionValue($value): string
 456      {
 457          if ($value === null) {
 458              return 'null';
 459          }
 460          if (is_bool($value)) {
 461              return $value ? 'true' : 'false';
 462          }
 463          if ($value instanceof AbstractEnum) {
 464              return $value->value;
 465          }
 466          if (is_array($value)) {
 467              return '[' . implode(', ', array_map(fn($item): string => $this->describeOptionValue($item), $value)) . ']';
 468          }
 469          if (is_scalar($value)) {
 470              return (string) $value;
 471          }
 472          return is_object($value) ? get_class($value) : gettype($value);
 473      }
 474      /**
 475       * Parses a single input into a message part.
 476       *
 477       * @since 1.4.0
 478       *
 479       * @param mixed $input The input to parse. Accepts a string, MessagePart, File, or
 480       *                     MessagePartArrayShape.
 481       * @return MessagePart The parsed message part.
 482       * @throws InvalidArgumentException If the input type is not supported or is an invalid part type.
 483       */
 484      private function parseInput($input): MessagePart
 485      {
 486          if ($input instanceof MessagePart) {
 487              return $this->validatePart($input);
 488          }
 489          if (is_string($input)) {
 490              if (trim($input) === '') {
 491                  throw new InvalidArgumentException('Cannot create an embedding input from an empty string.');
 492              }
 493              return new MessagePart($input);
 494          }
 495          if ($input instanceof File) {
 496              return new MessagePart($input);
 497          }
 498          if (is_array($input) && MessagePart::isArrayShape($input)) {
 499              return $this->validatePart(MessagePart::fromArray($input));
 500          }
 501          throw new InvalidArgumentException('Embedding input must be a string, MessagePart, File, or MessagePartArrayShape.');
 502      }
 503      /**
 504       * Validates that a message part is a valid embedding input.
 505       *
 506       * @since 1.4.0
 507       *
 508       * @param MessagePart $part The part to validate.
 509       * @return MessagePart The validated part.
 510       * @throws InvalidArgumentException If the part is not a text or file part.
 511       */
 512      private function validatePart(MessagePart $part): MessagePart
 513      {
 514          $type = $part->getType();
 515          if (!$type->isText() && !$type->isFile()) {
 516              throw new InvalidArgumentException('Embedding inputs must be text or file parts.');
 517          }
 518          return $part;
 519      }
 520      /**
 521       * Dispatches an event if an event dispatcher is registered.
 522       *
 523       * @since 1.4.0
 524       *
 525       * @param object $event The event to dispatch.
 526       * @return void
 527       */
 528      private function dispatchEvent(object $event): void
 529      {
 530          if ($this->eventDispatcher !== null) {
 531              $this->eventDispatcher->dispatch($event);
 532          }
 533      }
 534  }


Generated : Wed Oct 14 08:20:33 2026 Cross-referenced by PHPXref