Skip to content
Open
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
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ direction LR
+usingRequestOptions(RequestOptions $requestOptions) self
+usingSystemInstruction(string $systemInstruction) self
+usingMaxTokens(int $maxTokens) self
+usingTokenCounter(TokenCounterInterface $tokenCounter) self
+usingTemperature(float $temperature) self
+usingTopP(float $topP) self
+usingTopK(int $topK) self
Expand Down Expand Up @@ -556,6 +557,7 @@ direction LR
+usingProvider(string $providerIdOrClassName) self
+usingSystemInstruction(string $systemInstruction) self
+usingMaxTokens(int $maxTokens) self
+usingTokenCounter(TokenCounterInterface $tokenCounter) self
+usingTemperature(float $temperature) self
+usingTopP(float $topP) self
+usingTopK(int $topK) self
Expand Down Expand Up @@ -1103,6 +1105,7 @@ direction LR
+getName() string
+getSupportedCapabilities() CapabilityEnum[]
+getSupportedOptions() SupportedOption[]
+getContextWindow() ?int
+getJsonSchema() array< string, mixed >$
}
class ModelRequirements {
Expand Down
1 change: 1 addition & 0 deletions docs/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This glossary defines common terms relevant for the PHP AI Client and related pr
* **Agent**: An autonomous system that can perceive its environment, make decisions, and take actions to achieve specific goals, often leveraging AI models.
* **Candidate Count**: The number of different response options an LLM generates internally.
* **Capability**: A specific skill, function, or type of task that an AI model can perform, e.g. text generation or image generation.
* **Context Window**: The total number of tokens (input plus output) a _Model_ can process in a single request. Exposed as model metadata (`ModelMetadata::getContextWindow()`) and used for an advisory pre-flight check. Distinct from the _Max Tokens_ option, which caps only the generated output.
* **Extender API**: The API used by developers that want to enable the use of additional _Providers_ or _Models_.
* **Generative AI**: Overarching term describing AI models that generate content as requested in a prompt.
* **Implementer API**: The API used by people that want to _implement_ AI features in their own software/products.
Expand Down
88 changes: 88 additions & 0 deletions src/Builders/PromptBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use WordPress\AiClient\Builders\Traits\ModelResolutionTrait;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Common\Exception\TokenLimitReachedException;
use WordPress\AiClient\Events\AfterGenerateResultEvent;
use WordPress\AiClient\Events\BeforeGenerateResultEvent;
use WordPress\AiClient\Files\DTO\File;
Expand All @@ -27,6 +28,8 @@
use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface;
use WordPress\AiClient\Providers\Models\Tokenization\Contracts\TokenCounterInterface;
use WordPress\AiClient\Providers\Models\Tokenization\HeuristicTokenCounter;
use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface;
use WordPress\AiClient\Providers\ProviderRegistry;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
Expand Down Expand Up @@ -62,6 +65,11 @@ class PromptBuilder
*/
private ?EventDispatcherInterface $eventDispatcher = null;

/**
* @var TokenCounterInterface|null Optional token counter used for the pre-flight context-window check.
*/
private ?TokenCounterInterface $tokenCounter = null;

// phpcs:disable Generic.Files.LineLength.TooLong
/**
* Constructor.
Expand Down Expand Up @@ -247,6 +255,27 @@ public function usingMaxTokens(int $maxTokens): self
return $this;
}

/**
* Sets the token counter used for the pre-flight context-window check.
*
* By default the builder uses a rough, provider-agnostic estimate
* ({@see HeuristicTokenCounter}). Provide a custom counter here to supply accurate,
* model-specific counts (for example one backed by a real tokenizer or a server-side
* tokenize endpoint). This only affects the proactive check performed before a request is
* sent, which runs when the resolved model exposes a context window via
* {@see \WordPress\AiClient\Providers\Models\DTO\ModelMetadata::getContextWindow()}.
*
* @since n.e.x.t
*
* @param TokenCounterInterface $tokenCounter The token counter to use.
* @return self The builder instance, for method chaining.
*/
public function usingTokenCounter(TokenCounterInterface $tokenCounter): self
{
$this->tokenCounter = $tokenCounter;
return $this;
}

/**
* Sets the temperature for generation.
*
Expand Down Expand Up @@ -745,6 +774,9 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi

$model = $this->getConfiguredModel($capability);

// Fail fast if the assembled prompt is estimated to exceed the model's context window.
$this->assertPromptFitsContextWindow($model);

// Dispatch BeforeGenerateResultEvent
$this->dispatchEvent(
new BeforeGenerateResultEvent($this->messages, $model, $capability)
Expand All @@ -761,6 +793,62 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi
return $result;
}

/**
* Asserts that the assembled prompt is estimated to fit within the model's context window.
*
* The check runs only when the resolved model exposes a context window via
* {@see \WordPress\AiClient\Providers\Models\DTO\ModelMetadata::getContextWindow()}. When it
* does, the builder estimates the input token count (message text plus any system instruction)
* using the configured token counter, adds the configured output cap
* ({@see ModelConfig::getMaxTokens()}) when one is set, and throws when the total exceeds the
* context window. The estimate is advisory: a passing check does not guarantee the provider
* will accept the request, and models that do not publish a context window are never checked.
*
* @since n.e.x.t
*
* @param ModelInterface $model The resolved model to check the prompt against.
* @throws TokenLimitReachedException If the estimated prompt size exceeds the context window.
*/
private function assertPromptFitsContextWindow(ModelInterface $model): void
{
$contextWindow = $model->metadata()->getContextWindow();
if ($contextWindow === null) {
return;
}

$messages = $this->messages;

$systemInstruction = $this->modelConfig->getSystemInstruction();
if ($systemInstruction !== null && $systemInstruction !== '') {
array_unshift($messages, new UserMessage([new MessagePart($systemInstruction)]));
}

$tokenCounter = $this->tokenCounter ?? new HeuristicTokenCounter();
$estimatedInputTokens = $tokenCounter->countTokens($messages, $model->metadata());

$reservedOutputTokens = $this->modelConfig->getMaxTokens() ?? 0;
$estimatedTotalTokens = $estimatedInputTokens + $reservedOutputTokens;

if ($estimatedTotalTokens <= $contextWindow) {
return;
}

throw new TokenLimitReachedException(
sprintf(
'The prompt is estimated at %d tokens%s, which exceeds the context window of %d '
. 'tokens for model "%s". Reduce the prompt size, or provide an accurate token '
. 'counter via usingTokenCounter() if this estimate is inaccurate.',
$estimatedInputTokens,
$reservedOutputTokens > 0
? sprintf(' plus %d reserved for output', $reservedOutputTokens)
: '',
$contextWindow,
$model->metadata()->getId()
),
$contextWindow
);
}

/**
* Executes the model generation based on capability.
*
Expand Down
15 changes: 12 additions & 3 deletions src/Common/Exception/TokenLimitReachedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@
/**
* Exception thrown when a token limit is reached during prompt fulfillment.
*
* Providers should throw this exception when the token usage for a request
* exceeds the allowed limit, whether that is the model's context window
* or a configured maximum.
* This exception is thrown when the token usage for a request exceeds the allowed limit, whether
* that is the model's context window or a configured maximum. It covers two cases:
*
* - Proactively (input side): {@see \WordPress\AiClient\Builders\PromptBuilder} throws it before a
* request is sent when the estimated prompt size exceeds the resolved model's context window
* (see {@see \WordPress\AiClient\Providers\Models\DTO\ModelMetadata::getContextWindow()}). This is
* distinct from the per-request output cap configured via
* {@see \WordPress\AiClient\Providers\Models\DTO\ModelConfig::setMaxTokens()}.
* - Reactively (output side): providers may throw it when a response indicates generation was
* truncated because the token limit was reached.
*
* The associated limit, when known, is available via {@see self::getMaxTokens()}.
*
* @since 1.0.0
*/
Expand Down
63 changes: 57 additions & 6 deletions src/Providers/Models/DTO/ModelMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
* id: string,
* name: string,
* supportedCapabilities: list<string>,
* supportedOptions: list<SupportedOptionArrayShape>
* supportedOptions: list<SupportedOptionArrayShape>,
* contextWindow?: int
* }
*
* @extends AbstractDataTransferObject<ModelMetadataArrayShape>
Expand All @@ -33,6 +34,7 @@ class ModelMetadata extends AbstractDataTransferObject
public const KEY_NAME = 'name';
public const KEY_SUPPORTED_CAPABILITIES = 'supportedCapabilities';
public const KEY_SUPPORTED_OPTIONS = 'supportedOptions';
public const KEY_CONTEXT_WINDOW = 'contextWindow';

