Skip to content

symfonic.core.usage

usage

Canonical token-usage extraction from AIMessage responses (v8.6.8).

Single source of truth for reading LangChain's usage_metadata off an AIMessage-like response. Consolidates three previously-duplicated readouts (DRY):

  • callbacks/emit.py:_extract_usage -- the Jarvio-VALIDATED cost path, carrying real production cost numbers (v7.14.2).
  • nodes/react.py:_extract_usage -- the original (emit was hoisted from it); differed only on the object-style fallback branch.
  • streaming/transpiler.py -- an inline input/output-only readout with no cache handling (the v8.6.7 getattr-on-dict bug lived here, in the divergence this module closes).

This function reproduces emit's validated logic byte-for-byte: the dict path real providers (Anthropic, OpenAI) exercise is unchanged, and the object-style fallback adopts emit's attribute read (the canonical/validated behaviour) rather than react's zero-return.

The module depends only on :mod:symfonic.core.providers (for the Anthropic cache-TTL wire-shape owner) and the standard library, so all three call sites can import it without an import cycle.

extract_usage

extract_usage(message: Any) -> dict[str, int]

Pull token usage from an AIMessage-like response.

Canonical extractor for ALL LLM call sites (react node, the seven non-react bypass sites, and the streaming transpiler). Validated against real Jarvio production cost numbers (hoisted from the v7.14.2 callbacks/emit.py implementation).

LangChain stores usage as usage_metadata on AIMessage -- a UsageMetadata TypedDict that is a plain dict at runtime. Anthropic cache tokens are NESTED under input_token_details.{cache_read, cache_creation} -- a flat {k: int(v) for k, v in raw.items()} walk drops them (the nested dict fails the isinstance(v, int) type filter). The dict shape returned here matches what TokenUsage.from_dict consumes (cache_read_input_tokens / cache_creation_input_tokens, see contracts/callbacks.py).

Parameters:

Name Type Description Default
message Any

An AIMessage-like object. Read non-destructively via getattr(message, "usage_metadata", None) -- a missing attribute degrades to the zero-default shape rather than raising.

required

Returns:

Type Description
dict[str, int]

Dict with input_tokens and output_tokens (always present,

dict[str, int]

defaulting to 0). When the provider surfaces Anthropic cache

dict[str, int]

tokens, additionally includes cache_read_input_tokens and

dict[str, int]

cache_creation_input_tokens so TokenUsage.from_dict can

dict[str, int]

materialise the typed breakdown and the pricing layer can compute

dict[str, int]

the discounted cost. When the Anthropic 1h cache TTL is in play,

dict[str, int]

a cache_ttl string is included. Extended-thinking reasoning

dict[str, int]

tokens (Opus-4-x and forward-compat for Opus-5+) are surfaced as a

dict[str, int]

flat reasoning_tokens key.

Source code in src/symfonic/core/usage.py
def extract_usage(message: Any) -> dict[str, int]:
    """Pull token usage from an AIMessage-like response.

    Canonical extractor for ALL LLM call sites (react node, the seven
    non-react bypass sites, and the streaming transpiler).  Validated
    against real Jarvio production cost numbers (hoisted from the v7.14.2
    ``callbacks/emit.py`` implementation).

    LangChain stores usage as ``usage_metadata`` on AIMessage -- a
    ``UsageMetadata`` ``TypedDict`` that is a plain ``dict`` at runtime.
    Anthropic cache tokens are NESTED under
    ``input_token_details.{cache_read, cache_creation}`` -- a flat
    ``{k: int(v) for k, v in raw.items()}`` walk drops them (the nested
    dict fails the ``isinstance(v, int)`` type filter).  The dict shape
    returned here matches what ``TokenUsage.from_dict`` consumes
    (``cache_read_input_tokens`` / ``cache_creation_input_tokens``,
    see ``contracts/callbacks.py``).

    Args:
        message: An ``AIMessage``-like object.  Read non-destructively via
            ``getattr(message, "usage_metadata", None)`` -- a missing
            attribute degrades to the zero-default shape rather than
            raising.

    Returns:
        Dict with ``input_tokens`` and ``output_tokens`` (always present,
        defaulting to 0).  When the provider surfaces Anthropic cache
        tokens, additionally includes ``cache_read_input_tokens`` and
        ``cache_creation_input_tokens`` so ``TokenUsage.from_dict`` can
        materialise the typed breakdown and the pricing layer can compute
        the discounted cost.  When the Anthropic 1h cache TTL is in play,
        a ``cache_ttl`` string is included.  Extended-thinking reasoning
        tokens (Opus-4-x and forward-compat for Opus-5+) are surfaced as a
        flat ``reasoning_tokens`` key.
    """
    meta = getattr(message, "usage_metadata", None)
    if isinstance(meta, dict):
        usage: dict[str, int] = {
            "input_tokens": int(meta.get("input_tokens") or 0),
            "output_tokens": int(meta.get("output_tokens") or 0),
        }
        # Anthropic cache tokens live in a nested input_token_details
        # dict.  A flat-dict walk silently drops them (which was the bug
        # in the original Jarvio-proposed patch and the pre-v7.14.2
        # behaviour at the emit site).
        details = meta.get("input_token_details")
        if isinstance(details, dict):
            cache_read = details.get("cache_read", 0)
            cache_creation = details.get("cache_creation", 0)
            if cache_read:
                usage["cache_read_input_tokens"] = int(cache_read)
            if cache_creation:
                usage["cache_creation_input_tokens"] = int(cache_creation)
            # v7.20.0 T-7.20.0.6: surface the Anthropic 1h-cache TTL.
            # ``AnthropicProvider.extract_cache_ttl`` is the wire-shape
            # owner; we stay provider-agnostic by delegating.  Returns
            # ``"1h"`` only when ``ephemeral_1h_input_tokens > 0`` on
            # this turn; otherwise None and the field is omitted (the
            # cost layer's ``cache_ttl is None`` branch picks the 5-min
            # ``cache_write`` rate, the documented default).
            from .providers import AnthropicProvider

            cache_ttl = AnthropicProvider.extract_cache_ttl(details)
            if cache_ttl is not None:
                usage["cache_ttl"] = cache_ttl
        # v7.15.0: extended-thinking reasoning tokens live under
        # ``output_token_details.reasoning`` on Opus-4-x.  Top-level
        # ``reasoning_tokens`` is checked as forward-compat for Opus-5+
        # separated billing.  Surfaced as a flat ``reasoning_tokens``
        # key in the returned dict so ``TokenUsage.from_dict`` and
        # adopter bridges have a uniform read path regardless of
        # provider shape.
        reasoning = meta.get("reasoning_tokens", 0)
        if not reasoning:
            out_details = meta.get("output_token_details")
            if isinstance(out_details, dict):
                reasoning = out_details.get("reasoning", 0)
        if reasoning:
            usage["reasoning_tokens"] = int(reasoning)
        return usage

    # Object-style fallback: some adapters return a UsageMetadata-like
    # object instead of a dict.  The pre-v7.14.2 emit helper already
    # handled this branch; preserve back-compat.  Cache fields aren't
    # surfaced here because no known object-style adapter exposes them
    # -- if one ever does we add the getattr branch in a follow-up.
    return {
        "input_tokens": int(getattr(meta, "input_tokens", 0) or 0),
        "output_tokens": int(getattr(meta, "output_tokens", 0) or 0),
    }