Skip to content

Commit 87bfaaf

Browse files
authored
Merge pull request #52 from slider23/feature/usage-calculate-cost
Calculating cost of request
2 parents b986efa + d221540 commit 87bfaaf

File tree

12 files changed

+577
-4
lines changed

12 files changed

+577
-4
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
title: 'Calculating request cost'
3+
docname: 'cost_calculation'
4+
---
5+
6+
## Overview
7+
8+
When using LLM APIs, tracking costs is essential for budgeting and optimization.
9+
Instructor supports automatic cost calculation based on token usage and pricing
10+
configuration in your LLM presets.
11+
12+
This example demonstrates how to:
13+
1. Configure pricing in LLM config ($/1M tokens)
14+
2. Calculate cost after a request using `Usage::calculateCost()`
15+
16+
```php
17+
<?php
18+
require 'examples/boot.php';
19+
20+
use Cognesy\Instructor\StructuredOutput;
21+
use Cognesy\Polyglot\Inference\Data\Pricing;
22+
use Cognesy\Polyglot\Inference\Data\Usage;
23+
24+
class User {
25+
public int $age;
26+
public string $name;
27+
}
28+
29+
// Helper to display cost breakdown
30+
function printCostBreakdown(Usage $usage, Pricing $pricing): void {
31+
echo "Token Usage:\n";
32+
echo " Input tokens: {$usage->inputTokens}\n";
33+
echo " Output tokens: {$usage->outputTokens}\n";
34+
echo " Cache read: {$usage->cacheReadTokens}\n";
35+
echo " Cache write: {$usage->cacheWriteTokens}\n";
36+
echo " Reasoning: {$usage->reasoningTokens}\n";
37+
echo "\nPricing ($/1M tokens):\n";
38+
echo " Input: \${$pricing->inputPerMToken}\n";
39+
echo " Output: \${$pricing->outputPerMToken}\n";
40+
echo " Cache read: \${$pricing->cacheReadPerMToken}\n";
41+
echo "\nTotal cost: \$" . number_format($usage->calculateCost($pricing), 6) . "\n";
42+
}
43+
44+
// OPTION 1: Configure pricing in LLM config preset
45+
// In your config/llm.php, add pricing to your preset:
46+
//
47+
// 'openrouter-claude' => [
48+
// 'driver' => 'openrouter',
49+
// 'model' => 'anthropic/claude-3.5-sonnet',
50+
// 'pricing' => [
51+
// 'input' => 3.0, // $3 per 1M input tokens
52+
// 'output' => 15.0, // $15 per 1M output tokens
53+
// // cacheRead, cacheWrite, reasoning default to input price if not set
54+
// ],
55+
// ],
56+
//
57+
// Then calculateCost() works automatically:
58+
//
59+
// $response = (new StructuredOutput)
60+
// ->using('openrouter-claude')
61+
// ->with(messages: $text, responseModel: User::class)
62+
// ->response();
63+
// $cost = $response->usage()->calculateCost();
64+
65+
// OPTION 2: Calculate cost manually with explicit Pricing
66+
echo "CALCULATING COST WITH EXPLICIT PRICING\n";
67+
echo str_repeat("=", 50) . "\n\n";
68+
69+
$text = "Jason is 25 years old and works as an engineer.";
70+
71+
$response = (new StructuredOutput)
72+
->with(
73+
messages: $text,
74+
responseModel: User::class,
75+
)->response();
76+
77+
// Define pricing for default model gpt-4.1-nano
78+
$pricing = Pricing::fromArray([
79+
'input' => 0.2, // $0.2 per 1M input tokens
80+
'output' => 0.8, // $0.8 per 1M output tokens
81+
'cacheRead' => 0.05, // $0.05 per 1M output tokens
82+
]);
83+
84+
echo "TEXT: $text\n\n";
85+
printCostBreakdown($response->usage(), $pricing);
86+
87+
// You can also attach pricing to usage for later calculation
88+
$usageWithPricing = $response->usage()->withPricing($pricing);
89+
echo "\nCost via stored pricing: \$" . number_format($usageWithPricing->calculateCost(), 6) . "\n";
90+
91+
92+
// OPTION 3: Compare costs across different models
93+
echo "\n\n" . str_repeat("=", 50) . "\n";
94+
echo "COST COMPARISON ACROSS MODELS\n";
95+
echo str_repeat("=", 50) . "\n\n";
96+
97+
$usage = $response->usage();
98+
99+
$models = [
100+
'GPT-4o' => ['input' => 2.50, 'output' => 10.0],
101+
'GPT-4o-mini' => ['input' => 0.15, 'output' => 0.60],
102+
'Claude 3.5 Sonnet' => ['input' => 3.0, 'output' => 15.0],
103+
'Claude 3.5 Haiku' => ['input' => 0.80, 'output' => 4.0],
104+
'Gemini 2.0 Flash' => ['input' => 0.10, 'output' => 0.40],
105+
];
106+
107+
echo "For {$usage->inputTokens} input + {$usage->outputTokens} output tokens:\n\n";
108+
foreach ($models as $model => $prices) {
109+
$pricing = Pricing::fromArray($prices);
110+
$cost = $usage->calculateCost($pricing);
111+
printf(" %-20s \$%.6f\n", $model, $cost);
112+
}
113+
?>
114+
```

