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")import { randomUUID } from "node:crypto";
const short = () => randomUUID().replace(/-/g, "").slice(0, 12);
const BASE = (process.env.TOKENTRIAGE_BASE_URL ?? "http://127.0.0.1:8787").replace(/\/+$/, "");
const TRACE_ID = `run-${short()}`; // one stable id for the whole run
async function callLLM(
messages: unknown[],
{ agent, parentStep, stepClass = "user_turn" }:
{ agent: string; parentStep?: string; stepClass?: string },
): Promise<[string, unknown]> {
const stepId = `step-${short()}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-tt-trace-id": TRACE_ID,
"x-tt-step-id": stepId,
"x-tt-agent": agent, // "planner" | "coder" | "Task:reviewer"
"x-tt-step-class": stepClass, // user_turn | tool_loop | provider_state
};
if (parentStep) headers["x-tt-parent-id"] = parentStep; // fan-out edge for the sub-agent
const resp = await fetch(`${BASE}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({ model: "gpt-4o-mini", messages }),
});
return [stepId, await resp.json()];
}
// A two-step agent: a planner turn, then a coder sub-agent linked to the planner step.
const [plannerStep] = await callLLM(
[{ role: "user", content: "Plan a haiku about ledgers." }], { agent: "planner" });
await callLLM(
[{ role: "user", content: "Write the haiku." }],
{ agent: "coder", parentStep: plannerStep, stepClass: "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)import * as tt from "tokentriage";
tt.init({ baseUrl: "http://127.0.0.1:8787" });
await tt.trace({ user: "u-42", tenant: "acme", feature: "chat" }, async () => {
let plannerId: string | undefined;
await tt.step({ agent: "planner", stepClass: "user_turn" }, async () => {
plannerId = tt.currentStep()?.stepId;
const resp = await post(tt.headers(), planMessages); // your HTTP call
tt.receipt(resp);
});
await tt.step({ agent: "coder", stepClass: "tool_loop", parent: plannerId }, async () => {
const resp = await post(tt.headers(), codeMessages);
});
});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-id → x-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.