From 4e2682688174e0eced71feff4fad176aee180b10 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Fri, 24 Jul 2026 14:26:08 +0530 Subject: [PATCH 1/2] Add token limit checks and token counter support in PromptBuilder - Introduced TokenCounterInterface and HeuristicTokenCounter for token estimation. - Implemented context window checks in PromptBuilder to prevent exceeding model limits. - Enhanced ModelMetadata to include context window property. - Updated tests to validate new functionality and ensure proper exception handling. --- src/Builders/PromptBuilder.php | 88 ++++++++++++ .../Exception/TokenLimitReachedException.php | 15 +- src/Providers/Models/DTO/ModelMetadata.php | 63 ++++++++- .../Contracts/TokenCounterInterface.php | 34 +++++ .../Tokenization/HeuristicTokenCounter.php | 71 ++++++++++ tests/unit/Builders/PromptBuilderTest.php | 132 ++++++++++++++++++ .../Models/DTO/ModelMetadataTest.php | 127 +++++++++++++++++ .../HeuristicTokenCounterTest.php | 91 ++++++++++++ 8 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 src/Providers/Models/Tokenization/Contracts/TokenCounterInterface.php create mode 100644 src/Providers/Models/Tokenization/HeuristicTokenCounter.php create mode 100644 tests/unit/Providers/Models/Tokenization/HeuristicTokenCounterTest.php diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 538392db..802337b5 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -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; @@ -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; @@ -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. @@ -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. * @@ -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) @@ -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. * diff --git a/src/Common/Exception/TokenLimitReachedException.php b/src/Common/Exception/TokenLimitReachedException.php index def739f9..afd7239e 100644 --- a/src/Common/Exception/TokenLimitReachedException.php +++ b/src/Common/Exception/TokenLimitReachedException.php @@ -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 */ diff --git a/src/Providers/Models/DTO/ModelMetadata.php b/src/Providers/Models/DTO/ModelMetadata.php index 529f090f..87a3d286 100644 --- a/src/Providers/Models/DTO/ModelMetadata.php +++ b/src/Providers/Models/DTO/ModelMetadata.php @@ -22,7 +22,8 @@ * id: string, * name: string, * supportedCapabilities: list, - * supportedOptions: list + * supportedOptions: list, + * contextWindow?: int * } * * @extends AbstractDataTransferObject @@ -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. @@ -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. @@ -64,11 +71,20 @@ class ModelMetadata extends AbstractDataTransferObject * @param string $name The model's display name. * @param list $supportedCapabilities The model's supported capabilities. * @param list $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.'); } @@ -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; } /** @@ -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} * @@ -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], ]; @@ -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( @@ -188,6 +232,12 @@ public function toArray(): array $this->supportedOptions ), ]; + + if ($this->contextWindow !== null) { + $data[self::KEY_CONTEXT_WINDOW] = $this->contextWindow; + } + + return $data; } /** @@ -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 ); } diff --git a/src/Providers/Models/Tokenization/Contracts/TokenCounterInterface.php b/src/Providers/Models/Tokenization/Contracts/TokenCounterInterface.php new file mode 100644 index 00000000..ff467d78 --- /dev/null +++ b/src/Providers/Models/Tokenization/Contracts/TokenCounterInterface.php @@ -0,0 +1,34 @@ + $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; +} diff --git a/src/Providers/Models/Tokenization/HeuristicTokenCounter.php b/src/Providers/Models/Tokenization/HeuristicTokenCounter.php new file mode 100644 index 00000000..b51a1325 --- /dev/null +++ b/src/Providers/Models/Tokenization/HeuristicTokenCounter.php @@ -0,0 +1,71 @@ + $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 + { + $characters = 0; + + foreach ($messages as $message) { + foreach ($message->getParts() as $part) { + if (!$part->getType()->isText()) { + continue; + } + + $text = $part->getText(); + if ($text === null) { + continue; + } + + $characters += strlen($text); + } + } + + if ($characters === 0) { + return 0; + } + + return (int) ceil($characters / self::CHARACTERS_PER_TOKEN); + } +} diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 23a58959..61b17444 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\Builders\PromptBuilder; +use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; @@ -30,6 +31,7 @@ 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\VideoGeneration\Contracts\VideoGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; @@ -4159,4 +4161,134 @@ public function testUsingStopSequencesSetsProperty(): void $this->assertEquals(['STOP', 'END'], $config->getStopSequences()); } + + /** + * Tests that usingTokenCounter is fluent. + * + * @return void + */ + public function testUsingTokenCounterIsFluent(): void + { + $builder = new PromptBuilder($this->registry); + + $result = $builder->usingTokenCounter($this->createFixedTokenCounter(1)); + + $this->assertSame($builder, $result); + } + + /** + * Tests that generation throws when the estimated prompt exceeds the context window. + * + * @return void + */ + public function testGenerateThrowsWhenPromptExceedsContextWindow(): void + { + $metadata = new ModelMetadata('tiny', 'Tiny', [CapabilityEnum::textGeneration()], [], 1); + $model = $this->createMockTextGenerationModel($this->createTestResult(), $metadata); + + $builder = new PromptBuilder($this->registry, str_repeat('word ', 200)); + $builder->usingModel($model); + + try { + $builder->generateTextResult(); + $this->fail('Expected TokenLimitReachedException was not thrown.'); + } catch (TokenLimitReachedException $exception) { + $this->assertSame(1, $exception->getMaxTokens()); + } + } + + /** + * Tests that generation proceeds when the estimated prompt fits the context window. + * + * @return void + */ + public function testGenerateDoesNotThrowWhenPromptFitsContextWindow(): void + { + $result = $this->createTestResult('Fits'); + $metadata = new ModelMetadata('roomy', 'Roomy', [CapabilityEnum::textGeneration()], [], 100000); + $model = $this->createMockTextGenerationModel($result, $metadata); + + $builder = new PromptBuilder($this->registry, 'Short prompt'); + $builder->usingModel($model); + + $this->assertSame($result, $builder->generateTextResult()); + } + + /** + * Tests that no check is performed when the model does not expose a context window. + * + * @return void + */ + public function testGenerateDoesNotCheckWhenContextWindowUnknown(): void + { + $result = $this->createTestResult('Unknown window'); + $metadata = new ModelMetadata('unknown', 'Unknown', [CapabilityEnum::textGeneration()], []); + $model = $this->createMockTextGenerationModel($result, $metadata); + + $builder = new PromptBuilder($this->registry, str_repeat('word ', 5000)); + $builder->usingModel($model); + + $this->assertSame($result, $builder->generateTextResult()); + } + + /** + * Tests that reserved output tokens are counted against the context window. + * + * @return void + */ + public function testGenerateCountsReservedOutputTokens(): void + { + $metadata = new ModelMetadata('cap', 'Cap', [CapabilityEnum::textGeneration()], [], 100); + $model = $this->createMockTextGenerationModel($this->createTestResult(), $metadata); + + // Small input on its own fits, but the reserved output cap pushes the total over the window. + $builder = new PromptBuilder($this->registry, 'Short prompt'); + $builder->usingModel($model)->usingMaxTokens(500); + + $this->expectException(TokenLimitReachedException::class); + + $builder->generateTextResult(); + } + + /** + * Tests that an injected token counter overrides the default estimate. + * + * @return void + */ + public function testUsingTokenCounterOverridesDefaultEstimate(): void + { + $metadata = new ModelMetadata('roomy', 'Roomy', [CapabilityEnum::textGeneration()], [], 50); + $model = $this->createMockTextGenerationModel($this->createTestResult(), $metadata); + + // A tiny prompt that the default heuristic would pass, but the injected counter reports as huge. + $builder = new PromptBuilder($this->registry, 'Hi'); + $builder->usingModel($model)->usingTokenCounter($this->createFixedTokenCounter(1000)); + + $this->expectException(TokenLimitReachedException::class); + + $builder->generateTextResult(); + } + + /** + * Creates a token counter that always reports a fixed count. + * + * @param int $count The token count to report. + * @return TokenCounterInterface The fixed token counter. + */ + private function createFixedTokenCounter(int $count): TokenCounterInterface + { + return new class ($count) implements TokenCounterInterface { + private int $count; + + public function __construct(int $count) + { + $this->count = $count; + } + + public function countTokens(array $messages, ModelMetadata $modelMetadata): int + { + return $this->count; + } + }; + } } diff --git a/tests/unit/Providers/Models/DTO/ModelMetadataTest.php b/tests/unit/Providers/Models/DTO/ModelMetadataTest.php index 13192572..f6f5848f 100644 --- a/tests/unit/Providers/Models/DTO/ModelMetadataTest.php +++ b/tests/unit/Providers/Models/DTO/ModelMetadataTest.php @@ -484,4 +484,131 @@ public function testCloneClonesSupportedOptions(): void $this->assertSame($cap, $clonedCaps[$index]); } } + + /** + * Tests that the context window is stored when provided. + * + * @return void + */ + public function testConstructorWithContextWindow(): void + { + $metadata = new ModelMetadata('m', 'M', [], [], 128000); + + $this->assertSame(128000, $metadata->getContextWindow()); + } + + /** + * Tests that the context window defaults to null. + * + * @return void + */ + public function testConstructorWithoutContextWindow(): void + { + $metadata = new ModelMetadata('m', 'M', [], []); + + $this->assertNull($metadata->getContextWindow()); + } + + /** + * Tests that a non-positive context window is rejected. + * + * @return void + */ + public function testConstructorThrowsOnNonPositiveContextWindow(): void + { + $this->expectException(\WordPress\AiClient\Common\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Context window must be a positive integer.'); + + new ModelMetadata('m', 'M', [], [], 0); + } + + /** + * Tests that toArray includes the context window when set. + * + * @return void + */ + public function testToArrayIncludesContextWindow(): void + { + $metadata = new ModelMetadata('m', 'M', [], [], 32000); + + $array = $metadata->toArray(); + + $this->assertArrayHasKey(ModelMetadata::KEY_CONTEXT_WINDOW, $array); + $this->assertSame(32000, $array[ModelMetadata::KEY_CONTEXT_WINDOW]); + } + + /** + * Tests that toArray omits the context window when not set. + * + * @return void + */ + public function testToArrayExcludesContextWindow(): void + { + $metadata = new ModelMetadata('m', 'M', [], []); + + $this->assertArrayNotHasKey(ModelMetadata::KEY_CONTEXT_WINDOW, $metadata->toArray()); + } + + /** + * Tests that fromArray reads the context window when present. + * + * @return void + */ + public function testFromArrayWithContextWindow(): void + { + $metadata = ModelMetadata::fromArray([ + ModelMetadata::KEY_ID => 'm', + ModelMetadata::KEY_NAME => 'M', + ModelMetadata::KEY_SUPPORTED_CAPABILITIES => [], + ModelMetadata::KEY_SUPPORTED_OPTIONS => [], + ModelMetadata::KEY_CONTEXT_WINDOW => 8192, + ]); + + $this->assertSame(8192, $metadata->getContextWindow()); + } + + /** + * Tests that fromArray defaults the context window to null when absent. + * + * @return void + */ + public function testFromArrayWithoutContextWindow(): void + { + $metadata = ModelMetadata::fromArray([ + ModelMetadata::KEY_ID => 'm', + ModelMetadata::KEY_NAME => 'M', + ModelMetadata::KEY_SUPPORTED_CAPABILITIES => [], + ModelMetadata::KEY_SUPPORTED_OPTIONS => [], + ]); + + $this->assertNull($metadata->getContextWindow()); + } + + /** + * Tests round-trip array transformation preserves the context window. + * + * @return void + */ + public function testArrayRoundTripWithContextWindow(): void + { + $original = new ModelMetadata('m', 'M', [], [], 200000); + + $restored = ModelMetadata::fromArray($original->toArray()); + + $this->assertSame(200000, $restored->getContextWindow()); + } + + /** + * Tests that the JSON schema exposes the context window as an optional property. + * + * @return void + */ + public function testJsonSchemaIncludesContextWindow(): void + { + $schema = ModelMetadata::getJsonSchema(); + + $this->assertArrayHasKey(ModelMetadata::KEY_CONTEXT_WINDOW, $schema['properties']); + $this->assertSame('integer', $schema['properties'][ModelMetadata::KEY_CONTEXT_WINDOW]['type']); + $this->assertNotContains(ModelMetadata::KEY_CONTEXT_WINDOW, $schema['required']); + } } diff --git a/tests/unit/Providers/Models/Tokenization/HeuristicTokenCounterTest.php b/tests/unit/Providers/Models/Tokenization/HeuristicTokenCounterTest.php new file mode 100644 index 00000000..979fe1dd --- /dev/null +++ b/tests/unit/Providers/Models/Tokenization/HeuristicTokenCounterTest.php @@ -0,0 +1,91 @@ +counter = new HeuristicTokenCounter(); + $this->metadata = new ModelMetadata('m', 'M', [], []); + } + + /** + * Tests that the counter implements the shared contract. + * + * @return void + */ + public function testImplementsTokenCounterInterface(): void + { + $this->assertInstanceOf(TokenCounterInterface::class, $this->counter); + } + + /** + * Tests that an empty message list yields zero tokens. + * + * @return void + */ + public function testCountTokensReturnsZeroForNoMessages(): void + { + $this->assertSame(0, $this->counter->countTokens([], $this->metadata)); + } + + /** + * Tests that text is estimated at roughly four characters per token, rounded up. + * + * @return void + */ + public function testCountTokensRoundsUp(): void + { + // 4 characters -> 1 token. + $exact = new UserMessage([new MessagePart('abcd')]); + $this->assertSame(1, $this->counter->countTokens([$exact], $this->metadata)); + + // 5 characters -> ceil(5 / 4) = 2 tokens. + $overflow = new UserMessage([new MessagePart('abcde')]); + $this->assertSame(2, $this->counter->countTokens([$overflow], $this->metadata)); + } + + /** + * Tests that text across multiple parts and messages is summed. + * + * @return void + */ + public function testCountTokensSumsAcrossPartsAndMessages(): void + { + $messages = [ + new UserMessage([ + new MessagePart('abcd'), + new MessagePart('efgh'), + ]), + new UserMessage([new MessagePart('ijkl')]), + ]; + + // 12 characters total -> 12 / 4 = 3 tokens. + $this->assertSame(3, $this->counter->countTokens($messages, $this->metadata)); + } +} From 737aaa819f91d3f6e0894d7c0f6d413bcd3315a3 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Fri, 24 Jul 2026 14:26:14 +0530 Subject: [PATCH 2/2] Add token counter interface and context window definition to documentation --- docs/ARCHITECTURE.md | 3 +++ docs/GLOSSARY.md | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d2f1a02..12383dfe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 @@ -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 @@ -1103,6 +1105,7 @@ direction LR +getName() string +getSupportedCapabilities() CapabilityEnum[] +getSupportedOptions() SupportedOption[] + +getContextWindow() ?int +getJsonSchema() array< string, mixed >$ } class ModelRequirements { diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 86ef2fcb..ca44dbdb 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -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.