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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,31 @@ $imageFile = AiClient::prompt('Generate an illustration of the PHP elephant in t
->generateImage();
```

### Embedding generation using any compatible model
### Embedding generation using a specific model

Unlike the other capabilities, embedding generation always requires you to name the model. Embedding vectors are only comparable to other vectors produced by the same model, so a stored set of embeddings is permanently tied to the model that created it. If the library picked a model for you, that choice could change between requests — for example when the registered providers change — silently making new vectors incomparable to the ones you already stored. Omitting the model therefore raises an error rather than falling back to a default.

```php
use WordPress\AiClient\AiClient;

$embedding = AiClient::input('PHP powers a large part of the web.')
->usingProviderModel('google', 'gemini-embedding-001')
->generateEmbedding();

$values = $embedding->getValues();
```

You can also pass a model instance, which is useful when you already have one or want to reference the provider class directly:

```php
use WordPress\AiClient\AiClient;
use WordPress\GoogleAiProvider\Provider\GoogleProvider;

$embedding = AiClient::input('PHP powers a large part of the web.')
->usingModel(GoogleProvider::model('gemini-embedding-001'))
->generateEmbedding();
```

### Batch embedding generation

```php
Expand All @@ -108,7 +122,7 @@ $embeddings = AiClient::input([
'PHP powers a large part of the web.',
'WordPress makes publishing accessible.',
])
->usingProvider('openai')
->usingProviderModel('openai', 'text-embedding-3-small')
->generateEmbeddings();
```

Expand All @@ -118,11 +132,34 @@ $embeddings = AiClient::input([
use WordPress\AiClient\AiClient;

$embedding = AiClient::input('PHP powers a large part of the web.')
->usingProviderModel('openai', 'text-embedding-3-small')
->usingDimensions(512)
->generateEmbedding();
```

Embedding inputs are independent [`MessagePart`](https://github.com/WordPress/php-ai-client/blob/trunk/src/Messages/DTO/MessagePart.php) values, not a conversation. `input()` accepts one input or a list of inputs. Variadic `withInput()` accepts one or more arguments, and lists can be passed using PHP's spread syntax (`withInput(...$inputs)`). Each input may be a string, `MessagePart`, `File`, or message-part array shape. Model selection accounts for their input modalities and embedding configuration.
Embedding inputs are independent [`MessagePart`](https://github.com/WordPress/php-ai-client/blob/trunk/src/Messages/DTO/MessagePart.php) values, not a conversation. `input()` accepts one input or a list of inputs. Variadic `withInput()` accepts one or more arguments, and lists can be passed using PHP's spread syntax (`withInput(...$inputs)`). Each input may be a string, `MessagePart`, `File`, or message-part array shape.

The model you name is verified before any request is sent: it must support embedding generation, accept the input modalities of your inputs, and support every configuration option you set. If it does not, an `InvalidArgumentException` explains which capability or option is unsupported. To check without triggering an exception, call `isSupported()`, which returns `false` for any model that cannot fulfill the request — including one whose provider is not registered or configured, or a model ID the provider does not offer. Only omitting the model entirely still throws, since that is a programming error rather than an unsupported model.

### Discovering available embedding models

Since no model is chosen for you, you may need to find out which embedding models the configured providers offer:

```php
use WordPress\AiClient\AiClient;
use WordPress\AiClient\Providers\Models\DTO\ModelRequirements;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;

$requirements = new ModelRequirements([CapabilityEnum::embeddingGeneration()], []);

foreach (AiClient::defaultRegistry()->findModelsMetadataForSupport($requirements) as $providerModels) {
$providerId = $providerModels->getProvider()->getId();

foreach ($providerModels->getModels() as $modelMetadata) {
echo $providerId . ' / ' . $modelMetadata->getId() . "\n";
}
}
```

See the [`PromptBuilder` class](https://github.com/WordPress/php-ai-client/blob/trunk/src/Builders/PromptBuilder.php) and the [`EmbeddingBuilder` class](https://github.com/WordPress/php-ai-client/blob/trunk/src/Builders/EmbeddingBuilder.php) and their public methods for all the ways you can configure generation.

Expand All @@ -136,6 +173,8 @@ The AI Client supports PSR-14 event dispatching for prompt lifecycle events. Thi

- `BeforeGenerateResultEvent` - Dispatched before a prompt is sent to the model
- `AfterGenerateResultEvent` - Dispatched after a result is received from the model
- `BeforeGenerateEmbeddingEvent` - Dispatched before embedding inputs are sent to the model
- `AfterGenerateEmbeddingEvent` - Dispatched after an embedding result is received from the model

**Important:** Event listeners should not return a value, as they will be ignored. In order to modify data that is passed with the event object, you need to rely on setters on the event object. Any event data for which there are no setters on the event object is meant to be immutable or, in other words, read-only for the event listener.

Expand Down
87 changes: 60 additions & 27 deletions cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
* Usage:
* GOOGLE_API_KEY=123456 php cli.php 'Your prompt here' --providerId=google --modelId=gemini-2.5-flash
* OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai
* OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai --outputFormat=embedding-json
* GOOGLE_API_KEY=123456 OPENAI_API_KEY=123456 php cli.php 'Your prompt here'
*
* Embedding output formats require both --providerId and --modelId, because embeddings are only
* comparable to other embeddings from the same model:
* OPENAI_API_KEY=123456 php cli.php 'Your text here' --providerId=openai \
* --modelId=text-embedding-3-small --outputFormat=embedding-json
*
* For large prompts (e.g., with images), use stdin or file input:
* cat prompt.json | php cli.php - --providerId=openai --modelId=gpt-4o
* php cli.php @prompt.json --providerId=openai --modelId=gpt-4o
Expand All @@ -24,6 +28,19 @@

require_once __DIR__ . '/vendor/autoload.php';

// Register the provider packages that are installed as development dependencies.
foreach (
[
'WordPress\AnthropicAiProvider\Provider\AnthropicProvider',
'WordPress\GoogleAiProvider\Provider\GoogleProvider',
'WordPress\OpenAiAiProvider\Provider\OpenAiProvider',
] as $providerClassName
) {
if (class_exists($providerClassName)) {
AiClient::defaultRegistry()->registerProvider($providerClassName);
}
}

/**
* Prints the output to stdout.
*
Expand Down Expand Up @@ -162,44 +179,60 @@ function logError(string $message, int $exit_code = 1): void

$isEmbedding = $outputFormat === 'embedding-json' || $outputFormat === 'embedding-result-json';

if ($isEmbedding && (!$providerId || !$modelId)) {
logError(
'Embedding output formats require both --providerId and --modelId. Embeddings are only comparable '
. 'to other embeddings from the same model, so no model is selected automatically.'
);
}

if ($isEmbedding && $modelPreference) {
logWarning('The --modelPreference argument is ignored for embedding output formats.');
}

try {
$modelConfig = ModelConfig::fromArray($model_config_data);

// Embeddings use a dedicated builder; other output formats use the prompt builder.
$promptBuilder = $isEmbedding ? AiClient::input($promptInput) : AiClient::prompt($promptInput);
$promptBuilder = $promptBuilder->usingModelConfig($modelConfig);
if ($providerId && $modelId) {
$providerClassName = AiClient::defaultRegistry()->getProviderClassName($providerId);
$promptBuilder = $promptBuilder->usingModel($providerClassName::model($modelId));
} elseif ($providerId) {
$promptBuilder = $promptBuilder->usingProvider($providerId);
}
if ($modelPreference) {
$modelPreference = array_map(
static function ($item) {
$item = trim($item);
if (str_contains($item, '::')) {
return explode('::', $item, 2);
}
return $item;
},
explode(',', $modelPreference)
);
$promptBuilder = $promptBuilder->usingModelPreference(...$modelPreference);
if ($isEmbedding) {
// Embeddings use a dedicated builder, which requires an explicit model.
$builder = AiClient::input($promptInput)
->usingModelConfig($modelConfig)
->usingProviderModel($providerId, $modelId);
} else {
$builder = AiClient::prompt($promptInput)->usingModelConfig($modelConfig);
if ($providerId && $modelId) {
$providerClassName = AiClient::defaultRegistry()->getProviderClassName($providerId);
$builder = $builder->usingModel($providerClassName::model($modelId));
} elseif ($providerId) {
$builder = $builder->usingProvider($providerId);
}
if ($modelPreference) {
$modelPreference = array_map(
static function ($item) {
$item = trim($item);
if (str_contains($item, '::')) {
return explode('::', $item, 2);
}
return $item;
},
explode(',', $modelPreference)
);
$builder = $builder->usingModelPreference(...$modelPreference);
}
}
} catch (InvalidArgumentException $e) {
logError('Invalid arguments while trying to set up prompt builder: ' . $e->getMessage());
logError('Invalid arguments while trying to set up the builder: ' . $e->getMessage());
} catch (ResponseException $e) {
logError('Request failed while trying to set up prompt builder: ' . $e->getMessage());
logError('Request failed while trying to set up the builder: ' . $e->getMessage());
}

try {
if ($isEmbedding) {
$result = $promptBuilder->generateEmbeddingResult();
$result = $builder->generateEmbeddingResult();
} elseif ($outputFormat === 'image-json' || $outputFormat === 'image-base64') {
$result = $promptBuilder->generateImageResult();
$result = $builder->generateImageResult();
} else {
$result = $promptBuilder->generateTextResult();
$result = $builder->generateTextResult();
}
} catch (InvalidArgumentException $e) {
logError('Invalid arguments while trying to generate result: ' . $e->getMessage());
Expand Down
49 changes: 32 additions & 17 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ For the implementer facing API surface, two alternative APIs are available:
- A fluent API is used as the primary means of using the AI client SDK, for easy-to-read code by chaining declarative methods.
- A traditional method based API inspired by the Vercel AI SDK, which is more aligned with traditional WordPress patterns such as passing an array of arguments.

The fluent API is exposed through builders. Content generation (text, image, speech, video, ...) uses the `PromptBuilder`, while embedding generation uses a dedicated `EmbeddingBuilder`. Embeddings are kept on a separate builder because they transform inputs into vectors rather than generating a conversational response, so most of `PromptBuilder`'s prompt-oriented parameters (system instruction, temperature, output modalities, chat history, ...) do not apply. Both builders share the logic for selecting a provider/model — including model preferences and request options — through a common `ModelResolver` (owned by each builder) and a shared `ModelResolutionTrait` that exposes the `usingModel()`, `usingModelPreference()`, `usingModelConfig()`, `usingProvider()`, and `usingRequestOptions()` methods.
The fluent API is exposed through builders. Content generation (text, image, speech, video, ...) uses the `PromptBuilder`, while embedding generation uses a dedicated `EmbeddingBuilder`. Embeddings are kept on a separate builder because they transform inputs into vectors rather than generating a conversational response, so most of `PromptBuilder`'s prompt-oriented parameters (system instruction, temperature, output modalities, chat history, ...) do not apply.

The two builders differ fundamentally in how they arrive at a model:

- `PromptBuilder` **resolves** a model. If none is named, it discovers a suitable one across the configured providers, honoring model preferences and any provider constraint. This state and logic live on a `ModelResolver` owned by the builder, exposed through the shared `ModelResolutionTrait` (`usingModel()`, `usingModelPreference()`, `usingProvider()`, `usingRequestOptions()`).
- `EmbeddingBuilder` **verifies** a model, and never selects one. 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 silently invalidate existing vectors when the registered providers change. The model must be named via `usingModel()` or `usingProviderModel()`, and omitting it is an error. The builder then checks, before any request is made, that the model supports embedding generation, accepts the inputs' modalities, supports every configured option, and belongs to a configured provider.

Both builders accumulate model configuration through the shared `ModelConfigurationTrait`, which provides `usingModelConfig()`.

### Code examples

Expand Down Expand Up @@ -293,34 +300,44 @@ $jsonString = AiClient::generateTextResult(
)->toText();
```

#### Generate an embedding using any suitable model from any provider
#### Generate an embedding using a specific model

_Note: Embeddings use the dedicated `EmbeddingBuilder` (via `AiClient::input()`) rather than the `PromptBuilder`. Each input is embedded independently, producing one embedding vector per input._

_Note: Unlike every other capability, embedding generation requires the model to be named. No model is selected automatically, because embeddings from different models are not comparable. The model may be given as a `[provider ID, model ID]` pair or as a model instance._

##### Fluent API

```php
// Single input.
$embedding = AiClient::input('PHP powers a large part of the web.')
->usingProviderModel('google', 'gemini-embedding-001')
->generateEmbedding();

// Multiple inputs, embedded as a batch.
$embeddings = AiClient::input([
'PHP powers a large part of the web.',
'WordPress makes publishing accessible.',
])
->usingModel(GoogleProvider::model('gemini-embedding-001'))
->generateEmbeddings();
```

##### Traditional API

```php
$embedding = AiClient::generateEmbedding('PHP powers a large part of the web.');

$embeddings = AiClient::generateEmbeddings([
$embedding = AiClient::generateEmbedding(
'PHP powers a large part of the web.',
'WordPress makes publishing accessible.',
]);
['google', 'gemini-embedding-001']
);

$embeddings = AiClient::generateEmbeddings(
[
'PHP powers a large part of the web.',
'WordPress makes publishing accessible.',
],
['google', 'gemini-embedding-001']
);
```

## Class diagrams
Expand Down Expand Up @@ -409,9 +426,8 @@ direction LR
class EmbeddingBuilder {
+withInput(...$input) self
+usingModel(ModelInterface $model) self
+usingModelPreference(...$preferredModels) self
+usingProviderModel(string $providerIdOrClassName, string $modelId) self
+usingModelConfig(ModelConfig $config) self
+usingProvider(string $providerIdOrClassName) self
+usingRequestOptions(RequestOptions $requestOptions) self
+usingDimensions(int $dimensions) self
+isSupported() bool
Expand Down Expand Up @@ -460,9 +476,9 @@ direction LR
+convertTextToSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$
+generateSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$
+generateVideoResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$
+generateEmbeddingResult(string|MessagePart|File $input, ModelInterface $model) EmbeddingResult$
+generateEmbedding(string|MessagePart|File $input, ModelInterface $model) Embedding$
+generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface $model) Embedding[]$
+generateEmbeddingResult(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) EmbeddingResult$
+generateEmbedding(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) Embedding$
+generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface|array $model, ModelConfig $modelConfig) Embedding[]$
+generateTextOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$
+generateImageOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$
+convertTextToSpeechOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$
Expand Down Expand Up @@ -532,9 +548,9 @@ direction LR
+convertTextToSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$
+generateSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$
+generateVideoResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$
+generateEmbeddingResult(string|MessagePart|File $input, ModelInterface $model) EmbeddingResult$
+generateEmbedding(string|MessagePart|File $input, ModelInterface $model) Embedding$
+generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface $model) Embedding[]$
+generateEmbeddingResult(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) EmbeddingResult$
+generateEmbedding(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) Embedding$
+generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface|array $model, ModelConfig $modelConfig) Embedding[]$
+generateTextOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$
+generateImageOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$
+convertTextToSpeechOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$
Expand Down Expand Up @@ -599,9 +615,8 @@ direction LR
class EmbeddingBuilder {
+withInput(...$input) self
+usingModel(ModelInterface $model) self
+usingModelPreference(...$preferredModels) self
+usingProviderModel(string $providerIdOrClassName, string $modelId) self
+usingModelConfig(ModelConfig $config) self
+usingProvider(string $providerIdOrClassName) self
+usingRequestOptions(RequestOptions $requestOptions) self
+usingDimensions(int $dimensions) self
+isSupported() bool
Expand Down
Loading
Loading