/**
* @var string The model's unique identifier.
Expand All @@ -54,6 +56,11 @@ class ModelMetadata extends AbstractDataTransferObject
*/
protected array $supportedOptions;

/**
* @var int|null The model's context window, in tokens, or null if unknown.
*/
protected ?int $contextWindow;


/**
* Constructor.
Expand All @@ -64,11 +71,20 @@ class ModelMetadata extends AbstractDataTransferObject
* @param string $name The model's display name.
* @param list<CapabilityEnum> $supportedCapabilities The model's supported capabilities.
* @param list<SupportedOption> $supportedOptions The model's supported configuration options.
* @param int|null $contextWindow The model's context window, in tokens, or null if unknown. This is the
* total number of tokens (input plus output) the model can process for a
* single request, distinct from the per-request output cap configured via
* {@see \WordPress\AiClient\Providers\Models\DTO\ModelConfig::setMaxTokens()}.
*
* @throws InvalidArgumentException If arrays are not lists.
* @throws InvalidArgumentException If arrays are not lists, or if the context window is not positive.
*/
public function __construct(string $id, string $name, array $supportedCapabilities, array $supportedOptions)
{
public function __construct(
string $id,
string $name,
array $supportedCapabilities,
array $supportedOptions,
?int $contextWindow = null
) {
if (!array_is_list($supportedCapabilities)) {
throw new InvalidArgumentException('Supported capabilities must be a list array.');
}
Expand All @@ -77,10 +93,15 @@ public function __construct(string $id, string $name, array $supportedCapabiliti
throw new InvalidArgumentException('Supported options must be a list array.');
}

if ($contextWindow !== null && $contextWindow < 1) {
throw new InvalidArgumentException('Context window must be a positive integer.');
}

$this->id = $id;
$this->name = $name;
$this->supportedCapabilities = $supportedCapabilities;
$this->supportedOptions = $supportedOptions;
$this->contextWindow = $contextWindow;
}

/**
Expand Down Expand Up @@ -131,6 +152,24 @@ public function getSupportedOptions(): array
return $this->supportedOptions;
}

/**
* Gets the model's context window, in tokens.
*
* The context window is the total number of tokens (input plus output) the model can process
* for a single request. It is distinct from the per-request output cap configured via
* {@see \WordPress\AiClient\Providers\Models\DTO\ModelConfig::setMaxTokens()}, which limits only
* the number of tokens the model may generate. Providers that do not publish a fixed context
* window (for example self-hosted, user-configurable servers) return null.
*
* @since n.e.x.t
*
* @return int|null The context window in tokens, or null if unknown.
*/
public function getContextWindow(): ?int
{
return $this->contextWindow;
}

/**
* {@inheritDoc}
*
Expand Down Expand Up @@ -162,6 +201,11 @@ public static function getJsonSchema(): array
'items' => SupportedOption::getJsonSchema(),
'description' => 'The model\'s supported configuration options.',
],
self::KEY_CONTEXT_WINDOW => [
'type' => 'integer',
'minimum' => 1,
'description' => 'The model\'s context window, in tokens (total input plus output).',
],
],
'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS],
];
Expand All @@ -176,7 +220,7 @@ public static function getJsonSchema(): array
*/
public function toArray(): array
{
return [
$data = [
self::KEY_ID => $this->id,
self::KEY_NAME => $this->name,
self::KEY_SUPPORTED_CAPABILITIES => array_map(
Expand All @@ -188,6 +232,12 @@ public function toArray(): array
$this->supportedOptions
),
];

if ($this->contextWindow !== null) {
$data[self::KEY_CONTEXT_WINDOW] = $this->contextWindow;
}

return $data;
}

/**
Expand All @@ -214,7 +264,8 @@ public static function fromArray(array $array): self
array_map(
static fn(array $optionData): SupportedOption => SupportedOption::fromArray($optionData),
$array[self::KEY_SUPPORTED_OPTIONS]
)
),
$array[self::KEY_CONTEXT_WINDOW] ?? null
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace WordPress\AiClient\Providers\Models\Tokenization\Contracts;

use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;

/**
* Contract for estimating the number of tokens a set of messages will consume.
*
* Token counting is model specific: every provider tokenizes text differently, and an accurate
* count generally requires that provider's own tokenizer. The SDK ships a rough, dependency-free
* default (see {@see \WordPress\AiClient\Providers\Models\Tokenization\HeuristicTokenCounter}); a
* consumer that has access to an accurate tokenizer (for example a self-hosted server's tokenize
* endpoint, or a bundled tokenizer library) may implement this interface and supply it via
* {@see \WordPress\AiClient\Builders\PromptBuilder::usingTokenCounter()}.
*
* @since n.e.x.t
*/
interface TokenCounterInterface
{
/**
* Estimates the number of tokens the given messages will consume for the given model.
*
* @since n.e.x.t
*
* @param list<Message> $messages The messages to estimate a token count for.
* @param ModelMetadata $modelMetadata The metadata of the model the messages are intended for.
* @return int The estimated number of tokens. Never negative.
*/
public function countTokens(array $messages, ModelMetadata $modelMetadata): int;
}
Loading
Loading