Skip to content

Commit 9fe9389

Browse files
committed
feat: Add response metadata and raw http response for OpenAI token counter
1 parent bdddee6 commit 9fe9389

17 files changed

+330
-15
lines changed

examples/chat-gpt-openai-metadata.php

+38
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

+5
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44

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

7+
use PhpLlm\LlmChain\Model\Response\Metadata\EmptyMetadataTrait;
8+
use PhpLlm\LlmChain\Model\Response\RawResponseAwareTrait;
79
use PhpLlm\LlmChain\Model\Response\ResponseInterface;
810

911
class ImageResponse implements ResponseInterface
1012
{
13+
use EmptyMetadataTrait;
14+
use RawResponseAwareTrait;
15+
1116
/** @var list<Base64Image|UrlImage> */
1217
private readonly array $images;
1318

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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->getContent(false);
33+
$content = \json_decode($content, true);
34+
35+
if (!\array_key_exists('usage', $content)) {
36+
return;
37+
}
38+
39+
$metadata->add('prompt_tokens', $content['usage']['prompt_tokens'] ?? null);
40+
$metadata->add('completion_tokens', $content['usage']['completion_tokens'] ?? null);
41+
$metadata->add('total_tokens', $content['usage']['total_tokens'] ?? null);
42+
}
43+
}

src/Chain/Toolbox/StreamResponse.php

+8-3
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
namespace PhpLlm\LlmChain\Chain\Toolbox;
66

77
use PhpLlm\LlmChain\Model\Message\Message;
8+
use PhpLlm\LlmChain\Model\Response\Metadata\EmptyMetadataTrait;
9+
use PhpLlm\LlmChain\Model\Response\RawResponseAwareTrait;
810
use PhpLlm\LlmChain\Model\Response\ResponseInterface;
911
use PhpLlm\LlmChain\Model\Response\ToolCallResponse;
1012

11-
final readonly class StreamResponse implements ResponseInterface
13+
final class StreamResponse implements ResponseInterface
1214
{
15+
use EmptyMetadataTrait;
16+
use RawResponseAwareTrait;
17+
1318
public function __construct(
14-
private \Generator $generator,
15-
private \Closure $handleToolCallsCallback,
19+
private readonly \Generator $generator,
20+
private readonly \Closure $handleToolCallsCallback,
1621
) {
1722
}
1823

src/Model/Response/AsyncResponse.php

+21
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\EmptyMetadataTrait;
79
use PhpLlm\LlmChain\Platform\ResponseConverter;
810
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
911

1012
final class AsyncResponse implements ResponseInterface
1113
{
14+
use EmptyMetadataTrait;
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/BinaryResponse.php

+5
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44

55
namespace PhpLlm\LlmChain\Model\Response;
66

7+
use PhpLlm\LlmChain\Model\Response\Metadata\EmptyMetadataTrait;
8+
79
final class BinaryResponse implements ResponseInterface
810
{
11+
use EmptyMetadataTrait;
12+
use RawResponseAwareTrait;
13+
914
public function __construct(
1015
public string $data,
1116
public ?string $mimeType = null,

src/Model/Response/ChoiceResponse.php

+6-2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
namespace PhpLlm\LlmChain\Model\Response;
66

77
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
8+
use PhpLlm\LlmChain\Model\Response\Metadata\EmptyMetadataTrait;
89

9-
final readonly class ChoiceResponse implements ResponseInterface
10+
final class ChoiceResponse implements ResponseInterface
1011
{
12+
use EmptyMetadataTrait;
13+
use RawResponseAwareTrait;
14+
1115
/**
1216
* @var Choice[]
1317
*/
14-
private array $choices;
18+
private readonly array $choices;
1519

1620
public function __construct(Choice ...$choices)
1721
{
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Model\Response\Metadata;
6+
7+
trait EmptyMetadataTrait
8+
{
9+
private ?Metadata $metadata = null;
10+
11+
public function getMetadata(): Metadata
12+
{
13+
if (null === $this->metadata) {
14+
$this->metadata = new Metadata();
15+
}
16+
17+
return $this->metadata;
18+
}
19+
}
+99
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Model\Response;
6+
7+
use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet;
8+
use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse;
9+
10+
trait RawResponseAwareTrait
11+
{
12+
private ?SymfonyHttpResponse $rawResponse = null;
13+
14+
public function setRawResponse(SymfonyHttpResponse $rawResponse): void
15+
{
16+
if (null !== $this->rawResponse) {
17+
throw new RawResponseAlreadySet();
18+
}
19+
20+
$this->rawResponse = $rawResponse;
21+
}
22+
23+
public function getRawResponse(): ?SymfonyHttpResponse
24+
{
25+
return $this->rawResponse;
26+
}
27+
}

src/Model/Response/ResponseInterface.php

+13
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,23 @@
44

55
namespace PhpLlm\LlmChain\Model\Response;
66

7+
use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet;
8+
use PhpLlm\LlmChain\Model\Response\Metadata\Metadata;
9+
use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse;
10+
711
interface ResponseInterface
812
{
913
/**
1014
* @return string|iterable<mixed>|object|null
1115
*/
1216
public function getContent(): string|iterable|object|null;
17+
18+
public function getMetadata(): Metadata;
19+
20+
public function getRawResponse(): ?SymfonyHttpResponse;
21+
22+
/**
23+
* @throws RawResponseAlreadySet if the response is tried to be set more than once
24+
*/
25+
public function setRawResponse(SymfonyHttpResponse $rawResponse): void;
1326
}

0 commit comments

Comments
 (0)