Skip to content

Commit c7e094b

Browse files
Merge pull request #303 from askui/fix/openai-usage-cached-tokens
fix(openai): report cached tokens disjoint from input_tokens
2 parents d422162 + 630c000 commit c7e094b

2 files changed

Lines changed: 40 additions & 1 deletion

File tree

src/askui/models/openai/messages_api.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,15 @@ def _from_openai_response(response: ChatCompletion) -> MessageParam:
291291
cached_tokens: int | None = None
292292
if response.usage.prompt_tokens_details is not None:
293293
cached_tokens = response.usage.prompt_tokens_details.cached_tokens
294+
# OpenAI-style usage counts cached tokens INSIDE prompt_tokens, while
295+
# `UsageParam` consumers (statistics callback, reporting) expect
296+
# Anthropic-style disjoint fields — subtract so cached tokens are
297+
# never counted or billed twice.
298+
input_tokens = response.usage.prompt_tokens
299+
if isinstance(cached_tokens, int) and cached_tokens > 0:
300+
input_tokens = max(0, input_tokens - cached_tokens)
294301
usage = UsageParam(
295-
input_tokens=response.usage.prompt_tokens,
302+
input_tokens=input_tokens,
296303
output_tokens=response.usage.completion_tokens,
297304
cache_read_input_tokens=cached_tokens,
298305
)

tests/unit/models/openai/test_messages_api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,14 @@ def _make_completion(
4141
finish_reason: str = "stop",
4242
prompt_tokens: int = 10,
4343
completion_tokens: int = 20,
44+
cached_tokens: int | None = None,
4445
) -> ChatCompletion:
4546
"""Create a mock ChatCompletion response."""
47+
prompt_tokens_details = None
48+
if cached_tokens is not None:
49+
from openai.types.completion_usage import PromptTokensDetails
50+
51+
prompt_tokens_details = PromptTokensDetails(cached_tokens=cached_tokens)
4652
return ChatCompletion(
4753
id="chatcmpl-test",
4854
choices=[
@@ -63,6 +69,7 @@ def _make_completion(
6369
prompt_tokens=prompt_tokens,
6470
completion_tokens=completion_tokens,
6571
total_tokens=prompt_tokens + completion_tokens,
72+
prompt_tokens_details=prompt_tokens_details,
6673
),
6774
)
6875

@@ -456,6 +463,31 @@ def test_usage_captured(self) -> None:
456463
assert result.usage.input_tokens == 50
457464
assert result.usage.output_tokens == 100
458465

466+
def test_cached_tokens_are_subtracted_from_input(self) -> None:
467+
"""OpenAI reports cached tokens as a SUBSET of prompt_tokens;
468+
`UsageParam` consumers expect disjoint fields (Anthropic style).
469+
Without the subtraction, cached tokens are counted and billed twice
470+
(statistics callback, reporting)."""
471+
completion = _make_completion(
472+
content="ok",
473+
prompt_tokens=1000,
474+
completion_tokens=50,
475+
cached_tokens=400,
476+
)
477+
result = _from_openai_response(completion)
478+
assert result.usage is not None
479+
assert result.usage.input_tokens == 600 # 1000 - 400 cached
480+
assert result.usage.cache_read_input_tokens == 400
481+
assert result.usage.output_tokens == 50
482+
483+
def test_cached_tokens_never_drive_input_negative(self) -> None:
484+
completion = _make_completion(
485+
content="ok", prompt_tokens=100, completion_tokens=5, cached_tokens=150
486+
)
487+
result = _from_openai_response(completion)
488+
assert result.usage is not None
489+
assert result.usage.input_tokens == 0
490+
459491

460492
class TestOpenAIMessagesApi:
461493
def test_create_message_delegates_to_client(self) -> None:

0 commit comments

Comments
 (0)