packages/polyglot/src/Inference/Collections/InferenceAttemptList.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Cognesy\Polyglot\Inference\Data\InferenceAttempt;
66
use Cognesy\Polyglot\Inference\Data\Usage;
77
use Cognesy\Utils\Collection\ArrayList;
8+
use InvalidArgumentException;
89

910
class InferenceAttemptList
1011
{
@@ -67,6 +68,29 @@ public function withNewAttempt(InferenceAttempt $attempt): self {
6768
return new self($this->attempts->withAppended($attempt));
6869
}
6970

71+
/**
72+
* Updates an attempt with the given one (by matching ID).
73+
*
74+
* @throws InvalidArgumentException If no attempt with the given ID exists
75+
*/
76+
public function withUpdatedAttempt(InferenceAttempt $attempt): self {
77+
$items = $this->attempts->all();
78+
$updated = false;
79+
foreach ($items as $index => $existing) {
80+
if ($existing->id === $attempt->id) {
81+
$items[$index] = $attempt;
82+
$updated = true;
83+
break;
84+
}
85+
}
86+
if (!$updated) {
87+
throw new InvalidArgumentException(
88+
"Cannot update attempt: no attempt found with ID '{$attempt->id}'"
89+
);
90+
}
91+
return new self(ArrayList::fromArray($items));
92+
}
93+
7094
// SERIALIZATION /////////////////////////////////////////
7195

7296
public function toArray(): array {

packages/polyglot/src/Inference/Config/LLMConfig.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Cognesy\Polyglot\Inference\Config;
44

55
use Cognesy\Config\Exceptions\ConfigurationException;
6+
use Cognesy\Polyglot\Inference\Data\Pricing;
67
use InvalidArgumentException;
78
use Throwable;
89

@@ -26,6 +27,7 @@ public function __construct(
2627
public int $maxOutputLength = 4096,
2728
public string $driver = 'openai-compatible',
2829
public array $options = [],
30+
public array $pricing = [],
2931
) {
3032
$this->assertNoRetryPolicyInOptions($this->options);
3133
}
@@ -62,9 +64,14 @@ public function toArray() : array {
6264
'maxOutputLength' => $this->maxOutputLength,
6365
'driver' => $this->driver,
6466
'options' => $this->options,
67+
'pricing' => $this->pricing,
6568
];
6669
}
6770

71+
public function getPricing(): Pricing {
72+
return Pricing::fromArray($this->pricing);
73+
}
74+
6875
private function assertNoRetryPolicyInOptions(array $options) : void {
6976
if (!array_key_exists('retryPolicy', $options) && !array_key_exists('retry_policy', $options)) {
7077
return;

packages/polyglot/src/Inference/Data/InferenceExecution.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,21 @@ public function withFinalizedPartialResponse(): self {
271271
isFinalized: true,
272272
);
273273
}
274+
275+
/**
276+
* Updates the response in the current attempt (e.g., to attach pricing).
277+
*/
278+
public function withUpdatedResponse(InferenceResponse $response): self {
279+
if ($this->currentAttempt === null) {
280+
return $this;
281+
}
282+
$updatedAttempt = $this->currentAttempt->withResponse($response);
283+
return $this->with(
284+
attempts: $this->attempts->withUpdatedAttempt($updatedAttempt),
285+
currentAttempt: $updatedAttempt,
286+
);
287+
}
288+
274289
private function withFinalizedAttempt(InferenceAttempt $attempt): self {
275290
return $this->with(
276291
attempts: $this->attempts->withNewAttempt($attempt),

packages/polyglot/src/Inference/Data/InferenceResponse.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ public function withContent(string $content): self {
195195
return $this->with(content: $content);
196196
}
197197

198+
public function withPricing(Pricing $pricing): self {
199+
return $this->with(usage: $this->usage->withPricing($pricing));
200+
}
201+
198202
public function withReasoningContentFallbackFromContent(): self {
199203
if ($this->reasoningContent !== '') {
200204
return $this;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Cognesy\Polyglot\Inference\Data;
4+
5+
use InvalidArgumentException;
6+
7+
/**
8+
* Pricing configuration for LLM token costs.
9+
* All prices are in USD per 1,000,000 tokens (per 1M tokens).
10+
*/
11+
final class Pricing
12+
{
13+
public readonly float $inputPerMToken;
14+
public readonly float $outputPerMToken;
15+
public readonly float $cacheReadPerMToken;
16+
public readonly float $cacheWritePerMToken;
17+
public readonly float $reasoningPerMToken;
18+
19+
public function __construct(
20+
float $inputPerMToken = 0.0,
21+
float $outputPerMToken = 0.0,
22+
?float $cacheReadPerMToken = null,
23+
?float $cacheWritePerMToken = null,
24+
?float $reasoningPerMToken = null,
25+
) {
26+
$this->inputPerMToken = $inputPerMToken;
27+
$this->outputPerMToken = $outputPerMToken;
28+
// Default to input price if not specified
29+
$this->cacheReadPerMToken = $cacheReadPerMToken ?? $inputPerMToken;
30+
$this->cacheWritePerMToken = $cacheWritePerMToken ?? $inputPerMToken;
31+
$this->reasoningPerMToken = $reasoningPerMToken ?? $inputPerMToken;
32+
}
33+
34+
// CONSTRUCTORS ///////////////////////////////////////////////////////
35+
36+
public static function none(): self {
37+
return new self();
38+
}
39+
40+
/**
41+
* Create from array with prices in $/1M tokens.
42+
* Keys: input, output, cacheRead, cacheWrite, reasoning
43+
* If cacheRead, cacheWrite, or reasoning are not specified, they default to input price.
44+
*
45+
* @throws InvalidArgumentException If any pricing value is non-numeric or negative
46+
*/
47+
public static function fromArray(array $data): self {
48+
$fields = [
49+
'input' => ['input', 'inputPerMToken'],
50+
'output' => ['output', 'outputPerMToken'],
51+
'cacheRead' => ['cacheRead', 'cacheReadPerMToken'],
52+
'cacheWrite' => ['cacheWrite', 'cacheWritePerMToken'],
53+
'reasoning' => ['reasoning', 'reasoningPerMToken'],
54+
];
55+
56+
$validated = [];
57+
foreach ($fields as $name => $keys) {
58+
$value = $data[$keys[0]] ?? $data[$keys[1]] ?? null;
59+
if ($value === null) {
60+
$validated[$name] = null;
61+
continue;
62+
}
63+
if (!is_numeric($value)) {
64+
throw new InvalidArgumentException(
65+
"Pricing field '{$name}' must be numeric, got: " . gettype($value)
66+
);
67+
}
68+
$floatValue = (float) $value;
69+
if ($floatValue < 0) {
70+
throw new InvalidArgumentException(
71+
"Pricing field '{$name}' must be non-negative, got: {$floatValue}"
72+
);
73+
}
74+
$validated[$name] = $floatValue;
75+
}
76+
77+
$input = $validated['input'] ?? 0.0;
78+
79+
return new self(
80+
inputPerMToken: $input,
81+
outputPerMToken: $validated['output'] ?? 0.0,
82+
cacheReadPerMToken: $validated['cacheRead'],
83+
cacheWritePerMToken: $validated['cacheWrite'],
84+
reasoningPerMToken: $validated['reasoning'],
85+
);
86+
}
87+
88+
// ACCESSORS //////////////////////////////////////////////////////////
89+
90+
public function hasAnyPricing(): bool {
91+
return $this->inputPerMToken > 0.0
92+
|| $this->outputPerMToken > 0.0;
93+
}
94+
95+
// TRANSFORMERS ///////////////////////////////////////////////////////
96+
97+
public function toArray(): array {
98+
return [
99+
'input' => $this->inputPerMToken,
100+
'output' => $this->outputPerMToken,
101+
'cacheRead' => $this->cacheReadPerMToken,
102+
'cacheWrite' => $this->cacheWritePerMToken,
103+
'reasoning' => $this->reasoningPerMToken,
104+
];
105+
}
106+
}

0 commit comments

Comments
 (0)