Skip to content

feat: Add Raw Response Metadata Handling - GPT Token Usage Example #270

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 2 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions examples/chat-gpt-openai-metadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

use PhpLlm\LlmChain\Bridge\OpenAI\GPT;
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
use PhpLlm\LlmChain\Bridge\OpenAI\TokenOutputProcessor;
use PhpLlm\LlmChain\Chain;
use PhpLlm\LlmChain\Model\Message\Message;
use PhpLlm\LlmChain\Model\Message\MessageBag;
use Symfony\Component\Dotenv\Dotenv;

require_once dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');

if (empty($_ENV['OPENAI_API_KEY'])) {
echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL;
exit(1);
}

$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
$llm = new GPT(GPT::GPT_4O_MINI, [
'temperature' => 0.5, // default options for the model
]);

$chain = new Chain($platform, $llm, outputProcessors: [new TokenOutputProcessor()]);
$messages = new MessageBag(
Message::forSystem('You are a pirate and you write funny.'),
Message::ofUser('What is the Symfony framework?'),
);
$response = $chain->call($messages, [
'max_tokens' => 500, // specific options just for this call
]);

$metadata = $response->getMetadata();

echo 'Utilized Tokens: '.$metadata['total_tokens'].PHP_EOL;
echo '-- Prompt Tokens: '.$metadata['prompt_tokens'].PHP_EOL;
echo '-- Completion Tokens: '.$metadata['completion_tokens'].PHP_EOL;
echo 'Remaining Tokens: '.$metadata['remaining_tokens'].PHP_EOL;
4 changes: 2 additions & 2 deletions src/Bridge/OpenAI/DallE/ImageResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE;

use PhpLlm\LlmChain\Model\Response\ResponseInterface;
use PhpLlm\LlmChain\Model\Response\BaseResponse;

