| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Wed Oct 14 08:20:33 2026 | Cross-referenced by PHPXref |