Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit 5a06b3f

Browse files
DZunkechr-hertel
andauthored
feat: add Raw Response Metadata Handling - GPT Token Usage Example (#270)
Currently it is not really possible to get into details of the interaction between user and model because the HTTPResponse is hidden behind the response converter and is then forgotten forever. So the only possibility currently to get details from the interaction would be a custom response and a custom response converter. As a draft and to collect your ideas and other input i want to throw this stone into the lake with having the HTTPResponse also transported into the direction of the output processors. Could this give a bit more flexibility? What do you think? Please have in mind this is only a draft to discuss. I thought about having this also possible if a request fails with an error status code in the future so the output processors could handle in a separation of convern way if there should be an exception thrown. Or do we maybe need an additional layer on top of the response converters itself? For sure this is the simplest use case with the text response in mind. Maybe for the streamed response it would need a more "recording" approach with an output processor. But would surely be possible. --------- Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
1 parent 30b7f92 commit 5a06b3f

25 files changed

+921
-19
lines changed

examples/chat-gpt-openai-metadata.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Bridge\OpenAI\GPT;
4+
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
5+
use PhpLlm\LlmChain\Bridge\OpenAI\TokenOutputProcessor;
6+
use PhpLlm\LlmChain\Chain;
7+
use PhpLlm\LlmChain\Model\Message\Message;
8+
use PhpLlm\LlmChain\Model\Message\MessageBag;
9+
use Symfony\Component\Dotenv\Dotenv;
10+
11+
require_once dirname(__DIR__).'/vendor/autoload.php';
12+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
13+
14+
if (empty($_ENV['OPENAI_API_KEY'])) {
15+
echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL;
16+
exit(1);
17+
}
18+
19+
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
20+
$llm = new GPT(GPT::GPT_4O_MINI, [
21+
'temperature' => 0.5, // default options for the model
22+
]);
23+
24+
$chain = new Chain($platform, $llm, outputProcessors: [new TokenOutputProcessor()]);
25+
$messages = new MessageBag(
26+
Message::forSystem('You are a pirate and you write funny.'),
27+
Message::ofUser('What is the Symfony framework?'),
28+
);
29+
$response = $chain->call($messages, [
30+
'max_tokens' => 500, // specific options just for this call
31+
]);
32+
33+
$metadata = $response->getMetadata();
34+
35+
echo 'Utilized Tokens: '.$metadata['total_tokens'].PHP_EOL;
36+
echo '-- Prompt Tokens: '.$metadata['prompt_tokens'].PHP_EOL;
37+
echo '-- Completion Tokens: '.$metadata['completion_tokens'].PHP_EOL;
38+
echo 'Remaining Tokens: '.$metadata['remaining_tokens'].PHP_EOL;

src/Bridge/OpenAI/DallE/ImageResponse.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE;
66

7-
use PhpLlm\LlmChain\Model\Response\ResponseInterface;
7+
use PhpLlm\LlmChain\Model\Response\BaseResponse;
88

9-
class ImageResponse implements ResponseInterface
9+
class ImageResponse extends BaseResponse
1010
{
1111
/** @var list<Base64Image|UrlImage> */
1212
private readonly array $images;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Bridge\OpenAI;
6+
7+
use PhpLlm\LlmChain\Chain\Output;
8+
use PhpLlm\LlmChain\Chain\OutputProcessor;
9+
use PhpLlm\LlmChain\Model\Response\StreamResponse;
10+
11+
final class TokenOutputProcessor implements OutputProcessor
12+
{
13+
public function processOutput(Output $output): void
14+
{
15+
if ($output->response instanceof StreamResponse) {
16+
// Streams have to be handled manually as the tokens are part of the streamed chunks
17+
return;
18+
}
19+
20+
$rawResponse = $output->response->getRawResponse();
21+
if (null === $rawResponse) {
22+
return;
23+
}
24+
25+
$metadata = $output->response->getMetadata();
26+
27+
$metadata->add(
28+
'remaining_tokens',
29+
(int) $rawResponse->getHeaders(false)['x-ratelimit-remaining-tokens'][0],
30+
);
31+
32+
$content = $rawResponse->toArray(false);
33+
34+
if (!\array_key_exists('usage', $content)) {
35+
return;
36+
}
37+
38+
$metadata->add('prompt_tokens', $content['usage']['prompt_tokens'] ?? null);
39+
$metadata->add('completion_tokens', $content['usage']['completion_tokens'] ?? null);
40+
$metadata->add('total_tokens', $content['usage']['total_tokens'] ?? null);
41+
}
42+
}

src/Chain/Toolbox/StreamResponse.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
namespace PhpLlm\LlmChain\Chain\Toolbox;
66

77
use PhpLlm\LlmChain\Model\Message\Message;
8-
use PhpLlm\LlmChain\Model\Response\ResponseInterface;
8+
use PhpLlm\LlmChain\Model\Response\BaseResponse;
99
use PhpLlm\LlmChain\Model\Response\ToolCallResponse;
1010

11-
final readonly class StreamResponse implements ResponseInterface
11+
final class StreamResponse extends BaseResponse
1212
{
1313
public function __construct(
14-
private \Generator $generator,
15-
private \Closure $handleToolCallsCallback,
14+
private readonly \Generator $generator,
15+
private readonly \Closure $handleToolCallsCallback,
1616
) {
1717
}
1818

src/Model/Response/AsyncResponse.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
namespace PhpLlm\LlmChain\Model\Response;
66

7+
use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet;
8+
use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait;
79
use PhpLlm\LlmChain\Platform\ResponseConverter;
810
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
911

1012
final class AsyncResponse implements ResponseInterface
1113
{
14+
use MetadataAwareTrait;
15+
1216
private bool $isConverted = false;
1317
private ResponseInterface $convertedResponse;
1418

@@ -27,10 +31,27 @@ public function getContent(): string|iterable|object|null
2731
return $this->unwrap()->getContent();
2832
}
2933

34+
public function getRawResponse(): HttpResponse
35+
{
36+
return $this->response;
37+
}
38+
39+
public function setRawResponse(HttpResponse $rawResponse): void
40+
{
41+
// Empty by design as the raw response is already set in the constructor and must only be set once
42+
throw new RawResponseAlreadySet();
43+
}
44+
3045
public function unwrap(): ResponseInterface
3146
{
3247
if (!$this->isConverted) {
3348
$this->convertedResponse = $this->responseConverter->convert($this->response, $this->options);
49+
50+
if (null === $this->convertedResponse->getRawResponse()) {
51+
// Fallback to set the raw response when it was not handled by the response converter itself
52+
$this->convertedResponse->setRawResponse($this->response);
53+
}
54+
3455
$this->isConverted = true;
3556
}
3657

src/Model/Response/BaseResponse.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Model\Response;
6+
7+
use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait;
8+
9+
abstract class BaseResponse implements ResponseInterface
10+
{
11+
use MetadataAwareTrait;
12+
use RawResponseAwareTrait;
13+
}

src/Model/Response/BinaryResponse.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
namespace PhpLlm\LlmChain\Model\Response;
66

7-
final class BinaryResponse implements ResponseInterface
7+
final class BinaryResponse extends BaseResponse
88
{
99
public function __construct(
1010
public string $data,

src/Model/Response/ChoiceResponse.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66

77
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
88

9-
final readonly class ChoiceResponse implements ResponseInterface
9+
final class ChoiceResponse extends BaseResponse
1010
{
1111
/**
1212
* @var Choice[]
1313
*/
14-
private array $choices;
14+
private readonly array $choices;
1515

1616
public function __construct(Choice ...$choices)
1717
{
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Model\Response\Exception;
6+
7+
final class RawResponseAlreadySet extends \RuntimeException
8+
{
9+
public function __construct()
10+
{
11+
parent::__construct('The raw response was already set.');
12+
}
13+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Model\Response\Metadata;
6+
7+
/**
8+
* @implements \IteratorAggregate<string, mixed>
9+
* @implements \ArrayAccess<string, mixed>
10+
*/
11+
class Metadata implements \JsonSerializable, \Countable, \IteratorAggregate, \ArrayAccess
12+
{
13+
/**
14+
* @var array<string, mixed>
15+
*/
16+
private array $metadata = [];
17+
18+
/**
19+
* @param array<string, mixed> $metadata
20+
*/
21+
public function __construct(array $metadata = [])
22+
{
23+
$this->set($metadata);
24+
}
25+
26+
/**
27+
* @return array<string, mixed>
28+
*/
29+
public function all(): array
30+
{
31+
return $this->metadata;
32+
}
33+
34+
/**
35+
* @param array<string, mixed> $metadata
36+
*/
37+
public function set(array $metadata): void
38+
{
39+
$this->metadata = $metadata;
40+
}
41+
42+
public function add(string $key, mixed $value): void
43+
{
44+
$this->metadata[$key] = $value;
45+
}
46+
47+
public function has(string $key): bool
48+
{
49+
return \array_key_exists($key, $this->metadata);
50+
}
51+
52+
public function get(string $key, mixed $default = null): mixed
53+
{
54+
return $this->metadata[$key] ?? $default;
55+
}
56+
57+
public function remove(string $key): void
58+
{
59+
unset($this->metadata[$key]);
60+
}
61+
62+
/**
63+
* @return array<string, mixed>
64+
*/
65+
public function jsonSerialize(): array
66+
{
67+
return $this->all();
68+
}
69+
70+
public function count(): int
71+
{
72+
return \count($this->metadata);
73+
}
74+
75+
public function getIterator(): \Traversable
76+
{
77+
return new \ArrayIterator($this->metadata);
78+
}
79+
80+
public function offsetExists(mixed $offset): bool
81+
{
82+
return $this->has((string) $offset);
83+
}
84+
85+
public function offsetGet(mixed $offset): mixed
86+
{
87+
return $this->get((string) $offset);
88+
}
89+
90+
public function offsetSet(mixed $offset, mixed $value): void
91+
{
92+
$this->add((string) $offset, $value);
93+
}
94+
95+
public function offsetUnset(mixed $offset): void
96+
{
97+
$this->remove((string) $offset);
98+
}
99+
}

0 commit comments

Comments
 (0)