keelwave
Guides

Cost Tracking

Attribute token spend to runs, steps, and models.

Tokens and cost are recorded on three tables. LLM calls land in ai_traces with per-call token counts and cost. Agent steps can carry their own tokens and cost_usd. Agent runs hold rollups in total_tokens and total_cost_usd.

The important rule: a run's totals prefer its linked traces. You almost never compute them yourself.

Cost is priced server-side

When a trace arrives at POST /v1/ingest/ai with both input_tokens and output_tokens, the API prices it from a per-model rate catalog compiled into the binary and overwrites whatever cost_usd the client sent. A client-supplied cost_usd is kept only when the model has no known rate.

Model lookup is case-insensitive and trims whitespace. If the exact ID is not in the catalog, a trailing dated snapshot suffix — -YYYY-MM-DD or @YYYYMMDD — is stripped and the base model's rate is tried, so gpt-4o-2099-01-01 falls back to gpt-4o. Rates are stored as USD per 1,000,000 tokens:

cost = input_tokens / 1e6 * input_per_mtok
     + output_tokens / 1e6 * output_per_mtok

total_tokens is filled in the same request: if you omit it but send both token counts, the API stores their sum.

This is why a fresh self-hosted install reports real costs with no configuration. You only need to send token counts.

How run totals derive from traces

A trace links to a run through agent_run_id. Once linked, the run's totals are kept in sync with its traces in two independent places.

At write time. A trigger on ai_traces recomputes the parent run's total_tokens and total_cost_usd after each insert. Tokens are derived null-safely, preferring the trace's own total_tokens and falling back to input_tokens + output_tokens:

total_tokens = coalesce((
    SELECT sum(coalesce(t.total_tokens, t.input_tokens + t.output_tokens))
    FROM ai_traces t
    WHERE t.agent_run_id = r.id AND t.project_id = r.project_id
), r.total_tokens)

The outer coalesce is what makes manual reporting still work: when a run has no linked traces the subquery is NULL and the stored rollup is left alone.

This trigger exists because traces usually arrive after the run finishes. A rollup computed at finish time would miss them, and the continuous aggregate that feeds cost alerting reads the raw columns and cannot join ai_traces.

At read time. The query layer applies the same prefer-traces logic when serving runs, so the value you read is correct even before the trigger has caught up.

Traces win over the rollup. If you report total_cost_usd on finish and emit linked traces for the same run, the trace sum replaces your value rather than adding to it. Pick one strategy per run.

Steps also accept tokens and cost_usd, and the SDKs accumulate those into the totals they send at finish. Step-level values are not summed into run totals by the server — they are there for per-step attribution in the timeline.

Reporting tokens and cost

Automatically, via adapters

The provider adapters read usage off each response and send it for you, linking to the active run when one is open. Nothing else is required.

import anthropic
from keelwave import Keelwave

client = Keelwave(api_key=os.environ["KEELWAVE_API_KEY"])
llm = client.wrap_anthropic(anthropic.Anthropic())

with client.run("research-agent") as run:
    # input_tokens / output_tokens read from resp.usage, cost priced server-side
    resp = llm.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": "summarise this"}],
    )

wrap_openai() does the same for the OpenAI client.

Manually, per call

Send a trace yourself when you are not using an adapter. Pass agent_run_id to attribute it to a run:

client.ingest_ai(
    model="gpt-4o",
    status="success",
    provider="openai",
    input_tokens=1200,
    output_tokens=350,
    latency_ms=820,
    agent_run_id=run.id,
)

status must be one of success, error, or timeout.

Per step

For non-LLM work you want costed, attach the numbers to the step:

run.step("think", content="planning", tokens=420)
run.tool_call("web_search", input={"q": q}, output=res, tokens=90, cost_usd=0.0004)

The SDK adds these into the run's running totals, which are sent at finish.

On finish

If you track spend entirely yourself, report it once when the run closes. The context managers do this from their accumulated totals; the raw call is:

client.ingest_agent_run_finish(
    run_id,
    timestamp=timestamp,
    status="completed",
    termination_reason="clean",
    total_steps=12,
    total_tokens=48_000,
    total_cost_usd=0.42,
    duration_ms=8_400,
)

timestamp must be the value returned when the run was started — it is part of the hypertable primary key.

Where totals surface

GET /v1/projects/{projectID}/agent/runs and GET /v1/projects/{projectID}/agent/runs/{runID} return total_tokens and total_cost_usd with the prefer-traces derivation already applied. total_cost_usd is omitted when neither traces nor a rollup reported a cost.

Aggregates:

  • GET /v1/projects/{projectID}/agent/summaryavg_cost_usd and avg_tokens for the window and the one before it, so you can render a delta. avg_cost_usd is null when no run in the window reported a cost.
  • GET /v1/projects/{projectID}/agent/health — the same two averages broken down per agent_name.

Alert rules accept a cost_burn signal, evaluated against the raw run columns the trigger keeps in sync.

On this page