From 2bc99c06ae14dfc837ae974f2a7739ca08e17d7e Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 8 Mar 2026 13:34:12 -0500 Subject: [PATCH 1/4] Add support for thought signatures in message parts. --- src/Messages/DTO/MessagePart.php | 50 ++++++- tests/unit/Messages/DTO/MessagePartTest.php | 144 ++++++++++++++++++++ 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 631357aa..1ffde78c 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -28,6 +28,7 @@ * @phpstan-type MessagePartArrayShape array{ * channel: string, * type: string, + * thoughtSignature?: string, * text?: string, * file?: FileArrayShape, * functionCall?: FunctionCallArrayShape, @@ -40,6 +41,7 @@ class MessagePart extends AbstractDataTransferObject { public const KEY_CHANNEL = 'channel'; public const KEY_TYPE = 'type'; + public const KEY_THOUGHT_SIGNATURE = 'thoughtSignature'; public const KEY_TEXT = 'text'; public const KEY_FILE = 'file'; public const KEY_FUNCTION_CALL = 'functionCall'; @@ -55,6 +57,11 @@ class MessagePart extends AbstractDataTransferObject */ private MessagePartTypeEnum $type; + /** + * @var string|null Thought signature for extended thinking. + */ + private ?string $thoughtSignature = null; + /** * @var string|null Text content (when type is TEXT). */ @@ -82,11 +89,13 @@ class MessagePart extends AbstractDataTransferObject * * @param mixed $content The content of this message part. * @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT. + * @param string|null $thoughtSignature Optional thought signature for extended thinking. * @throws InvalidArgumentException If an unsupported content type is provided. */ - public function __construct($content, ?MessagePartChannelEnum $channel = null) + public function __construct($content, ?MessagePartChannelEnum $channel = null, ?string $thoughtSignature = null) { $this->channel = $channel ?? MessagePartChannelEnum::content(); + $this->thoughtSignature = $thoughtSignature; if (is_string($content)) { $this->type = MessagePartTypeEnum::text(); @@ -136,6 +145,18 @@ public function getType(): MessagePartTypeEnum return $this->type; } + /** + * Gets the thought signature. + * + * @since n.e.x.t + * + * @return string|null The thought signature or null if not set. + */ + public function getThoughtSignature(): ?string + { + return $this->thoughtSignature; + } + /** * Gets the text content. * @@ -197,6 +218,11 @@ public static function getJsonSchema(): array 'description' => 'The channel this message part belongs to.', ]; + $thoughtSignatureSchema = [ + 'type' => 'string', + 'description' => 'Thought signature for extended thinking.', + ]; + return [ 'oneOf' => [ [ @@ -211,6 +237,7 @@ public static function getJsonSchema(): array 'type' => 'string', 'description' => 'Text content.', ], + self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, ], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => false, @@ -224,6 +251,7 @@ public static function getJsonSchema(): array 'const' => MessagePartTypeEnum::file()->value, ], self::KEY_FILE => File::getJsonSchema(), + self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, ], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => false, @@ -237,6 +265,7 @@ public static function getJsonSchema(): array 'const' => MessagePartTypeEnum::functionCall()->value, ], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema(), + self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, ], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => false, @@ -250,6 +279,7 @@ public static function getJsonSchema(): array 'const' => MessagePartTypeEnum::functionResponse()->value, ], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema(), + self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, ], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => false, @@ -287,6 +317,10 @@ public function toArray(): array ); } + if ($this->thoughtSignature !== null) { + $data[self::KEY_THOUGHT_SIGNATURE] = $this->thoughtSignature; + } + return $data; } @@ -303,15 +337,21 @@ public static function fromArray(array $array): self $channel = null; } + $thoughtSignature = $array[self::KEY_THOUGHT_SIGNATURE] ?? null; + // Check which properties are set to determine how to construct the MessagePart if (isset($array[self::KEY_TEXT])) { - return new self($array[self::KEY_TEXT], $channel); + return new self($array[self::KEY_TEXT], $channel, $thoughtSignature); } elseif (isset($array[self::KEY_FILE])) { - return new self(File::fromArray($array[self::KEY_FILE]), $channel); + return new self(File::fromArray($array[self::KEY_FILE]), $channel, $thoughtSignature); } elseif (isset($array[self::KEY_FUNCTION_CALL])) { - return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel); + return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel, $thoughtSignature); } elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) { - return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel); + return new self( + FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), + $channel, + $thoughtSignature + ); } else { throw new InvalidArgumentException( 'MessagePart requires one of: text, file, functionCall, or functionResponse.' diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index ecb9453f..de7a4ae0 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -486,4 +486,148 @@ public function testCloneClonesFunctionResponse(): void $this->assertNotSame($original->getFunctionResponse(), $cloned->getFunctionResponse()); } + + /** + * Tests creating MessagePart with a thought signature. + * + * @return void + */ + public function testCreateWithThoughtSignature(): void + { + $part = new MessagePart( + 'Some thought', + MessagePartChannelEnum::thought(), + 'sig_abc123' + ); + + $this->assertEquals('sig_abc123', $part->getThoughtSignature()); + $this->assertEquals('Some thought', $part->getText()); + $this->assertEquals(MessagePartChannelEnum::thought(), $part->getChannel()); + } + + /** + * Tests that thought signature is null by default. + * + * @return void + */ + public function testCreateWithoutThoughtSignature(): void + { + $part = new MessagePart('Hello'); + + $this->assertNull($part->getThoughtSignature()); + } + + /** + * Tests toArray includes thought signature when set. + * + * @return void + */ + public function testToArrayIncludesThoughtSignature(): void + { + $part = new MessagePart( + 'Thinking...', + MessagePartChannelEnum::thought(), + 'sig_xyz789' + ); + $array = $part->toArray(); + + $this->assertArrayHasKey(MessagePart::KEY_THOUGHT_SIGNATURE, $array); + $this->assertEquals('sig_xyz789', $array[MessagePart::KEY_THOUGHT_SIGNATURE]); + } + + /** + * Tests toArray excludes thought signature when not set. + * + * @return void + */ + public function testToArrayExcludesThoughtSignature(): void + { + $part = new MessagePart('Hello'); + $array = $part->toArray(); + + $this->assertArrayNotHasKey(MessagePart::KEY_THOUGHT_SIGNATURE, $array); + } + + /** + * Tests fromArray with thought signature. + * + * @return void + */ + public function testFromArrayWithThoughtSignature(): void + { + $array = [ + MessagePart::KEY_CHANNEL => MessagePartChannelEnum::thought()->value, + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'A thought', + MessagePart::KEY_THOUGHT_SIGNATURE => 'sig_fromarray', + ]; + + $part = MessagePart::fromArray($array); + + $this->assertEquals('A thought', $part->getText()); + $this->assertEquals('sig_fromarray', $part->getThoughtSignature()); + } + + /** + * Tests fromArray without thought signature still works. + * + * @return void + */ + public function testFromArrayWithoutThoughtSignature(): void + { + $array = [ + MessagePart::KEY_CHANNEL => MessagePartChannelEnum::content()->value, + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'No signature', + ]; + + $part = MessagePart::fromArray($array); + + $this->assertNull($part->getThoughtSignature()); + } + + /** + * Tests round-trip array transformation with thought signature. + * + * @return void + */ + public function testArrayRoundTripWithThoughtSignature(): void + { + $original = new MessagePart( + 'Thought content', + MessagePartChannelEnum::thought(), + 'sig_roundtrip' + ); + $array = $original->toArray(); + $restored = MessagePart::fromArray($array); + + $this->assertEquals($original->getText(), $restored->getText()); + $this->assertEquals($original->getChannel(), $restored->getChannel()); + $this->assertEquals($original->getThoughtSignature(), $restored->getThoughtSignature()); + } + + /** + * Tests JSON schema includes thought signature property. + * + * @return void + */ + public function testJsonSchemaIncludesThoughtSignature(): void + { + $schema = MessagePart::getJsonSchema(); + + foreach ($schema['oneOf'] as $variant) { + $this->assertArrayHasKey( + MessagePart::KEY_THOUGHT_SIGNATURE, + $variant['properties'] + ); + $this->assertEquals( + 'string', + $variant['properties'][MessagePart::KEY_THOUGHT_SIGNATURE]['type'] + ); + $this->assertNotContains( + MessagePart::KEY_THOUGHT_SIGNATURE, + $variant['required'] + ); + } + } } From 305b80d7349dc8722d249e0c17f0266834fc4dbe Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 8 Mar 2026 13:34:26 -0500 Subject: [PATCH 2/4] Add support for thought tokens in token usage data. --- src/Results/DTO/TokenUsage.php | 40 ++++++- tests/unit/Results/DTO/TokenUsageTest.php | 123 ++++++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index 4e283085..a18242ce 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -17,7 +17,8 @@ * @phpstan-type TokenUsageArrayShape array{ * promptTokens: int, * completionTokens: int, - * totalTokens: int + * totalTokens: int, + * thoughtTokens?: int * } * * @extends AbstractDataTransferObject @@ -27,6 +28,7 @@ class TokenUsage extends AbstractDataTransferObject public const KEY_PROMPT_TOKENS = 'promptTokens'; public const KEY_COMPLETION_TOKENS = 'completionTokens'; public const KEY_TOTAL_TOKENS = 'totalTokens'; + public const KEY_THOUGHT_TOKENS = 'thoughtTokens'; /** * @var int Number of tokens in the prompt. */ @@ -42,6 +44,11 @@ class TokenUsage extends AbstractDataTransferObject */ private int $totalTokens; + /** + * @var int|null Number of tokens used for thinking. + */ + private ?int $thoughtTokens; + /** * Constructor. * @@ -50,12 +57,14 @@ class TokenUsage extends AbstractDataTransferObject * @param int $promptTokens Number of tokens in the prompt. * @param int $completionTokens Number of tokens in the completion. * @param int $totalTokens Total number of tokens used. + * @param int|null $thoughtTokens Number of tokens used for thinking. */ - public function __construct(int $promptTokens, int $completionTokens, int $totalTokens) + public function __construct(int $promptTokens, int $completionTokens, int $totalTokens, ?int $thoughtTokens = null) { $this->promptTokens = $promptTokens; $this->completionTokens = $completionTokens; $this->totalTokens = $totalTokens; + $this->thoughtTokens = $thoughtTokens; } /** @@ -94,6 +103,18 @@ public function getTotalTokens(): int return $this->totalTokens; } + /** + * Gets the number of thought tokens. + * + * @since n.e.x.t + * + * @return int|null The thought token count or null if not available. + */ + public function getThoughtTokens(): ?int + { + return $this->thoughtTokens; + } + /** * {@inheritDoc} * @@ -116,6 +137,10 @@ public static function getJsonSchema(): array 'type' => 'integer', 'description' => 'Total number of tokens used.', ], + self::KEY_THOUGHT_TOKENS => [ + 'type' => 'integer', + 'description' => 'Number of tokens used for thinking.', + ], ], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS], ]; @@ -130,11 +155,17 @@ public static function getJsonSchema(): array */ public function toArray(): array { - return [ + $data = [ self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens, ]; + + if ($this->thoughtTokens !== null) { + $data[self::KEY_THOUGHT_TOKENS] = $this->thoughtTokens; + } + + return $data; } /** @@ -153,7 +184,8 @@ public static function fromArray(array $array): self return new self( $array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], - $array[self::KEY_TOTAL_TOKENS] + $array[self::KEY_TOTAL_TOKENS], + $array[self::KEY_THOUGHT_TOKENS] ?? null ); } } diff --git a/tests/unit/Results/DTO/TokenUsageTest.php b/tests/unit/Results/DTO/TokenUsageTest.php index 8cff5033..88bc58d0 100644 --- a/tests/unit/Results/DTO/TokenUsageTest.php +++ b/tests/unit/Results/DTO/TokenUsageTest.php @@ -309,4 +309,127 @@ public function testStreamingResponseUsage(): void $this->assertLessThan($midUsage->getTotalTokens(), $initialUsage->getTotalTokens()); $this->assertLessThan($finalUsage->getTotalTokens(), $midUsage->getTotalTokens()); } + + /** + * Tests creating TokenUsage with thought tokens. + * + * @return void + */ + public function testCreateWithThoughtTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 250, 100); + + $this->assertEquals(100, $tokenUsage->getPromptTokens()); + $this->assertEquals(50, $tokenUsage->getCompletionTokens()); + $this->assertEquals(250, $tokenUsage->getTotalTokens()); + $this->assertEquals(100, $tokenUsage->getThoughtTokens()); + } + + /** + * Tests that thought tokens are null by default. + * + * @return void + */ + public function testCreateWithoutThoughtTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 150); + + $this->assertNull($tokenUsage->getThoughtTokens()); + } + + /** + * Tests toArray includes thought tokens when set. + * + * @return void + */ + public function testToArrayIncludesThoughtTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 250, 100); + $array = $tokenUsage->toArray(); + + $this->assertArrayHasKey(TokenUsage::KEY_THOUGHT_TOKENS, $array); + $this->assertEquals(100, $array[TokenUsage::KEY_THOUGHT_TOKENS]); + } + + /** + * Tests toArray excludes thought tokens when not set. + * + * @return void + */ + public function testToArrayExcludesThoughtTokens(): void + { + $tokenUsage = new TokenUsage(100, 50, 150); + $array = $tokenUsage->toArray(); + + $this->assertArrayNotHasKey(TokenUsage::KEY_THOUGHT_TOKENS, $array); + } + + /** + * Tests fromArray with thought tokens. + * + * @return void + */ + public function testFromArrayWithThoughtTokens(): void + { + $array = [ + TokenUsage::KEY_PROMPT_TOKENS => 100, + TokenUsage::KEY_COMPLETION_TOKENS => 50, + TokenUsage::KEY_TOTAL_TOKENS => 250, + TokenUsage::KEY_THOUGHT_TOKENS => 100, + ]; + + $tokenUsage = TokenUsage::fromArray($array); + + $this->assertEquals(100, $tokenUsage->getThoughtTokens()); + } + + /** + * Tests fromArray without thought tokens still works. + * + * @return void + */ + public function testFromArrayWithoutThoughtTokens(): void + { + $array = [ + TokenUsage::KEY_PROMPT_TOKENS => 100, + TokenUsage::KEY_COMPLETION_TOKENS => 50, + TokenUsage::KEY_TOTAL_TOKENS => 150, + ]; + + $tokenUsage = TokenUsage::fromArray($array); + + $this->assertNull($tokenUsage->getThoughtTokens()); + } + + /** + * Tests round-trip array transformation with thought tokens. + * + * @return void + */ + public function testArrayRoundTripWithThoughtTokens(): void + { + $original = new TokenUsage(200, 100, 500, 200); + $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()); + } + + /** + * Tests JSON schema includes thought tokens property but not in required. + * + * @return void + */ + public function testJsonSchemaIncludesThoughtTokens(): void + { + $schema = TokenUsage::getJsonSchema(); + + $this->assertArrayHasKey(TokenUsage::KEY_THOUGHT_TOKENS, $schema['properties']); + $this->assertEquals('integer', $schema['properties'][TokenUsage::KEY_THOUGHT_TOKENS]['type']); + $this->assertArrayHasKey('description', $schema['properties'][TokenUsage::KEY_THOUGHT_TOKENS]); + $this->assertNotContains(TokenUsage::KEY_THOUGHT_TOKENS, $schema['required']); + } } From d96975780f4efe6f1d7106ac81bec36b3487aeb6 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 9 Mar 2026 18:28:44 -0500 Subject: [PATCH 3/4] Clarify docs about what thought tokens and completion tokens are expected to count. --- src/Results/DTO/TokenUsage.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index a18242ce..2e7f1e5c 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -12,6 +12,9 @@ * This DTO tracks the number of tokens used in prompts and completions, * which is important for monitoring usage and costs. * + * Note that thought tokens are a subset of completion tokens, not additive. + * In other words: completionTokens - thoughtTokens = tokens of actual output content. + * * @since 0.1.0 * * @phpstan-type TokenUsageArrayShape array{ @@ -35,7 +38,7 @@ class TokenUsage extends AbstractDataTransferObject private int $promptTokens; /** - * @var int Number of tokens in the completion. + * @var int Number of tokens in the completion, including any thought tokens. */ private int $completionTokens; @@ -45,7 +48,7 @@ class TokenUsage extends AbstractDataTransferObject private int $totalTokens; /** - * @var int|null Number of tokens used for thinking. + * @var int|null Number of tokens used for thinking, as a subset of completion tokens. */ private ?int $thoughtTokens; @@ -55,9 +58,9 @@ class TokenUsage extends AbstractDataTransferObject * @since 0.1.0 * * @param int $promptTokens Number of tokens in the prompt. - * @param int $completionTokens Number of tokens in the completion. + * @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. + * @param int|null $thoughtTokens Number of tokens used for thinking, as a subset of completion tokens. */ public function __construct(int $promptTokens, int $completionTokens, int $totalTokens, ?int $thoughtTokens = null) { @@ -80,7 +83,7 @@ public function getPromptTokens(): int } /** - * Gets the number of completion tokens. + * Gets the number of completion tokens, including any thought tokens. * * @since 0.1.0 * @@ -104,7 +107,7 @@ public function getTotalTokens(): int } /** - * Gets the number of thought tokens. + * Gets the number of thought tokens, which is a subset of the completion token count. * * @since n.e.x.t * @@ -131,7 +134,7 @@ public static function getJsonSchema(): array ], self::KEY_COMPLETION_TOKENS => [ 'type' => 'integer', - 'description' => 'Number of tokens in the completion.', + 'description' => 'Number of tokens in the completion, including any thought tokens.', ], self::KEY_TOTAL_TOKENS => [ 'type' => 'integer', @@ -139,7 +142,7 @@ public static function getJsonSchema(): array ], self::KEY_THOUGHT_TOKENS => [ 'type' => 'integer', - 'description' => 'Number of tokens used for thinking.', + 'description' => 'Number of tokens used for thinking, as a subset of completion tokens.', ], ], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS], From 7352d4538b6772b0bad716d1ef1cd825c5ee0b31 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 10 Mar 2026 12:43:06 -0500 Subject: [PATCH 4/4] Ensure thought tokens are part of completion tokens in tests. --- tests/unit/Results/DTO/TokenUsageTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/Results/DTO/TokenUsageTest.php b/tests/unit/Results/DTO/TokenUsageTest.php index 88bc58d0..c885e431 100644 --- a/tests/unit/Results/DTO/TokenUsageTest.php +++ b/tests/unit/Results/DTO/TokenUsageTest.php @@ -317,12 +317,12 @@ public function testStreamingResponseUsage(): void */ public function testCreateWithThoughtTokens(): void { - $tokenUsage = new TokenUsage(100, 50, 250, 100); + $tokenUsage = new TokenUsage(100, 50, 150, 20); $this->assertEquals(100, $tokenUsage->getPromptTokens()); $this->assertEquals(50, $tokenUsage->getCompletionTokens()); - $this->assertEquals(250, $tokenUsage->getTotalTokens()); - $this->assertEquals(100, $tokenUsage->getThoughtTokens()); + $this->assertEquals(150, $tokenUsage->getTotalTokens()); + $this->assertEquals(20, $tokenUsage->getThoughtTokens()); } /** @@ -344,11 +344,11 @@ public function testCreateWithoutThoughtTokens(): void */ public function testToArrayIncludesThoughtTokens(): void { - $tokenUsage = new TokenUsage(100, 50, 250, 100); + $tokenUsage = new TokenUsage(100, 50, 150, 30); $array = $tokenUsage->toArray(); $this->assertArrayHasKey(TokenUsage::KEY_THOUGHT_TOKENS, $array); - $this->assertEquals(100, $array[TokenUsage::KEY_THOUGHT_TOKENS]); + $this->assertEquals(30, $array[TokenUsage::KEY_THOUGHT_TOKENS]); } /**