From 43820274af6d10bbb7237deca2c0c7b551e4b1b1 Mon Sep 17 00:00:00 2001 From: yaman jain Date: Tue, 14 Jul 2026 19:04:45 +0530 Subject: [PATCH 1/2] Add cached token fields to TokenUsage and parse OpenAI cached_tokens --- ...actOpenAiCompatibleTextGenerationModel.php | 9 +- src/Results/DTO/TokenUsage.php | 79 +++++++++- ...penAiCompatibleTextGenerationModelTest.php | 97 ++++++++++++ tests/unit/Results/DTO/TokenUsageTest.php | 138 ++++++++++++++++++ 4 files changed, 317 insertions(+), 6 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index e0e2e71b..65127950 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -55,7 +55,10 @@ * @phpstan-type UsageData array{ * prompt_tokens?: int, * completion_tokens?: int, - * total_tokens?: int + * total_tokens?: int, + * prompt_tokens_details?: array{ + * cached_tokens?: int + * } * } * @phpstan-type ResponseData array{ * id?: string, @@ -607,7 +610,9 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera $tokenUsage = new TokenUsage( $usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0, - $usage['total_tokens'] ?? 0 + $usage['total_tokens'] ?? 0, + null, + $usage['prompt_tokens_details']['cached_tokens'] ?? null ); } else { $tokenUsage = new TokenUsage(0, 0, 0); diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index a6e02e69..b2121ae9 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -15,13 +15,18 @@ * Note that thought tokens are a subset of completion tokens, not additive. * In other words: completionTokens - thoughtTokens = tokens of actual output content. * + * Similarly, cached tokens are a subset of prompt tokens, not additive. + * In other words: promptTokens - cachedTokens = prompt tokens that were not served from a provider cache. + * * @since 0.1.0 * * @phpstan-type TokenUsageArrayShape array{ * promptTokens: int, * completionTokens: int, * totalTokens: int, - * thoughtTokens?: int + * thoughtTokens?: int, + * cachedTokens?: int, + * cacheCreationTokens?: int * } * * @extends AbstractDataTransferObject @@ -32,6 +37,8 @@ class TokenUsage extends AbstractDataTransferObject public const KEY_COMPLETION_TOKENS = 'completionTokens'; public const KEY_TOTAL_TOKENS = 'totalTokens'; public const KEY_THOUGHT_TOKENS = 'thoughtTokens'; + public const KEY_CACHED_TOKENS = 'cachedTokens'; + public const KEY_CACHE_CREATION_TOKENS = 'cacheCreationTokens'; /** * @var int Number of tokens in the prompt. */ @@ -52,6 +59,16 @@ class TokenUsage extends AbstractDataTransferObject */ private ?int $thoughtTokens; + /** + * @var int|null Number of prompt tokens served from a provider cache, as a subset of prompt tokens. + */ + private ?int $cachedTokens; + + /** + * @var int|null Number of tokens written to a provider cache while processing the request. + */ + private ?int $cacheCreationTokens; + /** * Constructor. * @@ -61,13 +78,24 @@ class TokenUsage extends AbstractDataTransferObject * @param int $completionTokens Number of tokens in the completion, including any thought tokens. * @param int $totalTokens Total number of tokens used. * @param int|null $thoughtTokens Number of tokens used for thinking, as a subset of completion tokens. + * @param int|null $cachedTokens Number of prompt tokens served from a provider cache, as a subset of prompt + * tokens. + * @param int|null $cacheCreationTokens Number of tokens written to a provider cache while processing the request. */ - public function __construct(int $promptTokens, int $completionTokens, int $totalTokens, ?int $thoughtTokens = null) - { + public function __construct( + int $promptTokens, + int $completionTokens, + int $totalTokens, + ?int $thoughtTokens = null, + ?int $cachedTokens = null, + ?int $cacheCreationTokens = null + ) { $this->promptTokens = $promptTokens; $this->completionTokens = $completionTokens; $this->totalTokens = $totalTokens; $this->thoughtTokens = $thoughtTokens; + $this->cachedTokens = $cachedTokens; + $this->cacheCreationTokens = $cacheCreationTokens; } /** @@ -118,6 +146,30 @@ public function getThoughtTokens(): ?int return $this->thoughtTokens; } + /** + * Gets the number of cached tokens, which is a subset of the prompt token count. + * + * @since n.e.x.t + * + * @return int|null The cached token count or null if not available. + */ + public function getCachedTokens(): ?int + { + return $this->cachedTokens; + } + + /** + * Gets the number of tokens written to a provider cache while processing the request. + * + * @since n.e.x.t + * + * @return int|null The cache creation token count or null if not available. + */ + public function getCacheCreationTokens(): ?int + { + return $this->cacheCreationTokens; + } + /** * {@inheritDoc} * @@ -144,6 +196,15 @@ public static function getJsonSchema(): array 'type' => 'integer', 'description' => 'Number of tokens used for thinking, as a subset of completion tokens.', ], + self::KEY_CACHED_TOKENS => [ + 'type' => 'integer', + 'description' => 'Number of prompt tokens served from a provider cache, as a subset of prompt' + . ' tokens.', + ], + self::KEY_CACHE_CREATION_TOKENS => [ + 'type' => 'integer', + 'description' => 'Number of tokens written to a provider cache while processing the request.', + ], ], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS], ]; @@ -168,6 +229,14 @@ public function toArray(): array $data[self::KEY_THOUGHT_TOKENS] = $this->thoughtTokens; } + if ($this->cachedTokens !== null) { + $data[self::KEY_CACHED_TOKENS] = $this->cachedTokens; + } + + if ($this->cacheCreationTokens !== null) { + $data[self::KEY_CACHE_CREATION_TOKENS] = $this->cacheCreationTokens; + } + return $data; } @@ -188,7 +257,9 @@ public static function fromArray(array $array): self $array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS], - $array[self::KEY_THOUGHT_TOKENS] ?? null + $array[self::KEY_THOUGHT_TOKENS] ?? null, + $array[self::KEY_CACHED_TOKENS] ?? null, + $array[self::KEY_CACHE_CREATION_TOKENS] ?? null ); } } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6c99c75b..e62ed064 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -137,6 +137,103 @@ public function testGenerateTextResultSuccess(): void $this->assertEquals(15, $result->getTokenUsage()->getTotalTokens()); } + /** + * Tests generateTextResult() surfaces cached tokens from prompt tokens details. + * + * @return void + */ + public function testGenerateTextResultParsesCachedTokens(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'chatcmpl-123', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hi there!', + ], + 'finish_reason' => 'stop', + ], + ], + 'usage' => [ + 'prompt_tokens' => 2048, + 'completion_tokens' => 5, + 'total_tokens' => 2053, + 'prompt_tokens_details' => [ + 'cached_tokens' => 1024, + ], + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + $result = $model->generateTextResult($prompt); + + $this->assertEquals(2048, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(1024, $result->getTokenUsage()->getCachedTokens()); + } + + /** + * Tests generateTextResult() returns null cached tokens when the provider omits prompt tokens details. + * + * @return void + */ + public function testGenerateTextResultWithoutCachedTokens(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'chatcmpl-123', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hi there!', + ], + 'finish_reason' => 'stop', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 5, + 'total_tokens' => 15, + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + $result = $model->generateTextResult($prompt); + + $this->assertNull($result->getTokenUsage()->getCachedTokens()); + $this->assertNull($result->getTokenUsage()->getCacheCreationTokens()); + } + /** * Tests generateTextResult() method on API failure. * diff --git a/tests/unit/Results/DTO/TokenUsageTest.php b/tests/unit/Results/DTO/TokenUsageTest.php index c885e431..cb8e5ed0 100644 --- a/tests/unit/Results/DTO/TokenUsageTest.php +++ b/tests/unit/Results/DTO/TokenUsageTest.php @@ -432,4 +432,142 @@ public function testJsonSchemaIncludesThoughtTokens(): void $this->assertArrayHasKey('description', $schema['properties'][TokenUsage::KEY_THOUGHT_TOKENS]); $this->assertNotContains(TokenUsage::KEY_THOUGHT_TOKENS, $schema['required']); } + + /** + * Tests creating TokenUsage with cache tokens. + * + * @return void + */ + public function testCreateWithCacheTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 150, null, 80, 15); + + $this->assertEquals(100, $tokenUsage->getPromptTokens()); + $this->assertEquals(50, $tokenUsage->getCompletionTokens()); + $this->assertEquals(150, $tokenUsage->getTotalTokens()); + $this->assertEquals(80, $tokenUsage->getCachedTokens()); + $this->assertEquals(15, $tokenUsage->getCacheCreationTokens()); + } + + /** + * Tests that cache tokens are null by default. + * + * @return void + */ + public function testCreateWithoutCacheTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 150); + + $this->assertNull($tokenUsage->getCachedTokens()); + $this->assertNull($tokenUsage->getCacheCreationTokens()); + } + + /** + * Tests toArray includes cache tokens when set. + * + * @return void + */ + public function testToArrayIncludesCacheTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 150, null, 80, 15); + $array = $tokenUsage->toArray(); + + $this->assertArrayHasKey(TokenUsage::KEY_CACHED_TOKENS, $array); + $this->assertEquals(80, $array[TokenUsage::KEY_CACHED_TOKENS]); + $this->assertArrayHasKey(TokenUsage::KEY_CACHE_CREATION_TOKENS, $array); + $this->assertEquals(15, $array[TokenUsage::KEY_CACHE_CREATION_TOKENS]); + } + + /** + * Tests toArray excludes cache tokens when not set. + * + * @return void + */ + public function testToArrayExcludesCacheTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 150); + $array = $tokenUsage->toArray(); + + $this->assertArrayNotHasKey(TokenUsage::KEY_CACHED_TOKENS, $array); + $this->assertArrayNotHasKey(TokenUsage::KEY_CACHE_CREATION_TOKENS, $array); + } + + /** + * Tests fromArray with cache tokens. + * + * @return void + */ + public function testFromArrayWithCacheTokens(): void + { + $array = [ + TokenUsage::KEY_PROMPT_TOKENS => 100, + TokenUsage::KEY_COMPLETION_TOKENS => 50, + TokenUsage::KEY_TOTAL_TOKENS => 150, + TokenUsage::KEY_CACHED_TOKENS => 80, + TokenUsage::KEY_CACHE_CREATION_TOKENS => 15, + ]; + + $tokenUsage = TokenUsage::fromArray($array); + + $this->assertEquals(80, $tokenUsage->getCachedTokens()); + $this->assertEquals(15, $tokenUsage->getCacheCreationTokens()); + } + + /** + * Tests fromArray without cache tokens still works. + * + * @return void + */ + public function testFromArrayWithoutCacheTokens(): void + { + $array = [ + TokenUsage::KEY_PROMPT_TOKENS => 100, + TokenUsage::KEY_COMPLETION_TOKENS => 50, + TokenUsage::KEY_TOTAL_TOKENS => 150, + ]; + + $tokenUsage = TokenUsage::fromArray($array); + + $this->assertNull($tokenUsage->getCachedTokens()); + $this->assertNull($tokenUsage->getCacheCreationTokens()); + } + + /** + * Tests round-trip array transformation with cache tokens. + * + * @return void + */ + public function testArrayRoundTripWithCacheTokens(): void + { + $original = new TokenUsage(200, 100, 300, 40, 160, 25); + $array = $original->toArray(); + $restored = TokenUsage::fromArray($array); + + $this->assertEquals($original->getPromptTokens(), $restored->getPromptTokens()); + $this->assertEquals($original->getCompletionTokens(), $restored->getCompletionTokens()); + $this->assertEquals($original->getTotalTokens(), $restored->getTotalTokens()); + $this->assertEquals($original->getThoughtTokens(), $restored->getThoughtTokens()); + $this->assertEquals($original->getCachedTokens(), $restored->getCachedTokens()); + $this->assertEquals($original->getCacheCreationTokens(), $restored->getCacheCreationTokens()); + } + + /** + * Tests JSON schema includes cache token properties but not in required. + * + * @return void + */ + public function testJsonSchemaIncludesCacheTokens(): void + { + $schema = TokenUsage::getJsonSchema(); + + $this->assertArrayHasKey(TokenUsage::KEY_CACHED_TOKENS, $schema['properties']); + $this->assertEquals('integer', $schema['properties'][TokenUsage::KEY_CACHED_TOKENS]['type']); + $this->assertArrayHasKey('description', $schema['properties'][TokenUsage::KEY_CACHED_TOKENS]); + $this->assertNotContains(TokenUsage::KEY_CACHED_TOKENS, $schema['required']); + + $this->assertArrayHasKey(TokenUsage::KEY_CACHE_CREATION_TOKENS, $schema['properties']); + $this->assertEquals('integer', $schema['properties'][TokenUsage::KEY_CACHE_CREATION_TOKENS]['type']); + $this->assertArrayHasKey('description', $schema['properties'][TokenUsage::KEY_CACHE_CREATION_TOKENS]); + $this->assertNotContains(TokenUsage::KEY_CACHE_CREATION_TOKENS, $schema['required']); + } } From 969377aa3174a94550992a59ca7792a0e6488e77 Mon Sep 17 00:00:00 2001 From: yaman jain Date: Tue, 14 Jul 2026 19:37:52 +0530 Subject: [PATCH 2/2] Parse cache_write_tokens into cache creation token count --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 6 ++++-- .../AbstractOpenAiCompatibleTextGenerationModelTest.php | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 65127950..85ef1c24 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -57,7 +57,8 @@ * completion_tokens?: int, * total_tokens?: int, * prompt_tokens_details?: array{ - * cached_tokens?: int + * cached_tokens?: int, + * cache_write_tokens?: int * } * } * @phpstan-type ResponseData array{ @@ -612,7 +613,8 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera $usage['completion_tokens'] ?? 0, $usage['total_tokens'] ?? 0, null, - $usage['prompt_tokens_details']['cached_tokens'] ?? null + $usage['prompt_tokens_details']['cached_tokens'] ?? null, + $usage['prompt_tokens_details']['cache_write_tokens'] ?? null ); } else { $tokenUsage = new TokenUsage(0, 0, 0); diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index e62ed064..9466aee2 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -138,7 +138,7 @@ public function testGenerateTextResultSuccess(): void } /** - * Tests generateTextResult() surfaces cached tokens from prompt tokens details. + * Tests generateTextResult() surfaces cache token counts from prompt tokens details. * * @return void */ @@ -165,6 +165,7 @@ public function testGenerateTextResultParsesCachedTokens(): void 'total_tokens' => 2053, 'prompt_tokens_details' => [ 'cached_tokens' => 1024, + 'cache_write_tokens' => 512, ], ], ]) @@ -185,6 +186,7 @@ public function testGenerateTextResultParsesCachedTokens(): void $this->assertEquals(2048, $result->getTokenUsage()->getPromptTokens()); $this->assertEquals(1024, $result->getTokenUsage()->getCachedTokens()); + $this->assertEquals(512, $result->getTokenUsage()->getCacheCreationTokens()); } /**