GitHubBook a demoStart routing
Browse docs

Attribute cost per agent step

See dollars per trace, per agent, and per step for a multi-step agent by putting explicit x-tt-* identity headers on the wire, then reading them back with tokentriage top.

Last updated

On this page

Get exact dollars per trace, per agent, and per step for a multi-step agent by stamping explicit x-tt-* identity headers on each outbound LLM call, then reading the ledger back with tokentriage top.

TokenTriage attributes cost by trace identity, and identity comes exclusively from explicit request headers — never a timing- or prefix-based guess. A request with no x-tt-trace-id is still fully costed as a standalone row; it just isn’t grouped into a trace. That is the honest trade: a narrow ledger over a wrong one.

Prerequisites

  • A running proxy. The examples below assume the deterministic default data-plane port 8787:

    $ tokentriage run --simple openai/gpt-4o-mini --complex openai/gpt-4o
  • Your agent controls its own HTTP client (LangGraph, CrewAI, LlamaIndex, or a hand-written loop) so it can set outbound headers per request. Claude Code cannot — see the note at the end.

The identity headers

These four headers carry trace identity. The names and casing are the wire contract exactly as internal/trace/extract.go reads them:

Header Sets Cadence
x-tt-trace-id Groups requests into one agent trace. Once per top-level run.
x-tt-step-id Identifies the step within the trace — enables the per-step tree. Fresh per LLM call.
x-tt-parent-id The parent step id — draws the sub-agent fan-out edge. On delegation only.
x-tt-agent Logical agent / sub-agent name (e.g. planner, coder). Per role.

One optional header refines the picture: x-tt-step-class — an explicit step class, one of user_turn, tool_loop, or provider_state. When absent, the proxy derives a structural guess and records step_class_source: derived; the guess feeds routing but never trace identity.

All x-tt-* headers are stripped before the request is forwarded upstream — the provider never sees them.

Step 1 — Stamp the headers on each call

Point your client’s base_url at the proxy and set a stable trace id for the whole run, plus a fresh step id (and the parent edge for sub-agents) on every call.

Raw headers (any HTTP client)

import os, uuid, urllib.request, json

BASE = os.environ.get("TOKENTRIAGE_BASE_URL", "http://127.0.0.1:8787").rstrip("/")
TRACE_ID = f"run-{uuid.uuid4().hex[:12]}"   # one stable id for the whole run

def call_llm(messages, *, agent, parent_step=None, step_class="user_turn"):
    step_id = f"step-{uuid.uuid4().hex[:12]}"
    headers = {
        "Content-Type": "application/json",
        "x-tt-trace-id": TRACE_ID,
        "x-tt-step-id": step_id,
        "x-tt-agent": agent,             # "planner" | "coder" | "Task:reviewer"
        "x-tt-step-class": step_class,   # user_turn | tool_loop | provider_state
    }
    if parent_step:
        headers["x-tt-parent-id"] = parent_step   # fan-out edge for the sub-agent
    payload = json.dumps({"model": "gpt-4o-mini", "messages": messages}).encode()
    req = urllib.request.Request(
        f"{BASE}/v1/chat/completions", data=payload, headers=headers, method="POST"
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return step_id, json.loads(resp.read())

# A two-step agent: a planner turn, then a coder sub-agent linked to the planner step.
planner_step, _ = call_llm(
    [{"role": "user", "content": "Plan a haiku about ledgers."}], agent="planner")
call_llm(
    [{"role": "user", "content": "Write the haiku."}],
    agent="coder", parent_step=planner_step, step_class="tool_loop")

SDK (mints and propagates the ids for you)

import tokentriage as tt

tt.init(base_url="http://127.0.0.1:8787")

with tt.trace(user="u-42", tenant="acme", feature="chat"):
    with tt.step(agent="planner", step_class="user_turn") as planner:
        resp = post(tt.headers(), plan_messages)   # your HTTP call
        tt.receipt(resp)
    with tt.step(agent="coder", step_class="tool_loop", parent=planner.step_id):
        resp = post(tt.headers(), code_messages)

The SDK emits the byte-identical wire — same header names, same parent edge — only the minted id values differ from hand-picked ones. See the Python SDK reference for trace() / step() / headers().

Step 2 — Read back the per-step tree

You already have TRACE_ID from the run above; feed it to tokentriage top --trace. Steps are linked by x-tt-parent-idx-tt-step-id and rendered as an indented tree with a per-step cost column:

$ tokentriage top --trace run-a1b2c3d4e5f6

You should see (illustrative):

trace run-a1b2c3d4e5f6  (2 steps)
STEP                CLASS      AGENT    MODEL         COST       IN    OUT  CACHE-R
step-9f8e7d6c5b4a   user_turn  planner  gpt-4o-mini   $0.000180  120   240  0
  step-1a2b3c4d5e6f  tool_loop  coder    gpt-4o        $0.004500  310   520  0
TOTAL                                                  $0.004680  430   760  0

The child step is indented under its parent because the x-tt-parent-id edge was on the wire — not inferred. A step whose price could not be resolved shows - in the COST column and is excluded from TOTAL (a $0 there would falsely claim the step was free).

Step 3 — Roll up by agent or step class

To see spend split across roles or step classes across all traces, use --by:

$ tokentriage top --by agent
AGENT    REQS  INPUT  OUTPUT  CACHE-R  CACHE-W  REASON  CACHE-SAV  COST       P50        P90        P99
planner  8     960    1920    0        0        0       0.0%       $0.001440  $0.000180  $0.000180  $0.000180
coder    8     2480   4160    0        0        0       0.0%       $0.036000  $0.004500  $0.004500  $0.004500
TOTAL    16    3440   6080    0        0        0       0.0%       $0.037440

The accepted --by dimensions are trace, agent, model, step-class, and tool. So tokentriage top --by step-class breaks spend down by user_turn / tool_loop / provider_state, and --by trace lists every run. Add --since 24h (also 30d, 2w, or an RFC3339 timestamp) to window the rollup.

Claude Code: per-session, not per-step

tokentriage wrap runs a child command with ANTHROPIC_BASE_URL / OPENAI_BASE_URL pointed at the proxy and a fresh x-tt-trace-id injected via ANTHROPIC_CUSTOM_HEADERS:

$ tokentriage wrap --agent claude -- claude
tokentriage wrap: base=http://127.0.0.1:8787 trace=tt_… agent="claude"

Because ANTHROPIC_CUSTOM_HEADERS attaches only static headers, every call in the session shares one trace id and (with --agent) one agent name — so you get honest per-session grouping, not per-step. Claude Code exposes no per-subagent wire identity, so per-step attribution is out of scope there. Frameworks that own the HTTP client (Step 1) get the full per-step tree.