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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user