feat: better cached token usage logging

This commit is contained in:
Alex committed 2026-09-03 09:01:28 +01:00
1 parent 96cad1f8e2
commit 6d0319fb63
12 files changed
+423 -7

No files matched your search

@@ -0,0 +1,35 @@
"""0031 token_usage cache tokens — persist the prompt-cache breakdown.
Providers report how much of each prompt was served from the prompt cache
(``cached_tokens``) and, on newer OpenAI-family models, how much was written
to it (``cache_write_tokens``). The LLM clients already parsed both and the
usage layer discarded them, so ``token_usage`` could not show a cache hit
rate. These two nullable columns carry the breakdown; NULL means the
provider reported nothing (distinct from 0). ``prompt_tokens`` keeps its
meaning as the provider's total. Idempotent both ways.
Revision ID: 0031_token_usage_cache_tokens
Revises: 0030_superseded_messages
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0031_token_usage_cache_tokens"
down_revision: Union[str, None] = "0030_superseded_messages"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE token_usage ADD COLUMN IF NOT EXISTS cached_tokens integer;")
op.execute(
"ALTER TABLE token_usage ADD COLUMN IF NOT EXISTS cache_write_tokens integer;"
)
def downgrade() -> None:
op.execute("ALTER TABLE token_usage DROP COLUMN IF EXISTS cache_write_tokens;")
op.execute("ALTER TABLE token_usage DROP COLUMN IF EXISTS cached_tokens;")
+6
View File
@@ -710,6 +710,7 @@ class BaseLLM(ABC):
completion_tokens,
latency_ms,
cached_tokens=None,
cache_write_tokens=None,
error=None,
):
# Non-streaming counterpart to ``_emit_stream_finished_log``. Paired
@@ -730,6 +731,8 @@ class BaseLLM(ABC):
}
if cached_tokens is not None:
extra["cached_tokens"] = int(cached_tokens)
if cache_write_tokens is not None:
extra["cache_write_tokens"] = int(cache_write_tokens)
if error is not None:
extra["error_class"] = type(error).__name__
logging.info("llm_gen_finished", extra=extra)
@@ -757,6 +760,7 @@ class BaseLLM(ABC):
completion_tokens,
latency_ms,
cached_tokens=None,
cache_write_tokens=None,
error=None,
):
# Paired with ``llm_stream_start`` so cost dashboards can sum tokens
@@ -774,6 +778,8 @@ class BaseLLM(ABC):
}
if cached_tokens is not None:
extra["cached_tokens"] = int(cached_tokens)
if cache_write_tokens is not None:
extra["cache_write_tokens"] = int(cache_write_tokens)
if error is not None:
extra["error_class"] = type(error).__name__
logging.info("llm_stream_finished", extra=extra)
+25 -4
View File
@@ -1307,17 +1307,36 @@ class OpenAILLM(BaseLLM):
output_details = getattr(usage, "completion_tokens_details", None)
try:
cached = int(getattr(input_details, "cached_tokens", 0) or 0)
written = int(getattr(input_details, "cache_write_tokens", 0) or 0)
reasoning = int(getattr(output_details, "reasoning_tokens", 0) or 0)
except (TypeError, ValueError):
cached = 0
written = 0
reasoning = 0
if cached:
result["prompt_tokens_details"] = {"cached_tokens": cached}
details = self._prompt_cache_details(cached, written)
if details:
result["prompt_tokens_details"] = details
if reasoning:
result["completion_tokens_details"] = {"reasoning_tokens": reasoning}
self._last_usage = result
self._last_usage_claimed = False
@staticmethod
def _prompt_cache_details(cached: int, written: int) -> dict:
"""Build the ``prompt_tokens_details`` breakdown from the two cache bins.
Only non-zero bins are included so a provider that never reports
caching produces no details (and no misleading zero downstream).
``cache_write_tokens`` is reported by newer OpenAI-family deployments
that charge for cache writes.
"""
details = {}
if cached:
details["cached_tokens"] = cached
if written:
details["cache_write_tokens"] = written
return details
@staticmethod
def _function_call_ids(response):
"""call_ids of every ``function_call`` item in a Responses output.
@@ -1357,9 +1376,11 @@ class OpenAILLM(BaseLLM):
"total_tokens": int(getattr(usage, "total_tokens", 0) or prompt + completion),
}
cached = int(getattr(input_details, "cached_tokens", 0) or 0)
written = int(getattr(input_details, "cache_write_tokens", 0) or 0)
reasoning = int(getattr(output_details, "reasoning_tokens", 0) or 0)
if cached:
result["prompt_tokens_details"] = {"cached_tokens": cached}
details = self._prompt_cache_details(cached, written)
if details:
result["prompt_tokens_details"] = details
if reasoning:
result["completion_tokens_details"] = {"reasoning_tokens": reasoning}
self._last_usage = result
+6
View File
@@ -235,6 +235,12 @@ token_usage_table = Table(
# Added in ``0015_token_usage_model_id``. Canonical model id (catalog
# name for built-ins, UUID for BYOM); NULL on un-backfilled rows.
Column("model_id", Text),
# Added in ``0031_token_usage_cache_tokens``. Prompt-cache breakdowns of
# ``prompt_tokens`` as reported by the provider (reads; and writes on
# newer OpenAI-family models). NULL = not reported, distinct from 0 = no
# cache activity, so hit-rate queries stay honest across providers.
Column("cached_tokens", Integer),
Column("cache_write_tokens", Integer),
)
user_logs_table = Table(
@@ -35,6 +35,8 @@ class TokenUsageRepository:
request_id: Optional[str] = None,
model_id: Optional[str] = None,
timestamp: Optional[datetime] = None,
cached_tokens: Optional[int] = None,
cache_write_tokens: Optional[int] = None,
) -> None:
# Attribution guard: the ``token_usage_attribution_chk`` CHECK
# constraint requires at least one of ``user_id`` / ``api_key``
@@ -60,12 +62,14 @@ class TokenUsageRepository:
INSERT INTO token_usage (
user_id, api_key, agent_id,
prompt_tokens, generated_tokens,
cached_tokens, cache_write_tokens,
source, request_id, model_id, timestamp
)
VALUES (
:user_id, :api_key,
CAST(:agent_id AS uuid),
:prompt_tokens, :generated_tokens,
:cached_tokens, :cache_write_tokens,
:source, :request_id, :model_id, COALESCE(:timestamp, now())
)
"""
@@ -76,6 +80,8 @@ class TokenUsageRepository:
"agent_id": agent_id_uuid,
"prompt_tokens": prompt_tokens,
"generated_tokens": generated_tokens,
"cached_tokens": cached_tokens,
"cache_write_tokens": cache_write_tokens,
"source": source,
"request_id": request_id,
"model_id": model_id,
+28 -1
View File
@@ -131,6 +131,11 @@ def _persist_call_usage(llm, call_usage):
agent_id=str(agent_id) if agent_id else None,
prompt_tokens=call_usage["prompt_tokens"],
generated_tokens=call_usage["generated_tokens"],
# Present only when the provider reported the breakdown;
# persisted as NULL otherwise so "unknown" never reads as
# "0% cache hits".
cached_tokens=call_usage.get("cached_tokens"),
cache_write_tokens=call_usage.get("cache_write_tokens"),
source=(
getattr(llm, "_token_usage_source", None) or "agent_stream"
),
@@ -151,6 +156,12 @@ def _prefer_provider_usage(llm: Any, call_usage: Dict[str, int]) -> Dict[str, in
``*_tokens_details`` breakdowns (``cached_tokens``,
``reasoning_tokens``) back out of these bins — that would break
parity with what providers bill.
The prompt-cache sub-bins ARE carried alongside (``cached_tokens``,
``cache_write_tokens``; Anthropic's ``cache_creation_tokens`` maps to
the latter) so persistence and the finish events can chart them. They
are added only when the provider reported them. The rest of
``call_usage`` (e.g. ``model``) is preserved rather than replaced.
"""
reported = getattr(llm, "_last_usage", None)
if not isinstance(reported, dict):
@@ -174,10 +185,22 @@ def _prefer_provider_usage(llm: Any, call_usage: Dict[str, int]) -> Dict[str, in
# Slotted/immutable LLM stand-ins can't record the claim; this
# call still gets the provider counts, which is correct for them.
pass
return {
merged = {
**call_usage,
"prompt_tokens": int(prompt or 0),
"generated_tokens": int(completion or 0),
}
details = reported.get("prompt_tokens_details")
if isinstance(details, dict):
cached = details.get("cached_tokens")
written = details.get("cache_write_tokens")
if written is None:
written = details.get("cache_creation_tokens")
if cached is not None:
merged["cached_tokens"] = int(cached or 0)
if written is not None:
merged["cache_write_tokens"] = int(written or 0)
return merged
def gen_token_usage(func):
@@ -224,6 +247,8 @@ def gen_token_usage(func):
prompt_tokens=call_usage["prompt_tokens"],
completion_tokens=call_usage["generated_tokens"],
latency_ms=int((time.monotonic() - started_at) * 1000),
cached_tokens=call_usage.get("cached_tokens"),
cache_write_tokens=call_usage.get("cache_write_tokens"),
error=error,
)
except Exception:
@@ -272,6 +297,8 @@ def stream_token_usage(func):
prompt_tokens=call_usage["prompt_tokens"],
completion_tokens=call_usage["generated_tokens"],
latency_ms=int((time.monotonic() - started_at) * 1000),
cached_tokens=call_usage.get("cached_tokens"),
cache_write_tokens=call_usage.get("cache_write_tokens"),
error=error,
)
except Exception: