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:
co-authored by
Claude Opus 5
parent
736b6a9a82
commit
483de9f88a
@@ -295,6 +295,7 @@ impl<V: PhaseVm> TurnExecutor for MicroVmTurnExecutor<V> {
|
||||
// the honest value for "not measured on this path".
|
||||
tokens: 0,
|
||||
gated: Vec::new(),
|
||||
spend: Default::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ impl TurnExecutor for SubTopologyExecutor {
|
||||
output: record.final_output,
|
||||
tokens: record.totals.tokens,
|
||||
gated,
|
||||
spend: Default::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ fn step(
|
||||
output: output.into(),
|
||||
gated: Vec::new(),
|
||||
tokens: 0,
|
||||
spend: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -683,6 +683,7 @@ impl ZeroClawDriveExecutor {
|
||||
{
|
||||
let mut output = String::new();
|
||||
let mut tokens: u64 = 0;
|
||||
let mut spend = cm_orchestrator::Spend::default();
|
||||
let mut gated: Vec<GatedAction> = Vec::new();
|
||||
let mut trace = ToolTrace::default();
|
||||
|
||||
@@ -720,6 +721,23 @@ impl ZeroClawDriveExecutor {
|
||||
let input = v.get("input_tokens").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
let out = v.get("output_tokens").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
tokens = input + out;
|
||||
// The frame has always carried these; only
|
||||
// `tokens` was read, so every agent turn was
|
||||
// charged with no record of who was paid.
|
||||
spend = cm_orchestrator::Spend {
|
||||
input_tokens: input,
|
||||
output_tokens: out,
|
||||
provider: v
|
||||
.get("provider")
|
||||
.and_then(|p| p.as_str())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(str::to_string),
|
||||
model: v
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string),
|
||||
};
|
||||
break;
|
||||
}
|
||||
"approval_request" => {
|
||||
@@ -807,6 +825,7 @@ impl ZeroClawDriveExecutor {
|
||||
output: output.trim().to_string(),
|
||||
tokens,
|
||||
gated,
|
||||
spend,
|
||||
},
|
||||
trace,
|
||||
))
|
||||
@@ -968,7 +987,10 @@ mod tests {
|
||||
json!({"type": "session_start", "session_id": "s1", "resumed": false}),
|
||||
json!({"type": "chunk", "content": "hel"}),
|
||||
json!({"type": "chunk", "content": "lo"}),
|
||||
json!({"type": "done", "input_tokens": 5, "output_tokens": 7}),
|
||||
// The real frame carries model and provider; the executor read
|
||||
// only the two token counts until 2026-09-14.
|
||||
json!({"type": "done", "input_tokens": 5, "output_tokens": 7,
|
||||
"model": "claude-sonnet-5", "provider": "anthropic"}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1068,6 +1090,16 @@ mod tests {
|
||||
let out = exec.run_turn(req()).await.unwrap();
|
||||
assert_eq!(out.output, "hello");
|
||||
assert_eq!(out.tokens, 12);
|
||||
assert_eq!(
|
||||
out.spend,
|
||||
cm_orchestrator::Spend {
|
||||
input_tokens: 5,
|
||||
output_tokens: 7,
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-sonnet-5".into()),
|
||||
},
|
||||
"the split and the provider must survive the done frame, not just the sum"
|
||||
);
|
||||
assert!(out.gated.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user