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 -9
View File
@@ -36,21 +36,36 @@ pub async fn charge(
run_id: Option<Uuid>,
input_tokens: u64,
output_tokens: u64,
// Who was paid, and for which mission. `None` where the executor did not
// say. Until 2026-09-14 no agent-side row carried these, so the larger
// half of the spend could not be asked per provider — the judge's half
// could, and that is how a plan emptying twice went unexplained.
provider: Option<&str>,
model: Option<&str>,
mission_id: Option<Uuid>,
) -> Result<i64, BillingError> {
let owed = credits_for_tokens(input_tokens + output_tokens);
let mut tx = pool.begin().await?;
sqlx::query!(
// `sqlx::query`, not `query!`: the macro pins this statement to offline
// metadata that a schema change then has to regenerate against a live
// database, and the columns added by migration 0085 are nullable text
// and uuid — nothing here that a compile-time check would catch.
sqlx::query(
"INSERT INTO usage_events
(workspace_id, agent_id, run_id, kind, tokens_in, tokens_out, credits)
VALUES ($1, $2, $3, 'llm_tokens', $4, $5, $6)",
workspace_id.as_uuid(),
agent_id.as_uuid(),
run_id,
input_tokens as i64,
output_tokens as i64,
sqlx::types::BigDecimal::from(owed),
(workspace_id, agent_id, run_id, kind, tokens_in, tokens_out, credits,
provider, model, mission_id)
VALUES ($1, $2, $3, 'llm_tokens', $4, $5, $6, $7, $8, $9)",
)
.bind(workspace_id.as_uuid())
.bind(agent_id.as_uuid())
.bind(run_id)
.bind(input_tokens as i64)
.bind(output_tokens as i64)
.bind(sqlx::types::BigDecimal::from(owed))
.bind(provider)
.bind(model)
.bind(mission_id)
.execute(&mut *tx)
.await?;
+2 -2
View File
@@ -62,7 +62,7 @@ async fn charges_span_lots_oldest_first_and_record_usage() {
.unwrap();
// 2500 tokens → 3 credits: drains the first lot (2) then one more.
let deducted = charge(&pool, ws.id, agent.id, Some(run_id), 1500, 1000)
let deducted = charge(&pool, ws.id, agent.id, Some(run_id), 1500, 1000, None, None, None)
.await
.unwrap();
assert_eq!(deducted, 3);
@@ -96,7 +96,7 @@ async fn an_empty_workspace_records_usage_but_clamps_at_zero() {
.unwrap();
// Owes 5, only 1 available: deducts 1, balance hits zero, never negative.
let deducted = charge(&pool, ws.id, agent.id, Some(run_id), 4000, 500)
let deducted = charge(&pool, ws.id, agent.id, Some(run_id), 4000, 500, None, None, None)
.await
.unwrap();
assert_eq!(deducted, 1);