Skip to content

Commit 5b9008f

Browse files
authored
Merge pull request #139 from Takki-cd/feat/add-cache-tokens
feat: add fine-grained cache token tracking across 5 LLM providers
2 parents 8abf0fd + 3fa4ceb commit 5b9008f

3 files changed

Lines changed: 177 additions & 43 deletions

File tree

oxygent/schemas/usage.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from enum import Enum
44

5-
from pydantic import BaseModel, Field, computed_field
5+
from pydantic import BaseModel, Field, model_validator
66

77

88
class EstimationMethod(str, Enum):
@@ -19,23 +19,29 @@ class TokenUsage(BaseModel):
1919
Attributes:
2020
input_tokens: Number of input (prompt) tokens.
2121
output_tokens: Number of output (completion) tokens.
22-
cached_tokens: Number of input tokens served from cache (e.g. OpenAI
23-
prompt_tokens_details.cached_tokens, Anthropic cache_read_input_tokens).
24-
reasoning_tokens: Number of tokens used for reasoning/thinking
25-
(e.g. OpenAI completion_tokens_details.reasoning_tokens).
22+
cached_input_tokens: Number of input tokens served from cache read hit.
23+
(e.g. OpenAI prompt_tokens_details.cached_tokens,
24+
Anthropic cache_read_input_tokens, DeepSeek prompt_cache_hit_tokens,
25+
Gemini cachedContentTokenCount).
26+
cache_creation_input_tokens: Number of input tokens written to cache.
27+
(e.g. Anthropic cache_creation_input_tokens).
28+
reasoning_tokens: Number of tokens used for reasoning/thinking.
2629
model_name: Name of the LLM model used.
2730
estimation_method: Method used for token counting.
2831
"""
2932

3033
input_tokens: int = Field(default=0, ge=0)
3134
output_tokens: int = Field(default=0, ge=0)
32-
cached_tokens: int = Field(default=0, ge=0)
35+
total_tokens: int = Field(default=0, ge=0)
36+
cached_input_tokens: int = Field(default=0, ge=0)
37+
cache_creation_input_tokens: int = Field(default=0, ge=0)
3338
reasoning_tokens: int = Field(default=0, ge=0)
3439
model_name: str = ""
3540
estimation_method: EstimationMethod = EstimationMethod.EXACT
3641

37-
@computed_field
38-
@property
39-
def total_tokens(self) -> int:
40-
"""Total tokens (input + output), always computed."""
41-
return self.input_tokens + self.output_tokens
42+
@model_validator(mode="after")
43+
def _compute_total(self) -> "TokenUsage":
44+
"""Use API-provided total_tokens when available, fallback to input+output."""
45+
if self.total_tokens == 0:
46+
self.total_tokens = self.input_tokens + self.output_tokens
47+
return self

oxygent/utils/token_utils.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,35 @@ def build_token_usage(
8080
u = _to_dict(usage_data)
8181

8282
# --- input / output tokens ---
83-
# OpenAI-family: prompt_tokens; Gemini native: promptTokenCount
84-
input_tokens = u.get("prompt_tokens") or u.get("promptTokenCount", 0)
85-
output_tokens = u.get("completion_tokens") or u.get("candidatesTokenCount", 0)
83+
# OpenAI: prompt_tokens; Gemini native: promptTokenCount; Anthropic: input_tokens
84+
input_tokens = u.get("prompt_tokens") or u.get("promptTokenCount") or u.get("input_tokens", 0)
85+
output_tokens = u.get("completion_tokens") or u.get("candidatesTokenCount") or u.get("output_tokens", 0)
86+
87+
# --- total tokens (API-provided) ---
88+
# Gemini: total = prompt + completion + thoughts (thoughts NOT included in completion)
89+
# OpenAI: total = prompt + completion (reasoning already included in completion)
90+
total_tokens = u.get("total_tokens") or 0
8691

8792
# --- cached tokens ---
88-
cached_tokens = 0
93+
# cached_input_tokens: cache read hits (chain fallback)
94+
# 1. prompt_tokens_details.cached_tokens -> OpenAI / Doubao / Alibaba Cloud
95+
# 2. top-level cache_read_input_tokens -> Anthropic
96+
# 3. top-level prompt_cache_hit_tokens -> DeepSeek
97+
# 4. top-level cachedContentTokenCount -> Gemini native
98+
cached_input_tokens = 0
8999
prompt_details = u.get("prompt_tokens_details")
90100
if prompt_details:
91101
d = _to_dict(prompt_details)
92-
cached_tokens = d.get("cached_tokens") or d.get("cache_read_input_tokens", 0)
102+
cached_input_tokens = d.get("cached_tokens") or 0
103+
if not cached_input_tokens:
104+
cached_input_tokens = u.get("cache_read_input_tokens") or 0
105+
if not cached_input_tokens:
106+
cached_input_tokens = u.get("prompt_cache_hit_tokens") or 0
107+
if not cached_input_tokens:
108+
cached_input_tokens = u.get("cachedContentTokenCount") or 0
109+
110+
# cache_creation_input_tokens: cache writes (Anthropic / Alibaba Cloud)
111+
cache_creation_input_tokens = u.get("cache_creation_input_tokens") or 0
93112

94113
# --- reasoning / thinking tokens ---
95114
# Priority: completion_tokens_details.reasoning_tokens (OpenAI/Doubao)
@@ -110,7 +129,9 @@ def build_token_usage(
110129
return TokenUsage(
111130
input_tokens=input_tokens,
112131
output_tokens=output_tokens,
113-
cached_tokens=cached_tokens,
132+
total_tokens=total_tokens,
133+
cached_input_tokens=cached_input_tokens,
134+
cache_creation_input_tokens=cache_creation_input_tokens,
114135
reasoning_tokens=reasoning_tokens,
115136
model_name=model_name,
116137
estimation_method=EstimationMethod.EXACT,
@@ -138,7 +159,8 @@ def aggregate_token_usage(oxy_request: Any, usage: TokenUsage) -> None:
138159
"total_tokens": 0,
139160
"input_tokens": 0,
140161
"output_tokens": 0,
141-
"cached_tokens": 0,
162+
"cached_input_tokens": 0,
163+
"cache_creation_input_tokens": 0,
142164
"reasoning_tokens": 0,
143165
"request_count": 0,
144166
"by_model": {},
@@ -148,7 +170,8 @@ def aggregate_token_usage(oxy_request: Any, usage: TokenUsage) -> None:
148170
tm["total_tokens"] += usage.total_tokens
149171
tm["input_tokens"] += usage.input_tokens
150172
tm["output_tokens"] += usage.output_tokens
151-
tm["cached_tokens"] += usage.cached_tokens
173+
tm["cached_input_tokens"] += usage.cached_input_tokens
174+
tm["cache_creation_input_tokens"] += usage.cache_creation_input_tokens
152175
tm["reasoning_tokens"] += usage.reasoning_tokens
153176
tm["request_count"] += 1
154177

@@ -158,7 +181,8 @@ def aggregate_token_usage(oxy_request: Any, usage: TokenUsage) -> None:
158181
"total_tokens": 0,
159182
"input_tokens": 0,
160183
"output_tokens": 0,
161-
"cached_tokens": 0,
184+
"cached_input_tokens": 0,
185+
"cache_creation_input_tokens": 0,
162186
"reasoning_tokens": 0,
163187
"request_count": 0,
164188
}
@@ -167,7 +191,8 @@ def aggregate_token_usage(oxy_request: Any, usage: TokenUsage) -> None:
167191
mm["total_tokens"] += usage.total_tokens
168192
mm["input_tokens"] += usage.input_tokens
169193
mm["output_tokens"] += usage.output_tokens
170-
mm["cached_tokens"] += usage.cached_tokens
194+
mm["cached_input_tokens"] += usage.cached_input_tokens
195+
mm["cache_creation_input_tokens"] += usage.cache_creation_input_tokens
171196
mm["reasoning_tokens"] += usage.reasoning_tokens
172197
mm["request_count"] += 1
173198

tests/unittest/test_token_metering.py

Lines changed: 125 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,24 @@ def test_gemini_openai_compat_format(self):
169169
assert usage.output_tokens == 9
170170
assert usage.reasoning_tokens == 26
171171

172+
def test_gemini_total_tokens_includes_thinking(self):
173+
"""Gemini total_tokens includes thoughts_tokens (differs from input+output)."""
174+
usage = build_token_usage(
175+
{
176+
"prompt_tokens": 7,
177+
"completion_tokens": 10,
178+
"thoughts_tokens": 21,
179+
"total_tokens": 38,
180+
},
181+
[],
182+
"",
183+
"gemini-2.5-flash",
184+
)
185+
assert usage.input_tokens == 7
186+
assert usage.output_tokens == 10
187+
assert usage.reasoning_tokens == 21
188+
assert usage.total_tokens == 38 # API total includes thinking
189+
172190
def test_gemini_native_format(self):
173191
"""Gemini native: camelCase fields from usageMetadata."""
174192
usage = build_token_usage(
@@ -203,7 +221,7 @@ def test_doubao_format(self):
203221
assert usage.output_tokens == 27
204222

205223
def test_cached_tokens(self):
206-
"""Extract cached_tokens from prompt_tokens_details."""
224+
"""Extract cached_input_tokens from prompt_tokens_details."""
207225
usage = build_token_usage(
208226
{
209227
"prompt_tokens": 500,
@@ -214,7 +232,59 @@ def test_cached_tokens(self):
214232
"",
215233
"gpt-4o",
216234
)
217-
assert usage.cached_tokens == 300
235+
assert usage.cached_input_tokens == 300
236+
assert usage.cache_creation_input_tokens == 0
237+
238+
def test_anthropic_format(self):
239+
"""Anthropic: top-level cache_read/cache_creation, no prompt_tokens_details."""
240+
usage = build_token_usage(
241+
{
242+
"input_tokens": 50,
243+
"output_tokens": 100,
244+
"cache_read_input_tokens": 100000,
245+
"cache_creation_input_tokens": 248,
246+
},
247+
[],
248+
"",
249+
"claude-sonnet-4-20250514",
250+
)
251+
assert usage.input_tokens == 50
252+
assert usage.output_tokens == 100
253+
assert usage.cached_input_tokens == 100000
254+
assert usage.cache_creation_input_tokens == 248
255+
256+
def test_deepseek_cache_format(self):
257+
"""DeepSeek: top-level prompt_cache_hit_tokens."""
258+
usage = build_token_usage(
259+
{
260+
"prompt_tokens": 405,
261+
"total_tokens": 415,
262+
"completion_tokens": 10,
263+
"prompt_tokens_details": None,
264+
"prompt_cache_hit_tokens": 380,
265+
"reasoning_tokens": 42,
266+
},
267+
[],
268+
"",
269+
"deepseek-r1",
270+
)
271+
assert usage.cached_input_tokens == 380
272+
assert usage.cache_creation_input_tokens == 0
273+
274+
def test_gemini_native_cache_format(self):
275+
"""Gemini native: cachedContentTokenCount。"""
276+
usage = build_token_usage(
277+
{
278+
"promptTokenCount": 200,
279+
"candidatesTokenCount": 15,
280+
"thoughtsTokenCount": 30,
281+
"cachedContentTokenCount": 150,
282+
},
283+
[],
284+
"",
285+
"gemini-2.5-flash",
286+
)
287+
assert usage.cached_input_tokens == 150
218288

219289
def test_fallback_to_estimator(self):
220290
usage = build_token_usage(
@@ -284,30 +354,38 @@ def test_multi_model_accumulation(self):
284354

285355
def test_cached_and_reasoning_accumulation(self):
286356
req = MagicMock(shared_data={})
287-
aggregate_token_usage(
288-
req,
289-
TokenUsage(
290-
input_tokens=100,
291-
output_tokens=50,
292-
cached_tokens=60,
293-
reasoning_tokens=20,
294-
model_name="deepseek-r1",
295-
),
357+
u1 = build_token_usage(
358+
{
359+
"prompt_tokens": 100,
360+
"completion_tokens": 50,
361+
"prompt_tokens_details": {"cached_tokens": 60},
362+
"cache_creation_input_tokens": 10,
363+
"reasoning_tokens": 20,
364+
},
365+
[],
366+
"",
367+
"deepseek-r1",
296368
)
297-
aggregate_token_usage(
298-
req,
299-
TokenUsage(
300-
input_tokens=80,
301-
output_tokens=30,
302-
cached_tokens=40,
303-
reasoning_tokens=10,
304-
model_name="deepseek-r1",
305-
),
369+
u2 = build_token_usage(
370+
{
371+
"prompt_tokens": 80,
372+
"completion_tokens": 30,
373+
"prompt_tokens_details": {"cached_tokens": 40},
374+
"cache_creation_input_tokens": 5,
375+
"reasoning_tokens": 10,
376+
},
377+
[],
378+
"",
379+
"deepseek-r1",
306380
)
381+
aggregate_token_usage(req, u1)
382+
aggregate_token_usage(req, u2)
307383
m = req.shared_data["_metrics"]["token_usage"]
308-
assert m["cached_tokens"] == 100
384+
assert m["cached_input_tokens"] == 100
385+
assert m["cache_creation_input_tokens"] == 15
309386
assert m["reasoning_tokens"] == 30
310-
assert m["by_model"]["deepseek-r1"]["cached_tokens"] == 100
387+
assert m["by_model"]["deepseek-r1"]["cached_input_tokens"] == 100
388+
assert m["by_model"]["deepseek-r1"]["cache_creation_input_tokens"] == 15
311389
assert m["by_model"]["deepseek-r1"]["reasoning_tokens"] == 30
312390

313391
def test_disabled(self):
@@ -347,3 +425,28 @@ async def test_aggregates_token_usage(self):
347425
assert isinstance(resp.extra["usage"], dict)
348426
assert resp.extra["usage"]["total_tokens"] == 15
349427
assert req.shared_data["_metrics"]["token_usage"]["total_tokens"] == 15
428+
429+
430+
# ---------------------------------------------------------------------------
431+
# model_dump includes new cache fields
432+
# ---------------------------------------------------------------------------
433+
434+
435+
class TestModelDump:
436+
def test_includes_cached_input_tokens(self):
437+
usage = TokenUsage(
438+
input_tokens=100,
439+
output_tokens=50,
440+
cached_input_tokens=80,
441+
cache_creation_input_tokens=10,
442+
model_name="gpt-4o",
443+
)
444+
data = usage.model_dump()
445+
assert data["cached_input_tokens"] == 80
446+
assert data["cache_creation_input_tokens"] == 10
447+
448+
def test_defaults_zero(self):
449+
usage = TokenUsage(input_tokens=100, output_tokens=50)
450+
data = usage.model_dump()
451+
assert data["cached_input_tokens"] == 0
452+
assert data["cache_creation_input_tokens"] == 0

0 commit comments

Comments
 (0)