Step classes
Three words that say what kind of model call this is. What each means, and what the proxy fills in when you leave it out.
On this page
An agent loop and a chat turn look identical over HTTP. Both are a POST with messages in it. They behave nothing alike: a chat turn is one call with a fresh prompt, and a tool loop is six calls that share almost all of their input and reuse the provider’s prompt cache across the whole run.
If your cost report cannot tell them apart, the most expensive shape in your traffic is
invisible. x-tt-step-class is the header that tells them apart, and this page is about what
the three values mean and what happens when you leave it out.
The three values
The vocabulary is closed. A value outside it is ignored rather than stored.
| Value | The call is | Typical shape |
|---|---|---|
user_turn |
a person or a caller asking something new | one call, fresh prompt, no tool traffic |
tool_loop |
one iteration of an agent working through tools | many calls, near-identical prefix, tool results in the body |
provider_state |
a call that maintains provider-side state rather than answering | no user-visible output |
provider_state is the one people ask about. It covers calls whose purpose is upkeep rather than
an answer, and it exists so that housekeeping traffic can be separated from work when you split
spend by class.
What happens when you leave it out
The class is always present on the row. When the header is absent the proxy guesses one from the shape of the request body, and records that it guessed.
The guess is a single rule: a request that carries a tools array or any tool-result content is
classed tool_loop, and anything else is classed user_turn. provider_state cannot be seen in
the body and is never guessed.
The row keeps the two facts apart:
| Field | Values | Means |
|---|---|---|
step_class |
one of the three | the class in force for this row |
step_class_source |
header or derived |
whether you said so or the proxy guessed |
Here is that mix, counted over one lab run against a mock upstream:
61 step_class='user_turn' source='derived' trace.source='none'
3 step_class='tool_loop' source='header' trace.source='x-tt'
1 step_class='user_turn' source='header' trace.source='x-tt'
1 step_class='user_turn' source='derived' trace.source='x-tt'
The last line is the interesting one: that request carried a trace id, a step id and an agent name, and no step class. Identity came from the headers and the class came from the guess, which is a perfectly normal row and one you would misread if you assumed the two travel together.
The guess informs routing, and never identity
The derived class is a real input. Routing conditions can read trace.step_class, so a rule that
sends tool loops to a cheaper tier fires on derived classes too.
What the guess cannot do is change who a row belongs to. Trace, step, parent and agent come from their own headers, and no amount of structural inference moves a row into a different trace. The split matters because the two carry different risk. A wrong routing input costs you one suboptimal decision that the ledger records and you can audit later. A wrong identity silently corrupts every total you group by it.
What tool_loop changes at request time
Marking a call tool_loop has one behavioural consequence beyond reporting, and it is worth
knowing before you set it.
When routing.cache_affinity is enabled, a tool_loop step is pinned to its served tier from
the first turn, rather than waiting for an observed cache read to trigger a pin. The reason is
economic: a tool loop reuses a large cached prefix across its iterations, and letting per-step
routing move the loop between upstreams forfeits the provider’s prompt-cache discount on every
move. Traffic of any other class pins reactively, once a cache read is actually seen.
That is the only place the class changes what happens to the request. Everywhere else it is a label. Routing limits covers affinity in full.
Choosing between the header and the guess
Set the header when you know. It costs one line and it turns a guess into a statement.
Rely on the guess when the caller is out of your control. It is right about the common case, and
step_class_source keeps it honest for anyone reading the numbers later.
Do not set the header to a value you are unsure of in order to avoid a derived marker. A
mislabelled tool_loop pins affinity and skews the class split, and both are harder to notice
than a derived marker you can filter on.