Skip to content

Commit 205be4a

Browse files
fix(OpenRouter): Fix token usage response (openai-php#560)
* chore: add fallback values to response token details for OpenRouter compatibility * test: diffrent OpenRouter model responses
1 parent 6f5861f commit 205be4a

File tree

6 files changed

+302
-14
lines changed

6 files changed

+302
-14
lines changed

src/Responses/Chat/CreateResponseUsageCompletionTokensDetails.php

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,42 @@ final class CreateResponseUsageCompletionTokensDetails
88
{
99
private function __construct(
1010
public readonly ?int $audioTokens,
11-
public readonly int $reasoningTokens,
12-
public readonly int $acceptedPredictionTokens,
13-
public readonly int $rejectedPredictionTokens
11+
public readonly ?int $reasoningTokens,
12+
public readonly ?int $acceptedPredictionTokens,
13+
public readonly ?int $rejectedPredictionTokens
1414
) {}
1515

1616
/**
17-
* @param array{audio_tokens?:int, reasoning_tokens:int, accepted_prediction_tokens:int, rejected_prediction_tokens:int} $attributes
17+
* @param array{audio_tokens?:int|null, reasoning_tokens?:int|null, accepted_prediction_tokens?:int|null, rejected_prediction_tokens?:int|null} $attributes
1818
*/
1919
public static function from(array $attributes): self
2020
{
2121
return new self(
2222
$attributes['audio_tokens'] ?? null,
23-
$attributes['reasoning_tokens'],
24-
$attributes['accepted_prediction_tokens'],
25-
$attributes['rejected_prediction_tokens'],
23+
$attributes['reasoning_tokens'] ?? null,
24+
$attributes['accepted_prediction_tokens'] ?? null,
25+
$attributes['rejected_prediction_tokens'] ?? null,
2626
);
2727
}
2828

2929
/**
30-
* @return array{audio_tokens?:int, reasoning_tokens:int, accepted_prediction_tokens:int, rejected_prediction_tokens:int}
30+
* @return array{audio_tokens?:int, reasoning_tokens?:int, accepted_prediction_tokens?:int, rejected_prediction_tokens?:int}
3131
*/
3232
public function toArray(): array
3333
{
34-
$result = [
35-
'reasoning_tokens' => $this->reasoningTokens,
36-
'accepted_prediction_tokens' => $this->acceptedPredictionTokens,
37-
'rejected_prediction_tokens' => $this->rejectedPredictionTokens,
38-
];
34+
$result = [];
35+
36+
if (! is_null($this->reasoningTokens)) {
37+
$result['reasoning_tokens'] = $this->reasoningTokens;
38+
}
39+
40+
if (! is_null($this->acceptedPredictionTokens)) {
41+
$result['accepted_prediction_tokens'] = $this->acceptedPredictionTokens;
42+
}
43+
44+
if (! is_null($this->rejectedPredictionTokens)) {
45+
$result['rejected_prediction_tokens'] = $this->rejectedPredictionTokens;
46+
}
3947

4048
if (! is_null($this->audioTokens)) {
4149
$result['audio_tokens'] = $this->audioTokens;

src/Responses/Chat/CreateResponseUsagePromptTokensDetails.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ private function __construct(
1212
) {}
1313

1414
/**
15-
* @param array{audio_tokens?:int, cached_tokens?: int} $attributes
15+
* @param array{audio_tokens?:int|null, cached_tokens?:int} $attributes
1616
*/
1717
public static function from(array $attributes): self
1818
{

tests/Fixtures/Chat.php

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,148 @@ function chatCompletion(): array
3737
];
3838
}
3939

40+
/**
41+
* @return array<string, mixed>
42+
*/
43+
function chatCompletionOpenRouter(): array
44+
{
45+
return [
46+
'id' => 'gen-123',
47+
'object' => 'chat.completion',
48+
'created' => 1744873707,
49+
'model' => 'mistral/ministral-8b',
50+
'choices' => [
51+
[
52+
'index' => 0,
53+
'message' => [
54+
'role' => 'assistant',
55+
'content' => 'Hello! How can I assist you today?',
56+
],
57+
'logprobs' => null,
58+
'finish_reason' => 'stop',
59+
],
60+
],
61+
'usage' => [
62+
'prompt_tokens' => 13,
63+
'completion_tokens' => 20,
64+
'total_tokens' => 33,
65+
],
66+
];
67+
}
68+
69+
/**
70+
* @return array<string, mixed>
71+
*/
72+
function chatCompletionOpenRouterOpenAI(): array
73+
{
74+
return [
75+
'id' => 'gen-123',
76+
'provider' => 'OpenAI',
77+
'model' => 'openai/gpt-4o-mini',
78+
'object' => 'chat.completion',
79+
'created' => 1744900650,
80+
'system_fingerprint' => 'fp_0392822090',
81+
'choices' => [
82+
[
83+
'index' => 0,
84+
'message' => [
85+
'role' => 'assistant',
86+
'content' => 'Hello! How can I assist you today?',
87+
'refusal' => null,
88+
'reasoning' => null,
89+
],
90+
'logprobs' => null,
91+
'finish_reason' => 'stop',
92+
'native_finish_reason' => 'stop',
93+
],
94+
],
95+
'usage' => [
96+
'prompt_tokens' => 21,
97+
'completion_tokens' => 21,
98+
'total_tokens' => 42,
99+
'prompt_tokens_details' => [
100+
'cached_tokens' => 0,
101+
],
102+
'completion_tokens_details' => [
103+
'reasoning_tokens' => 0,
104+
],
105+
],
106+
];
107+
}
108+
109+
/**
110+
* @return array<string, mixed>
111+
*/
112+
function chatCompletionOpenRouterGoogle(): array
113+
{
114+
return [
115+
'id' => 'gen-123',
116+
'provider' => 'Google',
117+
'model' => 'google/gemini-2.5-pro-preview-03-25',
118+
'object' => 'chat.completion',
119+
'created' => 1744910839,
120+
'choices' => [
121+
[
122+
'index' => 0,
123+
'message' => [
124+
'role' => 'assistant',
125+
'content' => 'Hello there! I\'m a large language model, trained by Google.',
126+
'refusal' => null,
127+
'reasoning' => null,
128+
],
129+
'logprobs' => null,
130+
'finish_reason' => 'stop',
131+
'native_finish_reason' => 'STOP',
132+
],
133+
],
134+
'usage' => [
135+
'prompt_tokens' => 10,
136+
'completion_tokens' => 138,
137+
'total_tokens' => 148,
138+
],
139+
];
140+
}
141+
142+
/**
143+
* @return array<string, mixed>
144+
*/
145+
function chatCompletionOpenRouterXAI(): array
146+
{
147+
return [
148+
'id' => 'gen-123',
149+
'provider' => 'xAI',
150+
'model' => 'x-ai/grok-3-mini-beta',
151+
'object' => 'chat.completion',
152+
'created' => 1744911228,
153+
'system_fingerprint' => 'fp_d133ae3397',
154+
'choices' => [
155+
[
156+
'index' => 0,
157+
'message' => [
158+
'role' => 'assistant',
159+
'content' => 'Hello! I\'m Grok, an AI model created by xAI.',
160+
'refusal' => null,
161+
'reasoning' => 'First, the user is asking "Hello! what model are you?"',
162+
],
163+
'logprobs' => null,
164+
'finish_reason' => 'stop',
165+
'native_finish_reason' => 'stop',
166+
],
167+
],
168+
'usage' => [
169+
'prompt_tokens' => 21,
170+
'completion_tokens' => 36,
171+
'total_tokens' => 392,
172+
'prompt_tokens_details' => [
173+
'cached_tokens' => 0,
174+
],
175+
'completion_tokens_details' => [
176+
'reasoning_tokens' => 335,
177+
],
178+
],
179+
];
180+
}
181+
40182
/**
41183
* @return array<string, mixed>
42184
*/

tests/Responses/Chat/CreateResponse.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,67 @@
203203
->function->name->toBe('get_current_weather')
204204
->function->arguments->toBe("{\n \"location\": \"Boston, MA\"\n}");
205205
});
206+
207+
test('from (OpenRouter)', function () {
208+
$completion = CreateResponse::from(chatCompletionOpenRouter(), meta());
209+
210+
expect($completion)
211+
->toBeInstanceOf(CreateResponse::class)
212+
->id->toBe('gen-123')
213+
->object->toBe('chat.completion')
214+
->created->toBe(1744873707)
215+
->model->toBe('mistral/ministral-8b')
216+
->systemFingerprint->toBeNull()
217+
->choices->toBeArray()->toHaveCount(1)
218+
->choices->each->toBeInstanceOf(CreateResponseChoice::class)
219+
->usage->toBeInstanceOf(CreateResponseUsage::class)
220+
->meta()->toBeInstanceOf(MetaInformation::class);
221+
});
222+
223+
test('from (OpenRouter OpenAI)', function () {
224+
$completion = CreateResponse::from(chatCompletionOpenRouterOpenAI(), meta());
225+
226+
expect($completion)
227+
->toBeInstanceOf(CreateResponse::class)
228+
->id->toBe('gen-123')
229+
->object->toBe('chat.completion')
230+
->created->toBe(1744900650)
231+
->model->toBe('openai/gpt-4o-mini')
232+
->systemFingerprint->toBe('fp_0392822090')
233+
->choices->toBeArray()->toHaveCount(1)
234+
->choices->each->toBeInstanceOf(CreateResponseChoice::class)
235+
->usage->toBeInstanceOf(CreateResponseUsage::class)
236+
->meta()->toBeInstanceOf(MetaInformation::class);
237+
});
238+
239+
test('from (OpenRouter Google)', function () {
240+
$completion = CreateResponse::from(chatCompletionOpenRouterGoogle(), meta());
241+
242+
expect($completion)
243+
->toBeInstanceOf(CreateResponse::class)
244+
->id->toBe('gen-123')
245+
->object->toBe('chat.completion')
246+
->created->toBe(1744910839)
247+
->model->toBe('google/gemini-2.5-pro-preview-03-25')
248+
->systemFingerprint->toBeNull()
249+
->choices->toBeArray()->toHaveCount(1)
250+
->choices->each->toBeInstanceOf(CreateResponseChoice::class)
251+
->usage->toBeInstanceOf(CreateResponseUsage::class)
252+
->meta()->toBeInstanceOf(MetaInformation::class);
253+
});
254+
255+
test('from (OpenRouter xAI)', function () {
256+
$completion = CreateResponse::from(chatCompletionOpenRouterXAI(), meta());
257+
258+
expect($completion)
259+
->toBeInstanceOf(CreateResponse::class)
260+
->id->toBe('gen-123')
261+
->object->toBe('chat.completion')
262+
->created->toBe(1744911228)
263+
->model->toBe('x-ai/grok-3-mini-beta')
264+
->systemFingerprint->toBe('fp_d133ae3397')
265+
->choices->toBeArray()->toHaveCount(1)
266+
->choices->each->toBeInstanceOf(CreateResponseChoice::class)
267+
->usage->toBeInstanceOf(CreateResponseUsage::class)
268+
->meta()->toBeInstanceOf(MetaInformation::class);
269+
});

tests/Responses/Chat/CreateResponseChoice.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@
4444
->finishReason->toBeNull();
4545
});
4646

47+
test('from OpenRouter OpenAI response', function () {
48+
$result = CreateResponseChoice::from(chatCompletionOpenRouterOpenAI()['choices'][0]);
49+
50+
expect($result)
51+
->index->toBe(0)
52+
->message->toBeInstanceOf(CreateResponseMessage::class)
53+
->logprobs->toBeNull()
54+
->finishReason->toBe('stop');
55+
});
56+
57+
test('from OpenRouter Google response', function () {
58+
$result = CreateResponseChoice::from(chatCompletionOpenRouterGoogle()['choices'][0]);
59+
60+
expect($result)
61+
->index->toBe(0)
62+
->message->toBeInstanceOf(CreateResponseMessage::class)
63+
->logprobs->toBeNull()
64+
->finishReason->toBe('stop');
65+
});
66+
67+
test('from OpenRouter xAI response', function () {
68+
$result = CreateResponseChoice::from(chatCompletionOpenRouterXAI()['choices'][0]);
69+
70+
expect($result)
71+
->index->toBe(0)
72+
->message->toBeInstanceOf(CreateResponseMessage::class)
73+
->logprobs->toBeNull()
74+
->finishReason->toBe('stop');
75+
});
76+
4777
test('to array', function () {
4878
$result = CreateResponseChoice::from(chatCompletion()['choices'][0]);
4979

tests/Responses/Chat/CreateResponseUsage.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,50 @@
1515
->completionTokensDetails->toBeInstanceOf(CreateResponseUsageCompletionTokensDetails::class);
1616
});
1717

18+
test('from (OpenRouter)', function () {
19+
$result = CreateResponseUsage::from(chatCompletionOpenRouter()['usage']);
20+
21+
expect($result)
22+
->promptTokens->toBe(13)
23+
->completionTokens->toBe(20)
24+
->totalTokens->toBe(33)
25+
->promptTokensDetails->toBeNull()
26+
->completionTokensDetails->toBeNull();
27+
});
28+
29+
test('from (OpenRouter OpenAI)', function () {
30+
$result = CreateResponseUsage::from(chatCompletionOpenRouterOpenAI()['usage']);
31+
32+
expect($result)
33+
->promptTokens->toBe(21)
34+
->completionTokens->toBe(21)
35+
->totalTokens->toBe(42)
36+
->promptTokensDetails->toBeInstanceOf(CreateResponseUsagePromptTokensDetails::class)
37+
->completionTokensDetails->toBeInstanceOf(CreateResponseUsageCompletionTokensDetails::class);
38+
});
39+
40+
test('from (OpenRouter Google)', function () {
41+
$result = CreateResponseUsage::from(chatCompletionOpenRouterGoogle()['usage']);
42+
43+
expect($result)
44+
->promptTokens->toBe(10)
45+
->completionTokens->toBe(138)
46+
->totalTokens->toBe(148)
47+
->promptTokensDetails->toBeNull()
48+
->completionTokensDetails->toBeNull();
49+
});
50+
51+
test('from (OpenRouter xAI)', function () {
52+
$result = CreateResponseUsage::from(chatCompletionOpenRouterXAI()['usage']);
53+
54+
expect($result)
55+
->promptTokens->toBe(21)
56+
->completionTokens->toBe(36)
57+
->totalTokens->toBe(392)
58+
->promptTokensDetails->toBeInstanceOf(CreateResponseUsagePromptTokensDetails::class)
59+
->completionTokensDetails->toBeInstanceOf(CreateResponseUsageCompletionTokensDetails::class);
60+
});
61+
1862
test('to array', function () {
1963
$result = CreateResponseUsage::from(chatCompletion()['usage']);
2064

0 commit comments

Comments
 (0)