class ImageResponse implements ResponseInterface
class ImageResponse extends BaseResponse
{
/** @var list<Base64Image|UrlImage> */
private readonly array $images;
Expand Down
42 changes: 42 additions & 0 deletions src/Bridge/OpenAI/TokenOutputProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\OpenAI;

use PhpLlm\LlmChain\Chain\Output;
use PhpLlm\LlmChain\Chain\OutputProcessor;
use PhpLlm\LlmChain\Model\Response\StreamResponse;

final class TokenOutputProcessor implements OutputProcessor
{
public function processOutput(Output $output): void
{
if ($output->response instanceof StreamResponse) {
// Streams have to be handled manually as the tokens are part of the streamed chunks
return;
}

$rawResponse = $output->response->getRawResponse();
if (null === $rawResponse) {
return;
}

$metadata = $output->response->getMetadata();

$metadata->add(
'remaining_tokens',
(int) $rawResponse->getHeaders(false)['x-ratelimit-remaining-tokens'][0],
);

$content = $rawResponse->toArray(false);

if (!\array_key_exists('usage', $content)) {
return;
}

$metadata->add('prompt_tokens', $content['usage']['prompt_tokens'] ?? null);
$metadata->add('completion_tokens', $content['usage']['completion_tokens'] ?? null);
$metadata->add('total_tokens', $content['usage']['total_tokens'] ?? null);
}
}
8 changes: 4 additions & 4 deletions src/Chain/Toolbox/StreamResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
namespace PhpLlm\LlmChain\Chain\Toolbox;

use PhpLlm\LlmChain\Model\Message\Message;
use PhpLlm\LlmChain\Model\Response\ResponseInterface;
use PhpLlm\LlmChain\Model\Response\BaseResponse;
use PhpLlm\LlmChain\Model\Response\ToolCallResponse;

final readonly class StreamResponse implements ResponseInterface
final class StreamResponse extends BaseResponse
{
public function __construct(
private \Generator $generator,
private \Closure $handleToolCallsCallback,
private readonly \Generator $generator,
private readonly \Closure $handleToolCallsCallback,
) {
}

Expand Down
21 changes: 21 additions & 0 deletions src/Model/Response/AsyncResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@

namespace PhpLlm\LlmChain\Model\Response;

use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet;
use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait;
use PhpLlm\LlmChain\Platform\ResponseConverter;
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;

final class AsyncResponse implements ResponseInterface
{
use MetadataAwareTrait;

private bool $isConverted = false;
private ResponseInterface $convertedResponse;

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

public function getRawResponse(): HttpResponse
{
return $this->response;
}

public function setRawResponse(HttpResponse $rawResponse): void
{
// Empty by design as the raw response is already set in the constructor and must only be set once
throw new RawResponseAlreadySet();
}

public function unwrap(): ResponseInterface
{
if (!$this->isConverted) {
$this->convertedResponse = $this->responseConverter->convert($this->response, $this->options);

if (null === $this->convertedResponse->getRawResponse()) {
// Fallback to set the raw response when it was not handled by the response converter itself
$this->convertedResponse->setRawResponse($this->response);
}

$this->isConverted = true;
}

Expand Down
13 changes: 13 additions & 0 deletions src/Model/Response/BaseResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Model\Response;

use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait;

abstract class BaseResponse implements ResponseInterface
{
use MetadataAwareTrait;
use RawResponseAwareTrait;
}
2 changes: 1 addition & 1 deletion src/Model/Response/BinaryResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace PhpLlm\LlmChain\Model\Response;

final class BinaryResponse implements ResponseInterface
final class BinaryResponse extends BaseResponse
{
public function __construct(
public string $data,
Expand Down
4 changes: 2 additions & 2 deletions src/Model/Response/ChoiceResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

use PhpLlm\LlmChain\Exception\InvalidArgumentException;

final readonly class ChoiceResponse implements ResponseInterface
final class ChoiceResponse extends BaseResponse
{
/**
* @var Choice[]
*/
private array $choices;
private readonly array $choices;

public function __construct(Choice ...$choices)
{
Expand Down
13 changes: 13 additions & 0 deletions src/Model/Response/Exception/RawResponseAlreadySet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Model\Response\Exception;

final class RawResponseAlreadySet extends \RuntimeException
{
public function __construct()
{
parent::__construct('The raw response was already set.');
}
}
99 changes: 99 additions & 0 deletions src/Model/Response/Metadata/Metadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Model\Response\Metadata;

/**
* @implements \IteratorAggregate<string, mixed>
* @implements \ArrayAccess<string, mixed>
*/
class Metadata implements \JsonSerializable, \Countable, \IteratorAggregate, \ArrayAccess
{
/**
* @var array<string, mixed>
*/
private array $metadata = [];

/**
* @param array<string, mixed> $metadata
*/
public function __construct(array $metadata = [])
{
$this->set($metadata);
}

/**
* @return array<string, mixed>
*/
public function all(): array
{
return $this->metadata;
}

/**
* @param array<string, mixed> $metadata
*/
public function set(array $metadata): void
{
$this->metadata = $metadata;
}

public function add(string $key, mixed $value): void
{
$this->metadata[$key] = $value;
}

public function has(string $key): bool
{
return \array_key_exists($key, $this->metadata);
}

public function get(string $key, mixed $default = null): mixed
{
return $this->metadata[$key] ?? $default;
}

public function remove(string $key): void
{
unset($this->metadata[$key]);
}

/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->all();
}

public function count(): int
{
return \count($this->metadata);
}

public function getIterator(): \Traversable
{
return new \ArrayIterator($this->metadata);
}

public function offsetExists(mixed $offset): bool
{
return $this->has((string) $offset);
}

public function offsetGet(mixed $offset): mixed
{
return $this->get((string) $offset);
}

public function offsetSet(mixed $offset, mixed $value): void
{
$this->add((string) $offset, $value);
}

public function offsetUnset(mixed $offset): void
{
$this->remove((string) $offset);
}
}
15 changes: 15 additions & 0 deletions src/Model/Response/Metadata/MetadataAwareTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Model\Response\Metadata;

trait MetadataAwareTrait
{
private ?Metadata $metadata = null;

public function getMetadata(): Metadata
{
return $this->metadata ??= new Metadata();
}
}
27 changes: 27 additions & 0 deletions src/Model/Response/RawResponseAwareTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Model\Response;

use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet;
use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse;

trait RawResponseAwareTrait
{
protected ?SymfonyHttpResponse $rawResponse = null;

public function setRawResponse(SymfonyHttpResponse $rawResponse): void
{
if (null !== $this->rawResponse) {
throw new RawResponseAlreadySet();
}

$this->rawResponse = $rawResponse;
}

public function getRawResponse(): ?SymfonyHttpResponse
{
return $this->rawResponse;
}
}
13 changes: 13 additions & 0 deletions src/Model/Response/ResponseInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,23 @@

namespace PhpLlm\LlmChain\Model\Response;

use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet;
use PhpLlm\LlmChain\Model\Response\Metadata\Metadata;
use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse;

interface ResponseInterface
{
/**
* @return string|iterable<mixed>|object|null
*/
public function getContent(): string|iterable|object|null;

public function getMetadata(): Metadata;

public function getRawResponse(): ?SymfonyHttpResponse;

/**
* @throws RawResponseAlreadySet if the response is tried to be set more than once
*/
public function setRawResponse(SymfonyHttpResponse $rawResponse): void;
}
4 changes: 2 additions & 2 deletions src/Model/Response/StreamResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

namespace PhpLlm\LlmChain\Model\Response;

final readonly class StreamResponse implements ResponseInterface
final class StreamResponse extends BaseResponse
{
public function __construct(
private \Generator $generator,
private readonly \Generator $generator,
) {
}

Expand Down
Loading