Skip to content

Commit 0bfd020

Browse files
zachrobo1claudehassiebp
authored
fix(openai): correct token details field names for Response API usage (#1564)
fix(openai): correct token details field names for Response API usage parsing The _parse_usage() function checked for input_token_details and output_token_details (singular), but OpenAI's Response API returns input_tokens_details and output_tokens_details (plural). This caused nested token detail fields (cached_tokens, reasoning_tokens) to be silently ignored. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com>
1 parent 21d79fc commit 0bfd020

File tree

2 files changed

+102
-2
lines changed

2 files changed

+102
-2
lines changed

langfuse/openai.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,8 +539,8 @@ def _parse_usage(usage: Optional[Any] = None) -> Any:
539539
for tokens_details in [
540540
"prompt_tokens_details",
541541
"completion_tokens_details",
542-
"input_token_details",
543-
"output_token_details",
542+
"input_tokens_details",
543+
"output_tokens_details",
544544
]:
545545
if tokens_details in usage_dict and usage_dict[tokens_details] is not None:
546546
tokens_details_dict = (

tests/test_parse_usage.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from langfuse.openai import _parse_usage
2+
3+
4+
class TestParseUsageNone:
5+
def test_returns_none_for_none(self):
6+
assert _parse_usage(None) is None
7+
8+
9+
class TestParseUsageEmbedding:
10+
def test_embedding_usage_returns_input_only(self):
11+
usage = {"prompt_tokens": 5, "total_tokens": 5}
12+
result = _parse_usage(usage)
13+
assert result == {"input": 5}
14+
15+
16+
class TestParseUsageChatCompletions:
17+
def test_prompt_tokens_details_flattened(self):
18+
usage = {
19+
"prompt_tokens": 100,
20+
"completion_tokens": 50,
21+
"total_tokens": 150,
22+
"prompt_tokens_details": {"cached_tokens": 20, "audio_tokens": None},
23+
"completion_tokens_details": {"reasoning_tokens": 10},
24+
}
25+
result = _parse_usage(usage)
26+
assert result["prompt_tokens"] == 100
27+
assert result["completion_tokens"] == 50
28+
assert result["total_tokens"] == 150
29+
# None values are stripped, non-None kept
30+
assert result["prompt_tokens_details"] == {"cached_tokens": 20}
31+
assert result["completion_tokens_details"] == {"reasoning_tokens": 10}
32+
33+
def test_details_as_object(self):
34+
"""Token details may arrive as an object with __dict__ instead of a dict."""
35+
36+
class Details:
37+
def __init__(self):
38+
self.cached_tokens = 30
39+
self.audio_tokens = None
40+
41+
usage = {
42+
"prompt_tokens": 100,
43+
"completion_tokens": 50,
44+
"total_tokens": 150,
45+
"prompt_tokens_details": Details(),
46+
"completion_tokens_details": None,
47+
}
48+
result = _parse_usage(usage)
49+
assert result["prompt_tokens_details"] == {"cached_tokens": 30}
50+
assert result["completion_tokens_details"] is None
51+
52+
53+
class TestParseUsageResponseApi:
54+
"""Tests for OpenAI Response API usage format (input_tokens_details / output_tokens_details)."""
55+
56+
def test_input_and_output_tokens_details_flattened(self):
57+
usage = {
58+
"input_tokens": 200,
59+
"output_tokens": 80,
60+
"total_tokens": 280,
61+
"input_tokens_details": {"cached_tokens": 50},
62+
"output_tokens_details": {"reasoning_tokens": 30},
63+
}
64+
result = _parse_usage(usage)
65+
assert result["input_tokens"] == 200
66+
assert result["output_tokens"] == 80
67+
assert result["input_tokens_details"] == {"cached_tokens": 50}
68+
assert result["output_tokens_details"] == {"reasoning_tokens": 30}
69+
70+
def test_none_values_stripped_from_details(self):
71+
usage = {
72+
"input_tokens": 200,
73+
"output_tokens": 80,
74+
"total_tokens": 280,
75+
"input_tokens_details": {"cached_tokens": 50, "audio_tokens": None},
76+
"output_tokens_details": {"reasoning_tokens": None},
77+
}
78+
result = _parse_usage(usage)
79+
assert result["input_tokens_details"] == {"cached_tokens": 50}
80+
assert result["output_tokens_details"] == {}
81+
82+
def test_details_as_object(self):
83+
class InputDetails:
84+
def __init__(self):
85+
self.cached_tokens = 40
86+
87+
class OutputDetails:
88+
def __init__(self):
89+
self.reasoning_tokens = 15
90+
91+
usage = {
92+
"input_tokens": 100,
93+
"output_tokens": 50,
94+
"total_tokens": 150,
95+
"input_tokens_details": InputDetails(),
96+
"output_tokens_details": OutputDetails(),
97+
}
98+
result = _parse_usage(usage)
99+
assert result["input_tokens_details"] == {"cached_tokens": 40}
100+
assert result["output_tokens_details"] == {"reasoning_tokens": 15}

0 commit comments

Comments
 (0)