Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/openai-agents-cache-reasoning-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Report cached input and reasoning tokens from OpenAI Agents generation and response spans, which nest them under the usage details fields.
64 changes: 59 additions & 5 deletions posthog/ai/openai_agents/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ def _parse_iso_timestamp(iso_str: Optional[str]) -> Optional[float]:
return None


def _usage_detail(usage: Any, details_keys: tuple, key: str) -> Optional[int]:
"""Read a token count nested under a usage details field.

Agents span usage is a dict (generation spans) or an OpenAI usage object
(response spans), with details under ``input_tokens_details``/
``output_tokens_details`` (Responses) or ``prompt_tokens_details``/
``completion_tokens_details`` (Chat Completions).
"""

def get(container: Any, name: str) -> Any:
if isinstance(container, dict):
return container.get(name)
return getattr(container, name, None)

for details_key in details_keys:
value = get(get(usage, details_key), key)
if isinstance(value, int):
return value
return None


class PostHogTracingProcessor(TracingProcessor):
"""
A tracing processor that sends OpenAI Agents SDK traces to PostHog.
Expand Down Expand Up @@ -528,11 +549,30 @@ def _handle_generation_span(
),
}

# Add optional token fields if present
if usage.get("reasoning_tokens"):
properties["$ai_reasoning_tokens"] = usage["reasoning_tokens"]
if usage.get("cache_read_input_tokens"):
properties["$ai_cache_read_input_tokens"] = usage["cache_read_input_tokens"]
# Add optional token fields if present. The Agents SDK nests these under
# the usage details fields; flat keys are kept for custom models.
reasoning_tokens = usage.get("reasoning_tokens") or _usage_detail(
usage,
("output_tokens_details", "completion_tokens_details"),
"reasoning_tokens",
)
if reasoning_tokens:
properties["$ai_reasoning_tokens"] = reasoning_tokens
cache_read_tokens = usage.get("cache_read_input_tokens")
if not cache_read_tokens:
cache_read_tokens = _usage_detail(
usage,
("input_tokens_details", "prompt_tokens_details"),
"cached_tokens",
)
if cache_read_tokens:
# OpenAI-shaped usage counts cached tokens inside input_tokens.
# Say so explicitly: the cost backend otherwise infers exclusive
# reporting from Claude model names served through compatible
# endpoints. A caller-supplied value still takes precedence.
properties["$ai_cache_reporting_exclusive"] = False
if cache_read_tokens:
properties["$ai_cache_read_input_tokens"] = cache_read_tokens

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1]Declare inclusive accounting when forwarding compatible-provider cache usage

IMPORTANT Β· REQUIRED Β· Amplified pre-existing defect
Location: posthog/ai/openai_agents/processor.py:561–565, RIGHT.

A cached Claude request through an OpenAI-compatible endpoint can now make PostHog's estimated input cost increase instead of decrease. The newly emitted cached tokens are already included in the input total, but the backend treats Claude-model usage as exclusive unless $ai_cache_reporting_exclusive explicitly says otherwise.

An ordinary supported caller is:

import os
from agents import Agent, Runner
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from openai import AsyncOpenAI
from posthog.ai.openai_agents import instrument

instrument(posthog_client)
provider = AsyncOpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ["OPENROUTER_API_KEY"],
)
agent = Agent(name="Assistant", model=OpenAIChatCompletionsModel(
    model="anthropic/claude-sonnet-4.6", openai_client=provider,
))
await Runner.run(agent, "...")

Agents documents compatible-provider configuration. OpenRouter documents inclusive prompt usage, with 10339 prompt tokens including 10318 cached tokens, 60 completion tokens and no cache writes. Agents normalizes these details without changing the total.

The pinned backend calculator infers exclusive accounting from the Anthropic/Claude model name despite $ai_provider: "openai". Explicit false overrides it. Model/provider lookup resolves this model to catalog rates of $3/M ordinary input and $0.30/M cached input.

Using the same documented-shaped synthetic response through real Runner.run, instrument, SDK queue and HTTP delivery on both revisions, then executing that backend calculator on the captured properties:

Snapshot / interpretation Estimated input cost
Merge base: cache field absent $0.031017
Head: cache field present, convention absent $0.0341124
Correct inclusive interpretation $0.0031584

The correct calculation is (10339 βˆ’ 10318) Γ— 0.000003 + 10318 Γ— 0.0000003.

Base was already wrong. This patch amplifies that defect by another $0.0030954 per fixture request, approximately 10% above the previous estimate. It affects analytics estimates, not the provider's actual bill. The demonstrated trigger is cached, inclusive Chat Completions usage with a model matched by the backend's Claude heuristic and no explicit convention/cost override; ordinary GPT requests are unaffected.

Consider declaring $ai_cache_reporting_exclusive: false for recognized inclusive nested-usage paths, preserving custom flat-key conventions and caller overrides. The Node Agents processor already declares this convention for Chat Completions and Responses. Add a compatible-Claude regression alongside the GPT case; the corresponding Responses mapping should remain consistent where its provider contract is inclusive.

Executed regression:

from unittest.mock import MagicMock
from agents import set_trace_processors
from agents.tracing import generation_span, trace
from posthog.ai.openai_agents import instrument


def test_cached_compatible_claude_usage_is_declared_inclusive():
    client = MagicMock(privacy_mode=False, enable_full_ai_capture=False)
    set_trace_processors([])
    processor = instrument(client, distinct_id="review-user")
    try:
        with trace("compatible-model"):
            with generation_span(
                model="anthropic/claude-sonnet-4.6",
                usage={
                    "input_tokens": 10339,
                    "output_tokens": 60,
                    "input_tokens_details": {"cached_tokens": 10318},
                },
            ):
                pass
        properties = next(
            call.kwargs["properties"] for call in client.capture.call_args_list
            if call.kwargs["event"] == "$ai_generation"
        )
        assert properties["$ai_cache_read_input_tokens"] == 10318
        assert properties.get("$ai_cache_reporting_exclusive") is False
    finally:
        set_trace_processors([])
        processor.shutdown()

Executed under identical Python/provider dependencies with sandboxed PYTHONPATH=<snapshot> python -m pytest -p pytest_timeout -c /dev/null --rootdir=<runtime> --confcutdir=<runtime> test_cache_reporting.py --timeout=30 -q -p no:cacheprovider:

  • Head: exit 1 at the convention assertion: None is False.
  • Base: exit 1 earlier because the cache field is absent. This alone is not a passing-base/failing-head proof; the captured-payload/calculator comparison establishes amplification.
  • Calculator: node cost-check.cjs exits 0 confirming amplification and the inclusive control; node cost-check.cjs --assert-correct exits 1 with head cost 0.0341124, expected 0.0031584. It executes pinned calculator source with TypeScript erasure/module-linkage substitution and real js-big-decimal; only logger/metrics are stubbed, not arithmetic. No full ingestion stack was run.

A verified supported mitigation is instrument(posthog_client, properties={"$ai_cache_reporting_exclusive": False}). It is processor-wide, not automatic per-model handling, and can be inappropriate for mixed custom-model conventions. No fix was applied or tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in 4de76ae.

  • When the cache count comes from the nested OpenAI-shaped details, the processor now also sends $ai_cache_reporting_exclusive: False, on both generation spans (input_tokens_details / prompt_tokens_details) and response spans. This matches the Node processor.
  • Flat cache_read_input_tokens from custom models gets no flag, so their own convention is kept.
  • A caller-supplied $ai_cache_reporting_exclusive (via instrument(..., properties=...)) still wins, because processor-wide properties are merged last.

Tests:

  • Added your test_cached_compatible_claude_usage_is_declared_inclusive as written. It fails on e424062 (None is False) and passes now.
  • Added test_flat_cache_keys_keep_their_own_convention and test_caller_cache_reporting_convention_takes_precedence.
  • The two GPT tests now also assert the flag.

Running real Runner.run with OpenAIChatCompletionsModel("anthropic/claude-sonnet-4.6") and your OpenRouter-shaped usage now captures $ai_input_tokens: 10339, $ai_cache_read_input_tokens: 10318 and $ai_cache_reporting_exclusive: false. That's the inclusive interpretation behind your $0.0031584.

Full suite: 5136 passed, 16 skipped. Ruff, mypy and the public API check are clean.

if usage.get("cache_creation_input_tokens"):
properties["$ai_cache_creation_input_tokens"] = usage[
"cache_creation_input_tokens"
Expand Down Expand Up @@ -708,6 +748,20 @@ def _handle_response_span(
if total_cost_usd is not None:
properties["$ai_total_cost_usd"] = total_cost_usd

if usage:
reasoning_tokens = _usage_detail(
usage, ("output_tokens_details",), "reasoning_tokens"
)
if reasoning_tokens:
properties["$ai_reasoning_tokens"] = reasoning_tokens
cache_read_tokens = _usage_detail(
usage, ("input_tokens_details",), "cached_tokens"
)
if cache_read_tokens:
properties["$ai_cache_read_input_tokens"] = cache_read_tokens
# Responses input_tokens include cached tokens.
properties["$ai_cache_reporting_exclusive"] = False

# Extract output content from response
if response:
output_items = getattr(response, "output", None)
Expand Down
157 changes: 157 additions & 0 deletions posthog/test/ai/openai_agents/test_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,163 @@ def test_generation_span_with_reasoning_tokens(
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_reasoning_tokens"] == 400

def test_generation_span_reads_nested_cache_and_reasoning_tokens(
self, processor, mock_client, mock_span
):
"""The Agents SDK nests cached and reasoning tokens under the usage
details fields of generation span usage."""
from agents.usage import Usage, model_usage_to_span_usage
from openai.types.responses.response_usage import (
InputTokensDetails,
OutputTokensDetails,
)

usage = Usage(
requests=1,
input_tokens=100,
output_tokens=50,
total_tokens=150,
input_tokens_details=InputTokensDetails(
cached_tokens=60, cache_write_tokens=0
),
output_tokens_details=OutputTokensDetails(reasoning_tokens=40),
)
mock_span.span_data = GenerationSpanData(
model="gpt-5-mini", usage=model_usage_to_span_usage(usage)
)

processor.on_span_start(mock_span)
processor.on_span_end(mock_span)

props = mock_client.capture.call_args[1]["properties"]
assert props["$ai_input_tokens"] == 100
assert props["$ai_cache_read_input_tokens"] == 60
assert props["$ai_output_tokens"] == 50
assert props["$ai_reasoning_tokens"] == 40
assert props["$ai_cache_reporting_exclusive"] is False

def test_response_span_reads_cache_and_reasoning_tokens(
self, processor, mock_client, mock_span
):
"""Response spans carry the OpenAI Responses usage object, with cached
and reasoning tokens in its details fields."""
from openai.types.responses import Response

response = Response.model_validate(
{
"id": "resp_123",
"object": "response",
"created_at": 0,
"model": "gpt-5-mini",
"status": "completed",
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"output": [],
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
"input_tokens_details": {
"cached_tokens": 60,
"cache_write_tokens": 0,
},
"output_tokens_details": {"reasoning_tokens": 40},
},
}
)
mock_span.span_data = ResponseSpanData(response=response, input="Hi")

processor.on_span_start(mock_span)
processor.on_span_end(mock_span)

props = mock_client.capture.call_args[1]["properties"]
assert props["$ai_input_tokens"] == 100
assert props["$ai_cache_read_input_tokens"] == 60
assert props["$ai_output_tokens"] == 50
assert props["$ai_reasoning_tokens"] == 40
assert props["$ai_cache_reporting_exclusive"] is False

def test_cached_compatible_claude_usage_is_declared_inclusive(self):
"""A Claude model behind an OpenAI-compatible endpoint reports inclusive
cached tokens. Without an explicit convention, the cost backend infers
exclusive reporting from the model name and overcounts input cost."""
from agents import set_trace_processors
from agents.tracing import generation_span, trace

client = MagicMock(privacy_mode=False, enable_full_ai_capture=False)
set_trace_processors([])
processor = instrument(client, distinct_id="review-user")
try:
with trace("compatible-model"):
with generation_span(
model="anthropic/claude-sonnet-4.6",
usage={
"input_tokens": 10339,
"output_tokens": 60,
"input_tokens_details": {"cached_tokens": 10318},
},
):
pass
properties = next(
call.kwargs["properties"]
for call in client.capture.call_args_list
if call.kwargs["event"] == "$ai_generation"
)
assert properties["$ai_cache_read_input_tokens"] == 10318
assert properties.get("$ai_cache_reporting_exclusive") is False
finally:
set_trace_processors([])
processor.shutdown()

def test_flat_cache_keys_keep_their_own_convention(
self, processor, mock_client, mock_span
):
"""Flat cache keys come from custom models with their own convention, so
no inclusive/exclusive flag is declared for them."""
mock_span.span_data = GenerationSpanData(
model="custom-model",
usage={
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 60,
},
)

processor.on_span_start(mock_span)
processor.on_span_end(mock_span)

props = mock_client.capture.call_args[1]["properties"]
assert props["$ai_cache_read_input_tokens"] == 60
assert "$ai_cache_reporting_exclusive" not in props

def test_caller_cache_reporting_convention_takes_precedence(
self, mock_client, mock_span
):
"""A processor-wide $ai_cache_reporting_exclusive overrides the default
declared for nested OpenAI-shaped usage."""
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
privacy_mode=False,
properties={"$ai_cache_reporting_exclusive": True},
)
mock_span.span_data = GenerationSpanData(
model="gpt-5-mini",
usage={
"input_tokens": 100,
"output_tokens": 50,
"input_tokens_details": {"cached_tokens": 60},
},
)

processor.on_span_start(mock_span)
processor.on_span_end(mock_span)

props = mock_client.capture.call_args[1]["properties"]
assert props["$ai_cache_read_input_tokens"] == 60
assert props["$ai_cache_reporting_exclusive"] is True

def test_function_span_mapping(self, processor, mock_client, mock_span):
"""Test FunctionSpanData maps to $ai_span event with type=tool."""
span_data = FunctionSpanData(
Expand Down
Loading