feat(billing): agent-side spend records who was paid

Judge spend gained provider, model and mission on 2026-09-14; agent spend —
the larger half — did not. The runtime's `done` frame has always carried
`model` and `provider` beside the two token counts, and `topology_exec` read
only the counts, summed them, and charged the sum as output with no record of
which provider served the turn.

`TurnOutcome` and `StepRecord` carry a `Spend` now (input/output split,
provider, model), the worker passes it through `cm_billing::charge` along with
the mission id, and the chat runtime records the model it requested — that
loop drives one provider with no chain, so requested is answered. A bare
model name is recorded without a guessed family. `StepRecord.spend` is
`serde(default)` so journaled checkpoints from before this field still load,
and `tokens` stays as the total every reader keys on.

`charge` moved from `query!` to `query`: the macro pins the statement to
offline metadata that a schema change then has to regenerate against a live
database, for columns that are nullable text and uuid.

The done-frame test now asserts the split and the provider survive, not just
the sum.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-14 08:17:20 -05:00
co-authored by Claude Opus 5
parent 736b6a9a82
commit 483de9f88a
13 changed files with 128 additions and 18 deletions
+24 -5
View File
@@ -556,16 +556,35 @@ async fn drive<E: TurnExecutor>(
}
if let Some(agent_id) = agent_of.get(&last.node_id).copied() {
if last.tokens > 0 {
// The executor reports ONE total, not an in/out split.
// Credits price the sum, so cost is right; the columns
// record it as output rather than inventing a split.
// The split and the provider come from the runtime's
// `done` frame via `StepRecord.spend`. An executor
// that reports only a total leaves the split at 0/0
// and the total goes on the output side, as before.
let (tin, tout) = if last.spend.input_tokens + last.spend.output_tokens > 0
{
(last.spend.input_tokens, last.spend.output_tokens)
} else {
(0, last.tokens as u64)
};
let mission_id: Option<Uuid> = sqlx::query_scalar::<_, Option<Uuid>>(
"SELECT mission_id FROM topology_runs WHERE id = $1",
)
.bind(id)
.fetch_optional(&pool)
.await
.ok()
.flatten()
.flatten();
if let Err(e) = cm_billing::charge(
&pool,
cm_domain::WorkspaceId::from(workspace_id),
cm_domain::AgentId::from(agent_id),
None,
0,
last.tokens as u64,
tin,
tout,
last.spend.provider.as_deref(),
last.spend.model.as_deref(),
mission_id,
)
.await
{