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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to this project will be documented here. Format follows [Kee

### Fixed

- **A cached token on an OpenAI-compatible endpoint with additive cache semantics was billed twice, the second time at the output rate.** The `total_tokens` consistency guard folds any positive `total_tokens - (input + output + reasoning)` remainder into `output`, on the premise — verified at the time across all three surfaces this adapter served — that cache counts always sit INSIDE `prompt_tokens`, so they can never appear in that remainder. Snowflake Cortex breaks the premise: it answers on an OpenAI **wire** (`/api/v2/cortex/v1/chat/completions`) with Anthropic's **additive** convention. Measured live against the real endpoint on 2026-08-25: `prompt_tokens: 7`, `prompt_tokens_details.cached_tokens: 4805`, `completion_tokens: 6`, `total_tokens: 4818` — the cached block sits outside `prompt_tokens` and inside `total_tokens`. The adapter therefore read 4,805 tokens as unaccounted and folded them into `output`, reporting **4,811 output tokens for a call that generated 6**, while the same 4,805 also shipped as `cache_read`: 17,503 tokens billed for 8,758 consumed on the original capture (**2.0x**), with the output line inflated ~800x. The reconciliation now subtracts `cache_read` and the raw `prompt_tokens_details.cache_write_tokens` as well.
- **`cache_write_tokens` is accounted for without being mapped.** It is deliberately absent from `CanonicalUsage.cache_write` — for OpenAI it sits inside `prompt_tokens` and billing it separately over-charges 2.24x — so it has no canonical route into the arithmetic and is read straight off the payload instead. Without that, an additive cache *write* inflates `output` exactly the way the read did.
- **The correction cannot suppress a genuine fold.** On a subtractive surface the cache counts are already inside `input`, so removing them again only drives the delta further negative, where the existing `> 0` guard already no-ops. Re-verified across every captured OpenAI, Databricks and Cloudflare fixture — all still 0 — and a payload carrying both an additive cache block and hidden thinking tokens still folds the thinking remainder alone.
- **Reported in review and answered "unreachable" — correctly, at the time.** PR #14 (2026-08-17) raised that `reasoning` was not a priori the only field able to inflate `total_tokens`, and named the exact payload shape. The answer was a three-surface census returning `unaccounted = 0` everywhere, which held until an OpenAI-compatible surface with additive caching existed. The regression fixtures are captured from that surface (`11_snowflake_cortex_plain_chat.json`, `12_snowflake_cortex_cache_chat.json`, via `capture_snowflake_cortex.py`) so the census is now pinned by a live payload rather than by an argument.
- **An explicitly-passed falsy `api_url` silently resolved to PRODUCTION Lago.** Preferring the config value over `""` is right — `requests` raises `MissingSchema`, which is not a `LagoApiError`, so the queue classified it transient, re-prepended the batch and retried at the 60s ceiling forever, stopping all billing with only a growing buffer as the symptom. But `LagoConfig`'s default is the production URL, so `api_url=os.environ.get("LAGO_API_URL", "")` with the var unset resolved to production with **no `on_error` and no log** — verified live: 0 reports, 0 log lines, and a client posting to `api.getlago.com`. For a CI job or a developer holding a real production key that writes live billing data, and ingested events cannot be un-ingested. The fallback is unchanged, so the original config-clobber bug stays fixed; it is now reported under `config.api_url` through the same log-plus-callback floor as every other drop path. An *unpassed* `api_url` stays silent — `None` means the caller never mentioned it, and reporting the common case would train customers to ignore the channel this fix depends on.
- **`usage_metadata` from the Cloudflare gateway got no drift sweep, and had already lost two counters.** `extras` was a fixed three-key dict, so any counter the adapter does not map vanished with no error and no `on_error` — the one place violating the drift contract `test_drift.py` enforces for the native adapters. Not hypothetical: replaying the **14 captured fixtures** through the adapter drops **`neurons`** (Cloudflare's Workers AI billing unit) in 4 entries and **`input_text_tokens`** in 1, and a live Logs API pull also returns **`units`**, a cost quantity that appears in no fixture at all — the hand-maintained key enumeration in the module docstring had already drifted past reality, which is exactly the failure mode a snapshot invites. Unmapped keys are now swept into `extras["usage_metadata"]` against an explicit `_MAPPED_USAGE_KEYS` set, so a ninth spelling surfaces on its own instead of needing another 14-fixture audit. Deliberately **nested** rather than merged flat into `extras`: the poller reads `extras["cached"]` to decide whether to skip billing a request Cloudflare served for free, so a future `usage_metadata` key called `cached` or `step` must not be able to shadow it. The regression test iterates the fixture directory rather than a fixed key list, so a recapture that introduces a new counter fails it with no test edit. Closes #16.
- **`prime()` re-downloaded the ~400-model OpenRouter catalogue regardless of the TTL.** It set `_openrouter_stale` unconditionally, and it is reached from `_auto_prime_pricing_for` on a matching `wrap()` and from `warm_pricing()` — both of which a server can run per request — so `pricing_ttl_seconds` never applied on that path at all, and the catalogue was refetched on essentially every flush tick, on the thread the queue drains events from. Measured with the shipped 1-hour TTL: **4 `prime()`+`maybe_refresh()` cycles produced 4 full downloads where 1 was correct; now 1.** Gated on the same "no table, or past the TTL" test `lookup()` already uses, so priming and looking up cannot disagree about what needs fetching — and a table that genuinely ages out is still re-primed, so prices do not freeze at the first fetch.
Expand Down
28 changes: 27 additions & 1 deletion src/lago_agent_sdk/adapters/openai_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,36 @@ def extract_openai_native(response: Any, model_id: str = "", provider_hint: str
# breakdown at all (measured: prompt 57, completion 47, total 1253) — still
# recovers its 1,149 tokens, because reasoning is 0 there.
#
# The cache counts are subtracted for the SAME reason as reasoning, and this was
# the half that was missing. The guard assumed every OpenAI-shaped surface reports
# cache_read INSIDE prompt_tokens, which held for all three surfaces that existed
# when it was written (native OpenAI: zero deltas; Databricks: 112/112 rows with
# total == input + output; Cloudflare: cache outside `input` but outside `total`
# too, so it never inflated the delta). Snowflake Cortex is the surface that broke
# it — an OpenAI-WIRE endpoint with Anthropic's ADDITIVE convention: measured
# 2026-08-25, prompt_tokens=7, cached_tokens=4805, completion_tokens=6,
# total_tokens=4818, i.e. the cached block sits OUTSIDE prompt_tokens and INSIDE
# total_tokens. Accounting for only input+output+reasoning made those 4,805 cached
# tokens look unaccounted, so they were folded into `output` — 4,811 reported for a
# call that generated 6, while the same tokens also shipped as cache_read. 2.0x on
# the call, 800x on the output line. See 12_snowflake_cortex_cache_chat.json.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since this PR effectively blesses snowflake as a provider through this adapter, should it also land in _OUTPUT_INCLUDES_REASONING in pricing.py? Cortex serves Anthropic models, whose thinking tokens sit inside output_tokens and are re-reported on the wire as completion_tokens_details.reasoning_tokens — both new fixtures carry the field, at 0.

The table is frozenset({"openai", "workers-ai"}) at pricing.py:111, plus _OPENAI_SHAPED_APIS = {"databricks_gateway"} at 137, and provider="snowflake", api="chat_completions" matches neither — so _token_semantics returns output_includes_reasoning=False and a call with completion_tokens=1000, reasoning_tokens=800 would bill 1,800 output-rate tokens for 1,000 generated.

Same class of convention-table gap this PR is closing, one field over — fold it in here, or leave it for a follow-up once someone can capture a thinking-enabled Cortex call?

#
# `cache_write_tokens` is read straight from the payload rather than from a
# canonical field because it is deliberately NOT mapped to CanonicalUsage.cache_write
# (for OpenAI it sits inside prompt_tokens and billing it separately over-charges
# 2.24x — see _MAPPED_DETAIL_FIELDS). It still has to be accounted for here, or an
# additive cache WRITE would inflate `output` exactly the way the read did.
#
# Subtracting them cannot suppress a genuine fold on a subtractive surface: there
# the cache counts are already inside `input`, so removing them again only drives
# the delta further negative, where the `> 0` guard already no-ops. Verified against
# every captured OpenAI, Databricks and Cloudflare fixture — all still 0.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On this line specifically: I don't think the Databricks and Cloudflare halves of that check can have run. extract_openai_native is imported in exactly one place in src (wrappers/openai.py:165) — the Databricks system-table rows go through extract_databricks_log, and the Cloudflare fixtures are gateway log entries with tokens_in/tokens_out rather than an OpenAI usage block. Neither carries a total_tokens remainder this guard could see, so "all still 0" is true of them without being evidence about them.

That leaves the 10 OpenAI fixtures, none of which pairs a cache count with a positive delta — the one combination I'm asking about above. Worth narrowing the claim to what was actually exercised? The CHANGELOG entry repeats it too.

#
# A no-op for real OpenAI either way: total always equals prompt + completion.
declared_total = _safe_int(usage.get("total_tokens"))
if declared_total:
unaccounted = declared_total - (input_tokens + output_tokens + reasoning)
cache_write = _safe_int(_safe_dict(usage.get("prompt_tokens_details")).get("cache_write_tokens"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This one I think may point the wrong way. The NOTE at lines 79-87 measures the OpenAI case as prompt_tokens=3025 with cache_write_tokens=3022 — the write sits INSIDE prompt_tokens, which is exactly the reason it's kept out of _MAPPED_DETAIL_FIELDS. But here it's subtracted from the total unconditionally, as though it were outside.

Taking that documented shape and putting it behind a proxy that under-reports — prompt_tokens=3025, completion_tokens=4, total_tokens=4200, so 1,171 real unreported tokens: on main the guard folds them and reports output=1175; on this branch it reports output=4 and drops unaccounted_output_tokens from extras entirely. The guard is gone on the one payload shape this file already documents as real.

The additive cache-write case the new test at line 353 pins is hand-written — fixture 12 has cache_write_tokens: 0, and I couldn't find a captured surface anywhere in the repo that reports one additively. Should the same additivity test apply to the write as to the read (cache_write_tokens > prompt_tokens), so the documented OpenAI shape keeps its guard?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should this pick the details container based on which branch ran? On the Responses path the container is input_tokens_details, so usage.get("prompt_tokens_details") is absent and cache_write stays 0 — meaning {"input_tokens": 13, "output_tokens": 4, "total_tokens": 1829, "input_tokens_details": {"cache_write_tokens": 1812}} still folds 1,812 into output and reports 1,816 for a call that generated 4. I ran it: that's the same payload the new test at line 353 asserts returns 4 on the chat shape.

cache_read is already handled on both branches since each one computes it locally, so as it stands the two API shapes disagree about the same convention. Reading cache_write off prompt_details / input_details inside each branch would cover both — does that work, or was the Responses path deliberately out of scope here?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small thing, related to the above: the chat branch already builds this same dict as prompt_details at line 206 — could cache_write be computed there, next to cache_read? That drops the second _safe_dict lookup and gives a natural place to select the container per API shape, which is the thing I flagged just above.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

How confident are we that cache_write_tokens is the only spelling we'll see in this position? OpenAI-compatible proxies fronting Anthropic (LiteLLM and similar) tend to re-report cache_creation_input_tokens, and Anthropic itself also emits cache_creation.ephemeral_5m_input_tokens. Either one inside prompt_tokens_details would leave cache_write at 0 and fold straight into output again.

The drift sweep at lines 223-226 already walks every unmapped nested key — could the accounting be derived from that rather than from a name match, so a new spelling is covered on arrival? If a name list is the pragmatic call for now, maybe worth a line in the comment saying that's a known limit.

unaccounted = declared_total - (input_tokens + output_tokens + reasoning + cache_read + cache_write)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I want to make sure I've followed the reasoning in the comment above about why this can't suppress a genuine fold. The argument holds when the cache count is the only thing making the delta positive — but what about a subtractive surface carrying a large cached block and a real remainder?

Concretely, Gemini through Google's own OpenAI-compat layer — the surface the comment at lines 234-239 says this guard was written for, and which pricing.py:95 lists in _INPUT_INCLUDES_CACHE_READ precisely because cached_tokens sits inside prompt_tokens: prompt_tokens=1200, cached_tokens=1000, completion_tokens=47, total_tokens=2396. I ran it both ways — on main that folds 1,149; on this branch the delta becomes 2396 - (1200 + 47 + 1000) and it folds 149. So 1,000 genuinely generated tokens go unbilled, silently and with no on_error, which is the failure this guard exists to prevent.

So the subtraction wants to be conditional rather than unconditional. I tried two ways of deciding it from the payload and neither is sound, which seems worth writing down before someone reaches for one:

  • Prove additivity from the totals — subtract only when input + output + reasoning + cache_read + cache_write <= declared_total. Holds on fixture 12 (7 + 6 + 4805 == 4818), but the condition reduces to cache_read <= remainder, so a smaller cached block passes the gate and the subtraction still runs: at cached_tokens=500 on the payload above it folds 649 rather than 1,149.
  • cache_read > input_tokens — no subtractive payload can report more cached than prompt tokens, so this never mis-fires on Gemini. But it breaks the other way: a Cortex call with a long fresh prompt over a small cached prefix (prompt_tokens=2000, cached_tokens=500, total_tokens=2506) reads as subtractive, and 500 tokens that were never generated get folded into output.

The payload on its own doesn't carry the convention — the only thing here that knows it is the table this repo already has. Would keying the subtraction off _token_semantics / _INPUT_INCLUDES_CACHE_READ (pricing.py:95) work, so this line and the cost path can't answer the same question differently? That's the same coupling I raised on the Snowflake tests, from the other end.

Entirely possible there's a reason that shape can't reach here — is there one?

if unaccounted > 0:
output_tokens += unaccounted
extras["unaccounted_output_tokens"] = unaccounted
Expand Down
124 changes: 124 additions & 0 deletions tests/unit/adapters/fixtures/capture_snowflake_cortex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Capture Snowflake Cortex responses off the OpenAI-compatible endpoint.

Saves to:
tests/unit/adapters/fixtures/openai_native/11_snowflake_cortex_plain_chat.json
tests/unit/adapters/fixtures/openai_native/12_snowflake_cortex_cache_chat.json

These live under `openai_native/` on purpose: Cortex answers on an OpenAI-wire
endpoint, so `extract_openai_native` / `extractOpenAINative` is the adapter that
serves them. They are the surface that proves the `total_tokens` reconciliation
cannot assume OpenAI's subtractive cache convention — on Cortex, `cached_tokens`
sits OUTSIDE `prompt_tokens` and INSIDE `total_tokens`.

Two things about Cortex that this script encodes, both measured 2026-08-25:

* Caching only happens with an explicit Anthropic-style `cache_control` part.
The same 4,800-token prompt sent twice WITHOUT it reports `cached_tokens: 0`
both times, so the "call1 then call2" pattern the OpenAI cache fixtures use
captures nothing here.
* `max_tokens` is rejected outright ("deprecated in favor of
max_completion_tokens"), unlike OpenAI which still accepts it.

Requires a Snowflake account with the Cortex REST endpoint entitled — it returns
403 `003001` otherwise, which is an account-level grant no config can work around.

SNOWFLAKE_HOST=<org>-<account>.snowflakecomputing.com \
SNOWFLAKE_PAT=<programmatic access token> \
python3 capture_snowflake_cortex.py

Idempotent: skips files that already exist. Re-run after deleting one to refresh it.
"""

from __future__ import annotations

import json
import os
import pathlib
import sys

import requests

MODEL = "claude-sonnet-4-5"
OUT = pathlib.Path(__file__).parent / "openai_native"

# Long enough to clear Anthropic's minimum cacheable prefix. Deliberately dull,
# fixed text: the fixture must not carry anything account- or person-identifying.
CACHEABLE_PREFIX = "Reference notes on widget calibration tolerances, revision seven. " * 400


def call(host: str, pat: str, body: dict) -> dict:
r = requests.post(
f"https://{host}/api/v2/cortex/v1/chat/completions",
headers={
"Authorization": f"Bearer {pat}",
"X-Snowflake-Authorization-Token-Type": "PROGRAMMATIC_ACCESS_TOKEN",
"Content-Type": "application/json",
"Accept": "application/json",
},
json=body,
timeout=120,
)
if r.status_code != 200:
sys.exit(f"Cortex returned {r.status_code}: {r.text[:300]}")
return r.json()


def save(name: str, response: dict) -> None:
path = OUT / name
if path.exists():
print(f"skip {name} (exists)")
return
path.write_text(json.dumps({"_model_id": MODEL, "_response": response}, indent=2) + "\n")
print(f"wrote {name}")


def main() -> None:
host = os.environ.get("SNOWFLAKE_HOST")
pat = os.environ.get("SNOWFLAKE_PAT")
if not host or not pat:
sys.exit("set SNOWFLAKE_HOST and SNOWFLAKE_PAT")

save(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think the existence check doesn't get a chance to run here: call(...) is evaluated as an argument to save(...), so both requests fire before save reaches if path.exists(). On a fresh checkout where both fixtures are already committed, re-running would issue two live Cortex calls — including the 4,800-token cacheable one — and then print skip … (exists).

The existing capture.py checks the path before the request (lines 119 and 141). Would doing the same, or passing a thunk, match what the docstring's "Idempotent" line at line 29 is after?

"11_snowflake_cortex_plain_chat.json",
call(
host,
pat,
{
"model": MODEL,
"messages": [{"role": "user", "content": "What is 2 + 2? Answer in one word."}],
"max_completion_tokens": 32,
},
),
)

# The regression fixture. `cache_control` is what makes Cortex report a cached
# block at all, and the resulting payload is the one that used to inflate
# `output` by the whole cached count.
save(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does a single call against a cold cache actually produce cached_tokens: 4805? With Anthropic-style ephemeral caching I'd expect the first cache_control call to record a cache write, and only a second call within the TTL to report the read — so following the docstring's "Re-run after deleting one to refresh it" more than ~5 minutes after a previous run might come back with cached_tokens: 0.

If that's the case the test would fail on assert u.cache_read == 4805 and assert u.output == 6, and the quickest way out would be exactly the hand-edit the test header warns against. The OpenAI cache fixtures handle this by capturing both calls and saving the second (03/04) — would that pattern work here, or is Cortex warm enough on the first call that it isn't a concern in practice?

"12_snowflake_cortex_cache_chat.json",
call(
host,
pat,
{
"model": MODEL,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": CACHEABLE_PREFIX,
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": "Reply with one word."},
],
}
],
"max_completion_tokens": 32,
},
),
)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"_model_id": "claude-sonnet-4-5",
"_response": {
"choices": [
{
"finish_reason": "",
"index": 0,
"message": {
"annotations": null,
"audio": {
"data": "",
"expires_at": 0,
"id": "",
"transcript": ""
},
"content": "Four",
"function_call": {
"arguments": "",
"name": ""
},
"refusal": "",
"role": "assistant",
"tool_calls": null
}
}
],
"created": 1787679484,
"id": "",
"model": "claude-sonnet-4-5",
"object": "chat.completion",
"service_tier": "",
"system_fingerprint": "",
"usage": {
"completion_tokens": 4,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0
},
"prompt_tokens": 21,
"prompt_tokens_details": {
"audio_tokens": 0,
"cache_write_tokens": 0,
"cached_tokens": 0
},
"total_tokens": 25
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"_model_id": "claude-sonnet-4-5",
"_response": {
"choices": [
{
"finish_reason": "",
"index": 0,
"message": {
"annotations": null,
"audio": {
"data": "",
"expires_at": 0,
"id": "",
"transcript": ""
},
"content": "Acknowledged.",
"function_call": {
"arguments": "",
"name": ""
},
"refusal": "",
"role": "assistant",
"tool_calls": null
}
}
],
"created": 1787679488,
"id": "",
"model": "claude-sonnet-4-5",
"object": "chat.completion",
"service_tier": "",
"system_fingerprint": "",
"usage": {
"completion_tokens": 6,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0
},
"prompt_tokens": 7,
"prompt_tokens_details": {
"audio_tokens": 0,
"cache_write_tokens": 0,
"cached_tokens": 4805
},
"total_tokens": 4818
}
}
}
Loading
Loading