From ff4d841b0d58ca04aace950f769e44a6f6576114 Mon Sep 17 00:00:00 2001 From: WhoamiI00 Date: Wed, 12 Aug 2026 15:08:18 +0530 Subject: [PATCH 1/2] fix(api): price cached input tokens at the cached rate Traced cost billed every prompt token at the full input rate, including the slice a provider served from its cache. On the case in #5711 -- a 25,978-token Gemini prompt with 24,540 cached -- that reports $0.007793 of prompt cost where $0.001168 is correct, 6.67x too high. It is worst on agent workloads, which replay a long prefix on every call, and it runs in OSS as well as cloud. `calculate_costs` read only `prompt` and `completion` out of `tokens.incremental` and called litellm's `cost_per_token` with those alone, even though litellm accepts `cache_read_input_tokens` and its price map carries a separate, much lower cached rate (a tenth of input, for Gemini Flash). Read the cached count and forward it. Three things this has to get right: - The count is a SUBSET of `prompt_tokens`, not an addition to it. litellm's convention is that `prompt_tokens` already includes the cached slice, and it normalizes Anthropic-style usage on the way in, so it is passed alongside the prompt total rather than deducted from it. Deducting would understate cost. - It must arrive as an int. litellm reads the slice back off `Usage.prompt_tokens_details.cached_tokens`, and its `Usage` model only derives that wrapper from an int -- given the float this metric is stored as, `prompt_tokens_details` is None and every token is billed at the full rate again, silently. Without the coercion the rest of this fix is a no-op. - The adapters disagree on the field name: `logfire_adapter` writes `cache_read` (matching what the runner emits), `vercelai_adapter` writes `cached`. Both are read, or the cost is right for one integration only. The kwarg is passed only when the count is non-zero, so an uncached span calls exactly the signature it always did. The SDK pins `litellm>=1,<2`, and on a 1.x without the parameter an unconditional kwarg would raise TypeError into the bare `except`, dropping costs for every span rather than just cached ones. On the SDK side the litellm handler never recorded the count at all, so `cache_read` could not reach the API for SDK-traced calls. It now reads `prompt_tokens_details.cached_tokens` (OpenAI and Google) with a fallback to a flat `cache_read_input_tokens` (Anthropic-style). The extraction was duplicated between the sync and async paths and is now one helper. Closes #5711 --- .github/pr-assets/5711-cached-token-cost.png | Bin 0 -> 52527 bytes api/oss/src/core/tracing/utils/trees.py | 47 +++- .../pytest/unit/tracing/utils/test_trees.py | 214 +++++++++++++++++- sdks/python/agenta/sdk/litellm/litellm.py | 91 ++++---- .../pytest/unit/test_litellm_token_usage.py | 121 ++++++++++ 5 files changed, 411 insertions(+), 62 deletions(-) create mode 100644 .github/pr-assets/5711-cached-token-cost.png create mode 100644 sdks/python/oss/tests/pytest/unit/test_litellm_token_usage.py diff --git a/.github/pr-assets/5711-cached-token-cost.png b/.github/pr-assets/5711-cached-token-cost.png new file mode 100644 index 0000000000000000000000000000000000000000..f6b9d1819b576a50958509d74feaba7a91e3b889 GIT binary patch literal 52527 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 VsEnV#pSrzsMOoFirP3e2{ui)X?aBZE literal 0 HcmV?d00001 diff --git a/api/oss/src/core/tracing/utils/trees.py b/api/oss/src/core/tracing/utils/trees.py index adf7181524a..5dc0a9161e4 100644 --- a/api/oss/src/core/tracing/utils/trees.py +++ b/api/oss/src/core/tracing/utils/trees.py @@ -575,6 +575,13 @@ def _cumulate_tree_dfs( "rerank", ] +# Prompt tokens served from a provider's cache, which price at a much lower rate than fresh +# input (a tenth of it, for some models). The ingest adapters disagree on the field name: +# `logfire_adapter` writes `cache_read` (matching the `gen_ai.usage.cache_read.input_tokens` +# the runner emits), while `vercelai_adapter` writes `cached` (from `ai.usage.cachedInputTokens`). +# Read every alias, or the cost is right for one integration and overstated for the other. +CACHE_READ_TOKEN_KEYS = ("cache_read", "cached") + def calculate_costs(span_idx: Dict[str, OTelFlatSpan]): for span in span_idx.values(): @@ -588,27 +595,50 @@ def calculate_costs(span_idx: Dict[str, OTelFlatSpan]): "model" ) or attr.get("ag", {}).get("data", {}).get("parameters", {}).get("model") - prompt_tokens = ( + tokens: dict = ( attr.get("ag", {}) .get("metrics", {}) .get("tokens", {}) .get("incremental", {}) - .get("prompt", 0.0) ) - completion_tokens = ( - attr.get("ag", {}) - .get("metrics", {}) - .get("tokens", {}) - .get("incremental", {}) - .get("completion", 0.0) + prompt_tokens = tokens.get("prompt", 0.0) + + completion_tokens = tokens.get("completion", 0.0) + + cache_read_tokens = next( + (tokens[key] for key in CACHE_READ_TOKEN_KEYS if tokens.get(key)), + 0.0, ) try: + # litellm's convention is that `prompt_tokens` INCLUDES the cached tokens and + # that it prices the cached slice separately (it normalizes Anthropic-style + # usage, where the input count excludes them, on the way in). So the cached + # count is passed ALONGSIDE the prompt total and must not be subtracted from + # it first -- doing that would understate cost instead of overstating it. + # + # Passed only when non-zero so a span with no caching calls exactly the + # signature it always did: the SDK pins `litellm>=1,<2`, and on a 1.x old + # enough to lack the parameter an unconditional kwarg would raise TypeError, + # which the `except` below would swallow into "no costs at all" for EVERY span. + # + # int(), and not incidentally: litellm reads the cached slice back off + # `Usage.prompt_tokens_details.cached_tokens`, and its `Usage` model only + # derives that wrapper from an int. Hand it the float this metric is stored + # as and `prompt_tokens_details` comes back None, so the cached tokens are + # billed at the full input rate again -- silently, with no error to catch. + cache_kwargs = ( + {"cache_read_input_tokens": int(cache_read_tokens)} + if cache_read_tokens + else {} + ) + costs = cost_calculator.cost_per_token( model=model, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, + **cache_kwargs, ) if not costs: @@ -646,6 +676,7 @@ def calculate_costs(span_idx: Dict[str, OTelFlatSpan]): model=model, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, + cache_read_tokens=cache_read_tokens, ) diff --git a/api/oss/tests/pytest/unit/tracing/utils/test_trees.py b/api/oss/tests/pytest/unit/tracing/utils/test_trees.py index be94c669dae..f3320bcd933 100644 --- a/api/oss/tests/pytest/unit/tracing/utils/test_trees.py +++ b/api/oss/tests/pytest/unit/tracing/utils/test_trees.py @@ -39,6 +39,8 @@ def _span( links=None, prompt_tokens: float = 0.0, completion_tokens: float = 0.0, + cache_read_tokens: float | None = None, + cache_token_key: str = "cache_read", prompt_cost: float = 0.0, completion_cost: float = 0.0, errors: int = 0, @@ -50,14 +52,18 @@ def _span( ) -> OTelFlatSpan: total_tokens = prompt_tokens + completion_tokens total_cost = prompt_cost + completion_cost + incremental_tokens = { + "prompt": prompt_tokens, + "completion": completion_tokens, + "total": total_tokens, + } + # Under `cache_token_key` because the ingest adapters disagree on the name: the logfire + # path writes `cache_read`, the Vercel AI path writes `cached`. Absent entirely when + # None, which is the shape of a span from a provider or model without prompt caching. + if cache_read_tokens is not None: + incremental_tokens[cache_token_key] = cache_read_tokens metrics = { - "tokens": { - "incremental": { - "prompt": prompt_tokens, - "completion": completion_tokens, - "total": total_tokens, - } - }, + "tokens": {"incremental": incremental_tokens}, "costs": { "incremental": { "prompt": prompt_cost, @@ -252,6 +258,200 @@ def _raise(**_): assert "incremental" in span_idx[ROOT_UUID].attributes["ag"]["metrics"]["costs"] +@pytest.mark.parametrize("cache_token_key", ["cache_read", "cached"]) +def test_calculate_costs_passes_cached_tokens_to_the_pricer( + monkeypatch, + cache_token_key, +): + """Cached prompt tokens must reach litellm, which prices them far below fresh input. + + Both spellings have to be honoured: the logfire ingest path writes `cache_read`, the + Vercel AI path writes `cached`. Reading only one silently overstates the other's cost. + """ + span = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=25978, + completion_tokens=100, + cache_read_tokens=24540, + cache_token_key=cache_token_key, + span_type=SpanType.CHAT, + ) + span_idx = {span.span_id: span} + + seen = {} + + def _capture(**kwargs): + seen.update(kwargs) + return (0.005, 0.001) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _capture, + ) + + calculate_costs(span_idx) + + assert seen["cache_read_input_tokens"] == 24540 + # The cached count is a SUBSET of the prompt total, not an addition to it. litellm + # re-prices that slice itself, so the prompt total is passed through untouched; + # subtracting the cached tokens here would understate cost instead of overstating it. + assert seen["prompt_tokens"] == 25978 + + +def test_calculate_costs_sends_the_cached_count_as_an_int(monkeypatch): + """The count must reach litellm as an int, not the float this metric is stored as. + + litellm reads the cached slice back off `Usage.prompt_tokens_details.cached_tokens`, + and its `Usage` model only builds that wrapper from an int -- given a float it leaves + `prompt_tokens_details` None and bills every token at the full input rate again. There + is no exception to catch: the whole fix degrades to a no-op. Verified against litellm + 1.92.0, where `cost_per_token(..., cache_read_input_tokens=24540)` prices a 25,978-token + Gemini prompt at $0.001168 and the same call with `24540.0` at $0.007793. + """ + span = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=25978.0, + completion_tokens=100.0, + cache_read_tokens=24540.0, + span_type=SpanType.CHAT, + ) + span_idx = {span.span_id: span} + + seen = {} + + def _capture(**kwargs): + seen.update(kwargs) + return (0.001, 0.002) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _capture, + ) + + calculate_costs(span_idx) + + assert isinstance(seen["cache_read_input_tokens"], int) + assert not isinstance(seen["cache_read_input_tokens"], bool) + assert seen["cache_read_input_tokens"] == 24540 + + +def test_calculate_costs_omits_cache_kwarg_when_nothing_was_cached(monkeypatch): + """A span with no caching must call exactly the signature it always did. + + The SDK pins `litellm>=1,<2`, and `calculate_costs` swallows every exception. On a 1.x + old enough to lack the parameter, passing it unconditionally would raise TypeError and + be swallowed into "no costs at all" for EVERY span, not just cached ones -- so the + pricer is called here with a signature that accepts nothing else. + """ + span = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=10, + completion_tokens=20, + span_type=SpanType.CHAT, + ) + span_idx = {span.span_id: span} + + def _legacy_signature(model, prompt_tokens, completion_tokens): + return (0.1, 0.2) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _legacy_signature, + ) + + calculate_costs(span_idx) + + costs = span_idx[ROOT_UUID].attributes["ag"]["metrics"]["costs"]["incremental"] + assert costs["prompt"] == pytest.approx(0.1) + assert costs["completion"] == pytest.approx(0.2) + assert costs["total"] == pytest.approx(0.3) + + +def test_calculate_costs_ignores_a_zero_cached_count(monkeypatch): + """An explicit zero is a cache miss, not a cached slice: still the legacy signature.""" + span = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=10, + completion_tokens=20, + cache_read_tokens=0, + span_type=SpanType.CHAT, + ) + span_idx = {span.span_id: span} + + def _legacy_signature(model, prompt_tokens, completion_tokens): + return (0.1, 0.2) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _legacy_signature, + ) + + calculate_costs(span_idx) + + costs = span_idx[ROOT_UUID].attributes["ag"]["metrics"]["costs"]["incremental"] + assert costs["total"] == pytest.approx(0.3) + + +def test_calculate_costs_bills_cached_tokens_below_fresh_input(monkeypatch): + """End-to-end shape of #5711, with the pricer modelling litellm's published contract. + + The reported case: a 25,978-token prompt of which 24,540 came from cache, on a model + whose cached rate is a tenth of its input rate. Billing every token at the full input + rate is what produced the 6.6x overstatement. + """ + input_rate = 0.30 / 1_000_000 + cached_rate = input_rate / 10 + output_rate = 2.50 / 1_000_000 + + def _priced(model, prompt_tokens, completion_tokens, cache_read_input_tokens=0): + fresh = prompt_tokens - cache_read_input_tokens + return ( + fresh * input_rate + cache_read_input_tokens * cached_rate, + completion_tokens * output_rate, + ) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _priced, + ) + + cached = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=25978, + completion_tokens=100, + cache_read_tokens=24540, + span_type=SpanType.CHAT, + ) + uncached = _span( + span_id=CHILD_A_UUID, + span_name="root", + prompt_tokens=25978, + completion_tokens=100, + span_type=SpanType.CHAT, + ) + + calculate_costs({cached.span_id: cached}) + calculate_costs({uncached.span_id: uncached}) + + cached_costs = cached.attributes["ag"]["metrics"]["costs"]["incremental"] + uncached_costs = uncached.attributes["ag"]["metrics"]["costs"]["incremental"] + + assert cached_costs["total"] < uncached_costs["total"] + # Compared on the prompt component, which is the part caching changes -- the issue's + # 6.6x is a prompt-side ratio, and folding in the (identical) completion cost would + # dilute it to ~5.7x. Same call, same token counts: the only difference is whether the + # cached slice was priced as cached. Before the fix both paths produced the high number. + assert uncached_costs["prompt"] / cached_costs["prompt"] == pytest.approx( + 6.67, + abs=0.01, + ) + + def test_calculate_and_propagate_metrics_runs_full_pipeline(monkeypatch): root = _span( span_id=ROOT_UUID, diff --git a/sdks/python/agenta/sdk/litellm/litellm.py b/sdks/python/agenta/sdk/litellm/litellm.py index 06dd439afdc..87efea8b0c4 100644 --- a/sdks/python/agenta/sdk/litellm/litellm.py +++ b/sdks/python/agenta/sdk/litellm/litellm.py @@ -1,5 +1,5 @@ import warnings -from typing import Dict +from typing import Any, Dict, Optional from opentelemetry.trace import SpanKind import agenta as ag @@ -10,6 +10,47 @@ log = get_module_logger(__name__) +def _read(source: Any, key: str) -> Any: + """Read ``key`` off a usage payload that may arrive as a dict or as an object.""" + if source is None: + return None + if isinstance(source, dict): + return source.get(key) + return getattr(source, key, None) + + +def _extract_token_usage(response_obj: Any) -> Dict[str, Optional[float]]: + """The token counts recorded under ``metrics.unit.tokens`` for one LLM call. + + ``cache_read`` is the slice of the prompt the provider served from its cache, which + prices far below fresh input. It is a SUBSET of ``prompt_tokens``, not an addition to + it, so it is recorded alongside the prompt total and never deducted from it; the + cost calculation re-prices that slice at the cached rate. + """ + usage = _read(response_obj, "usage") + + prompt_tokens = _read(usage, "prompt_tokens") + completion_tokens = _read(usage, "completion_tokens") + total_tokens = _read(usage, "total_tokens") + + # OpenAI and Google both report the cached count at + # ``prompt_tokens_details.cached_tokens``; Anthropic-style usage surfaces it flat as + # ``cache_read_input_tokens``. Check the nested form first: on a provider that reports + # both, the nested one is the OpenAI-convention value matching ``prompt_tokens``. + cache_read_tokens = _read(_read(usage, "prompt_tokens_details"), "cached_tokens") + if cache_read_tokens is None: + cache_read_tokens = _read(usage, "cache_read_input_tokens") + + # Falsy -> None throughout, matching how prompt/completion/total have always been + # recorded: a zero carries no more information than an absent field here. + return { + "prompt": float(prompt_tokens) if prompt_tokens else None, + "completion": float(completion_tokens) if completion_tokens else None, + "total": float(total_tokens) if total_tokens else None, + "cache_read": float(cache_read_tokens) if cache_read_tokens else None, + } + + def litellm_handler(): if ag.tracing is None: warnings.warn( @@ -174,29 +215,8 @@ def log_success_event( namespace="metrics.unit.costs", ) - # Handle both dict and object attribute access for usage, and safely handle None - usage = getattr(response_obj, "usage", None) - if isinstance(usage, dict): - prompt_tokens = usage.get("prompt_tokens") - completion_tokens = usage.get("completion_tokens") - total_tokens = usage.get("total_tokens") - elif usage is not None: - prompt_tokens = getattr(usage, "prompt_tokens", None) - completion_tokens = getattr(usage, "completion_tokens", None) - total_tokens = getattr(usage, "total_tokens", None) - else: - prompt_tokens = completion_tokens = total_tokens = None - span.set_attributes( - attributes=( - { - "prompt": float(prompt_tokens) if prompt_tokens else None, - "completion": float(completion_tokens) - if completion_tokens - else None, - "total": float(total_tokens) if total_tokens else None, - } - ), + attributes=_extract_token_usage(response_obj), namespace="metrics.unit.tokens", ) @@ -311,31 +331,8 @@ async def async_log_success_event( namespace="metrics.unit.costs", ) - # Handle both dict and object attribute access for usage - usage = getattr(response_obj, "usage", None) - if usage is None: - prompt_tokens = None - completion_tokens = None - total_tokens = None - elif isinstance(usage, dict): - prompt_tokens = usage.get("prompt_tokens") - completion_tokens = usage.get("completion_tokens") - total_tokens = usage.get("total_tokens") - else: - prompt_tokens = getattr(usage, "prompt_tokens", None) - completion_tokens = getattr(usage, "completion_tokens", None) - total_tokens = getattr(usage, "total_tokens", None) - span.set_attributes( - attributes=( - { - "prompt": float(prompt_tokens) if prompt_tokens else None, - "completion": float(completion_tokens) - if completion_tokens - else None, - "total": float(total_tokens) if total_tokens else None, - } - ), + attributes=_extract_token_usage(response_obj), namespace="metrics.unit.tokens", ) diff --git a/sdks/python/oss/tests/pytest/unit/test_litellm_token_usage.py b/sdks/python/oss/tests/pytest/unit/test_litellm_token_usage.py new file mode 100644 index 00000000000..307b316e71c --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_litellm_token_usage.py @@ -0,0 +1,121 @@ +"""Token extraction from a litellm response's usage object. + +Covers the cached-prompt-token count the cost calculation needs (see #5711): without it +every cached token is billed at the full input rate, which overstates cost by up to 6.6x +on a workload that replays a long prefix. +""" + +from types import SimpleNamespace + +import pytest + +from agenta.sdk.litellm.litellm import _extract_token_usage + + +def _response(usage): + return SimpleNamespace(usage=usage) + + +def test_reads_openai_style_usage_objects(): + usage = SimpleNamespace( + prompt_tokens=25978, + completion_tokens=100, + total_tokens=26078, + prompt_tokens_details=SimpleNamespace(cached_tokens=24540), + ) + + assert _extract_token_usage(_response(usage)) == { + "prompt": 25978.0, + "completion": 100.0, + "total": 26078.0, + # A SUBSET of `prompt`, not an addition to it: the cost calculation re-prices this + # slice at the provider's cached rate rather than adding a fourth token bucket. + "cache_read": 24540.0, + } + + +def test_reads_usage_delivered_as_a_dict(): + """litellm hands the usage object through as a dict on some providers.""" + usage = { + "prompt_tokens": 300, + "completion_tokens": 50, + "total_tokens": 350, + "prompt_tokens_details": {"cached_tokens": 128}, + } + + assert _extract_token_usage(_response(usage)) == { + "prompt": 300.0, + "completion": 50.0, + "total": 350.0, + "cache_read": 128.0, + } + + +def test_falls_back_to_the_flat_anthropic_style_cache_field(): + """Anthropic-style usage reports the cached count flat, with no nested details.""" + usage = SimpleNamespace( + prompt_tokens=300, + completion_tokens=50, + total_tokens=350, + cache_read_input_tokens=128, + ) + + assert _extract_token_usage(_response(usage))["cache_read"] == 128.0 + + +def test_prefers_the_nested_count_when_a_provider_reports_both(): + """The nested value is the OpenAI-convention one that matches `prompt_tokens`.""" + usage = SimpleNamespace( + prompt_tokens=300, + completion_tokens=50, + total_tokens=350, + prompt_tokens_details=SimpleNamespace(cached_tokens=128), + cache_read_input_tokens=999, + ) + + assert _extract_token_usage(_response(usage))["cache_read"] == 128.0 + + +@pytest.mark.parametrize( + "usage", + [ + pytest.param( + SimpleNamespace(prompt_tokens=300, completion_tokens=50, total_tokens=350), + id="no-cache-fields", + ), + pytest.param( + SimpleNamespace( + prompt_tokens=300, + completion_tokens=50, + total_tokens=350, + prompt_tokens_details=SimpleNamespace(cached_tokens=0), + ), + id="explicit-cache-miss", + ), + pytest.param( + SimpleNamespace( + prompt_tokens=300, + completion_tokens=50, + total_tokens=350, + prompt_tokens_details=None, + ), + id="null-details", + ), + ], +) +def test_records_no_cached_count_when_nothing_was_cached(usage): + """Absent, zero, and null all mean the same thing: nothing to re-price.""" + extracted = _extract_token_usage(_response(usage)) + + assert extracted["cache_read"] is None + assert extracted["prompt"] == 300.0 + + +def test_tolerates_a_response_without_usage(): + """A failed or streaming-partial response can carry no usage object at all.""" + assert _extract_token_usage(_response(None)) == { + "prompt": None, + "completion": None, + "total": None, + "cache_read": None, + } From 1c7a9ce0619198da53ed4d92a8d0e8694ac25ed3 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 20 Aug 2026 17:34:03 +0200 Subject: [PATCH 2/2] fix(api): ignore non-numeric or negative cached-token counts when pricing A truthy non-numeric cache field from a foreign OTLP source would reach int() inside the try, raise, and be swallowed by the bare except -- dropping the span's entire cost, where before the cached-rate fix such garbage was simply ignored and prompt/completion were still priced. A negative count is truthy too and would reach the pricer's fresh-minus-cached arithmetic. The selection now accepts only real positive numbers, so anything else degrades to the legacy no-cache call. --- api/oss/src/core/tracing/utils/trees.py | 11 ++- .../pytest/unit/tracing/utils/test_trees.py | 98 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/api/oss/src/core/tracing/utils/trees.py b/api/oss/src/core/tracing/utils/trees.py index 5dc0a9161e4..f5b2db8c537 100644 --- a/api/oss/src/core/tracing/utils/trees.py +++ b/api/oss/src/core/tracing/utils/trees.py @@ -606,8 +606,17 @@ def calculate_costs(span_idx: Dict[str, OTelFlatSpan]): completion_tokens = tokens.get("completion", 0.0) + # Only a real positive number qualifies. Non-numeric or negative garbage from a + # foreign OTLP source must degrade to "no cache kwarg", not reach the int() below + # and turn into a swallowed exception that drops the span's ENTIRE cost. cache_read_tokens = next( - (tokens[key] for key in CACHE_READ_TOKEN_KEYS if tokens.get(key)), + ( + value + for key in CACHE_READ_TOKEN_KEYS + if isinstance((value := tokens.get(key)), (int, float)) + and not isinstance(value, bool) + and value > 0 + ), 0.0, ) diff --git a/api/oss/tests/pytest/unit/tracing/utils/test_trees.py b/api/oss/tests/pytest/unit/tracing/utils/test_trees.py index f3320bcd933..ee05b184825 100644 --- a/api/oss/tests/pytest/unit/tracing/utils/test_trees.py +++ b/api/oss/tests/pytest/unit/tracing/utils/test_trees.py @@ -396,6 +396,104 @@ def _legacy_signature(model, prompt_tokens, completion_tokens): assert costs["total"] == pytest.approx(0.3) +def test_calculate_costs_ignores_a_non_numeric_cached_count(monkeypatch): + """Garbage in the cache field must not cost the span its ENTIRE cost. + + A foreign OTLP source can write anything under `tokens.incremental`. A truthy + non-numeric value that reached `int()` would raise inside the try, and the bare + `except` would swallow it into "no costs at all" for the span -- a regression + against the pre-fix behaviour, where a garbage cache field was simply ignored + and prompt/completion were still priced. + """ + span = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=10, + completion_tokens=20, + cache_read_tokens="24540", + span_type=SpanType.CHAT, + ) + span_idx = {span.span_id: span} + + def _legacy_signature(model, prompt_tokens, completion_tokens): + return (0.1, 0.2) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _legacy_signature, + ) + + calculate_costs(span_idx) + + costs = span_idx[ROOT_UUID].attributes["ag"]["metrics"]["costs"]["incremental"] + assert costs["prompt"] == pytest.approx(0.1) + assert costs["completion"] == pytest.approx(0.2) + assert costs["total"] == pytest.approx(0.3) + + +def test_calculate_costs_ignores_a_negative_cached_count(monkeypatch): + """A negative count is not a cached slice: still the legacy signature. + + Negative is truthy, so without the numeric guard it would reach the pricer, + where "fresh = prompt - cached" arithmetic turns it into an overstated cost. + """ + span = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=10, + completion_tokens=20, + cache_read_tokens=-5, + span_type=SpanType.CHAT, + ) + span_idx = {span.span_id: span} + + def _legacy_signature(model, prompt_tokens, completion_tokens): + return (0.1, 0.2) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _legacy_signature, + ) + + calculate_costs(span_idx) + + costs = span_idx[ROOT_UUID].attributes["ag"]["metrics"]["costs"]["incremental"] + assert costs["total"] == pytest.approx(0.3) + + +def test_calculate_costs_prefers_cache_read_over_cached_when_both_present(monkeypatch): + """`cache_read` wins when both aliases appear on one span. + + No ingest adapter writes both today, but the precedence should be pinned: + `cache_read` is the spelling the runner emits and the logfire path stores. + """ + span = _span( + span_id=ROOT_UUID, + span_name="root", + prompt_tokens=25978, + completion_tokens=100, + cache_read_tokens=24540, + span_type=SpanType.CHAT, + ) + span.attributes["ag"]["metrics"]["tokens"]["incremental"]["cached"] = 999 + span_idx = {span.span_id: span} + + seen = {} + + def _capture(**kwargs): + seen.update(kwargs) + return (0.001, 0.002) + + monkeypatch.setattr( + "oss.src.core.tracing.utils.trees.cost_calculator.cost_per_token", + _capture, + ) + + calculate_costs(span_idx) + + assert seen["cache_read_input_tokens"] == 24540 + + def test_calculate_costs_bills_cached_tokens_below_fresh_input(monkeypatch): """End-to-end shape of #5711, with the pricer modelling litellm's published contract.