Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,9 @@ async def call(
output_tokens = max(0, output_tokens - thoughts_tokens)
total_tokens = max(0, total_tokens - thoughts_tokens)

# Record LLM metrics
# Record LLM metrics. ``output_tokens`` is visible-only by now, so
# ``thoughts_tokens`` has to be recorded alongside it or the reasoning
# half of the billed output reaches no counter at all.
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
Expand All @@ -969,6 +971,8 @@ async def call(
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)

# Record trace span
Expand Down Expand Up @@ -1270,6 +1274,8 @@ async def call_with_tools(
if thoughts_tokens:
output_tokens = max(0, output_tokens - thoughts_tokens)

# See ``call()``: record the reasoning and cached counts too, so no
# billed token is dropped from the metrics counters.
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
Expand All @@ -1279,6 +1285,8 @@ async def call_with_tools(
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)

# Record OpenTelemetry span
Expand Down
86 changes: 85 additions & 1 deletion hindsight-api-slim/tests/test_token_usage_cached_thoughts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from pydantic import BaseModel
Expand All @@ -27,6 +27,7 @@
from hindsight_api.engine.reflect.models import StructuredOutputResult, TokenUsageSummary
from hindsight_api.engine.response_models import LLMToolCallResult, TokenUsage
from hindsight_api.extensions.operation_validator import RetainResult
from hindsight_api.metrics import MetricsCollector


class _OkModel(BaseModel):
Expand Down Expand Up @@ -293,3 +294,86 @@ async def test_openai_compatible_output_tokens_exclude_thoughts_like_gemini():
assert token_usage.total_tokens == token_usage.input_tokens + token_usage.output_tokens
# The reasoning tokens live in exactly one field, not both.
assert token_usage.output_tokens + token_usage.thoughts_tokens == 83


def _recorded_llm_call(collector):
"""The kwargs of the single record_llm_call the provider made."""
assert collector.record_llm_call.call_count == 1
return collector.record_llm_call.call_args.kwargs


@pytest.mark.asyncio
async def test_openai_compatible_call_records_cached_and_thoughts_on_metrics():
"""call() must hand cached/thoughts to the metrics collector, not only to
TokenUsage. The collector already accepts both kwargs and keeps a counter for
each; the provider simply never passed them, so hindsight.llm.tokens.thoughts
and hindsight.llm.tokens.cached_input stayed empty for every OpenAI-compatible
reasoning model."""
llm = _openai_llm()
llm._client.chat.completions.create = AsyncMock(return_value=_response(usage=_usage(cached=200, reasoning=80)))
collector = MagicMock(spec=MetricsCollector)
with patch(
"hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector",
return_value=collector,
):
await llm.call(
messages=[{"role": "user", "content": "Return whether this worked."}],
response_format=_OkModel,
max_retries=0,
return_usage=True,
)
recorded = _recorded_llm_call(collector)
assert recorded["cached_input_tokens"] == 200
assert recorded["thoughts_tokens"] == 80


@pytest.mark.asyncio
async def test_openai_compatible_call_with_tools_records_cached_and_thoughts_on_metrics():
"""call_with_tools() extracts both counts for its own return value, so it must
record them too — the tool-calling path is where the agentic loop spends most
of its reasoning tokens."""
llm = _openai_llm()
llm._client.chat.completions.create = AsyncMock(
return_value=_response(usage=_usage(cached=64, reasoning=33), content="done")
)
collector = MagicMock(spec=MetricsCollector)
with patch(
"hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector",
return_value=collector,
):
await llm.call_with_tools(messages=[{"role": "user", "content": "hi"}], tools=[], max_retries=0)
recorded = _recorded_llm_call(collector)
assert recorded["cached_input_tokens"] == 64
assert recorded["thoughts_tokens"] == 33


@pytest.mark.asyncio
async def test_openai_compatible_metrics_account_for_every_billed_output_token():
"""Accounting invariant: every token the provider bills as output lands on
exactly one counter, never zero and never two.

output_tokens is made visible-only just above the record_llm_call (reasoning
subtracted so it doesn't double-count against thoughts_tokens). That subtraction
only holds the books straight if thoughts_tokens is recorded as well: recorded
output + recorded thoughts must add back up to the provider's completion_tokens.
"""
completion, reasoning = 83, 64
llm = _openai_llm()
llm._client.chat.completions.create = AsyncMock(
return_value=_response(usage=_usage(reasoning=reasoning, prompt=20, completion=completion, total=103))
)
collector = MagicMock(spec=MetricsCollector)
with patch(
"hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector",
return_value=collector,
):
await llm.call(
messages=[{"role": "user", "content": "17*23?"}],
response_format=_OkModel,
max_retries=0,
return_usage=True,
)
recorded = _recorded_llm_call(collector)
assert recorded["output_tokens"] == completion - reasoning # visible-only
assert recorded["thoughts_tokens"] == reasoning
assert recorded["output_tokens"] + recorded["thoughts_tokens"] == completion