harness-core: model pricing + per-step cost accumulation (M3)

- types::ModelCost {input, output, cache_read, cache_write} (USD per 1M tokens)
  with cost_of(usage); reasoning tokens are billed within output by our providers
  so they are not charged separately. ModelInfo gains cost + reasoning/tool_call/
  attachment capability flags (all #[serde(default)] for forward-compat).
- Engine: RunConfig.cost threads pricing into process_step; on_finish now stamps
  the real dollar cost onto the StepFinish part; StepOutcome carries per-step cost.
- run_session accumulates each step's usage and cost onto the session (previously
  never updated) and republishes SessionUpdated — best-effort, store errors logged
  not fatal.
- Store gains a single-session getter (Session cmd + get_session).
- App passes cost: None for now (real rates land with models.dev wiring).
- Tests: ModelCost::cost_of math (+ reasoning exclusion), and the multi-step
  engine test now asserts session usage/cost accumulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:45:21 +02:00
co-authored by Claude Opus 4.8
parent 39d62348d8
commit 2f3dfe2305
7 changed files with 151 additions and 11 deletions
+58 -6
View File
@@ -20,6 +20,33 @@ pub struct RunConfig {
pub temperature: Option<f32>,
pub max_steps: u32,
pub instructions: Vec<String>,
/// Pricing for `model`, from models.dev metadata. `None` leaves cost at 0.
pub cost: Option<crate::types::ModelCost>,
}
/// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is
/// best-effort: a store error here is logged, not surfaced as a run failure.
async fn accumulate_session_usage(
ctx: &StepContext,
usage: &crate::types::TokenUsage,
cost: f64,
now: i64,
) {
match ctx.store.session(ctx.session_id.clone()).await {
Ok(Some(mut session)) => {
session.usage.add(usage);
session.cost += cost;
session.updated_at = now;
if let Err(e) = ctx.store.upsert_session(session.clone()).await {
tracing::warn!(error = %e, "failed to persist session usage");
return;
}
ctx.bus
.publish(crate::event::AppEvent::SessionUpdated { session });
}
Ok(None) => {}
Err(e) => tracing::warn!(error = %e, "failed to load session for usage accounting"),
}
}
/// opencode's exit condition: keep stepping while the last assistant turn asked for more
@@ -221,17 +248,24 @@ pub async fn run_session(
&ctx,
run_config.model.clone(),
&run_config.agent_name,
run_config.cost,
&mut doomloop,
)
.await;
match step {
Ok(outcome) if outcome.aborted => return RunOutcome::Aborted,
Ok(outcome) => match outcome.result {
StepResult::Continue => continue,
StepResult::Stop => return RunOutcome::Stopped,
StepResult::Compact => return RunOutcome::Stopped, // stub until M6
},
Ok(outcome) if outcome.aborted => {
accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await;
return RunOutcome::Aborted;
}
Ok(outcome) => {
accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await;
match outcome.result {
StepResult::Continue => continue,
StepResult::Stop => return RunOutcome::Stopped,
StepResult::Compact => return RunOutcome::Stopped, // stub until M6
}
}
Err(step_err) if matches!(step_err.source, ProviderError::Cancelled) => {
return RunOutcome::Aborted;
}
@@ -422,11 +456,28 @@ mod tests {
temperature: None,
max_steps: 10,
instructions: Vec::new(),
// $3/1M input, $15/1M output.
cost: Some(crate::types::ModelCost {
input: 3.0,
output: 15.0,
..Default::default()
}),
};
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
assert!(matches!(outcome, RunOutcome::Stopped));
// Both steps' usage and cost accumulate onto the session.
let session = store.session(session_id.clone()).await.unwrap().unwrap();
assert_eq!(session.usage.input, 30);
assert_eq!(session.usage.output, 13);
// (10*3 + 5*15)/1e6 + (20*3 + 8*15)/1e6 = 0.000105 + 0.00018
assert!(
(session.cost - 0.000_285).abs() < 1e-9,
"cost = {}",
session.cost
);
let messages = store.messages(session_id.clone()).await.unwrap();
assert_eq!(
messages.len(),
@@ -517,6 +568,7 @@ mod tests {
temperature: None,
max_steps: 10,
instructions: Vec::new(),
cost: None,
};
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;