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
+10
View File
@@ -875,6 +875,13 @@ impl Runtime {
);
// Meter the run (§8.4). Billing failures never fail the run — the
// usage ledger is the recovery path.
// This loop drives ONE provider with no fallback chain, so the model
// the request named is the model that answered. The family is taken
// only from an explicit `provider:` prefix — a bare model name is
// recorded as-is with no family rather than guessed at, and a chat
// run is not a mission.
let model = state.request.model.as_str();
let provider = model.split_once(':').map(|(p, _)| p);
if let Err(error) = cm_billing::charge(
&self.inner.pool,
state.workspace_id,
@@ -882,6 +889,9 @@ impl Runtime {
Some(run_id),
state.input_tokens,
state.output_tokens,
provider,
Some(model),
None,
)
.await
{