//! Per-prompt and per-session billing ledgers (not serialized). //! //! `total_tokens()` is input + output: Responses wire `total` is live context //! length. Compaction and other side calls never call `record_main_loop_call`. //! //! # Completeness ownership //! //! Wire incomplete is the OR of these stores (each has a distinct role): //! //! - **`UsageLedger.incomplete`** — durable on the bill snapshot. Set by nested //! subagent incomplete fold, drain timeout, true apply-miss, and //! `mark_usage_incomplete`. Monotonic for a ledger instance. //! - **Sticky (`subagent_usage_not_applied` on the coordinator)** — pin-scoped //! **report** signal (session-only attribution or apply-miss report). Not a //! second token sink; does not stain ledgers by itself. //! - **Foreground live IDs** — fold may still land; freeze drains ≤120s or fails //! closed. Cancel skips multi-second drain (actor-loop safety). //! - **Background live** — never waits; prompt report incomplete immediately; //! spend still folds into the session ledger at completion (no session-ledger //! incomplete). //! //! Freeze and cancel share one outcome policy: ledger marks only on fail-closed; //! sticky and background_live are report-level only. //! //! Projection (`PromptUsage`) never invents tokens; it only ORs completeness //! and scrubs costs when partial or incomplete. use indexmap::IndexMap; use kigi_sampling_types::TokenUsage; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct UsageTotals { pub input_tokens: u64, pub output_tokens: u64, pub cached_read_tokens: u64, pub reasoning_tokens: u64, pub model_calls: u64, pub api_duration_ms: u64, /// USD ticks (1e10 per USD). Absent when no call reported cost. pub cost_usd_ticks: Option, pub cost_missing_calls: u64, } impl UsageTotals { fn from_call( usage: &TokenUsage, api_duration_ms: Option, cost_usd_ticks: Option, ) -> Self { let cost_usd_ticks = kigi_sampling_types::reported_cost_ticks(cost_usd_ticks); Self { input_tokens: u64::from(usage.prompt_tokens), output_tokens: u64::from(usage.completion_tokens), cached_read_tokens: u64::from(usage.cached_prompt_tokens), reasoning_tokens: u64::from(usage.reasoning_tokens), model_calls: 1, api_duration_ms: api_duration_ms.unwrap_or(0), cost_usd_ticks, cost_missing_calls: u64::from(cost_usd_ticks.is_none()), } } pub fn total_tokens(&self) -> u64 { self.input_tokens.saturating_add(self.output_tokens) } pub fn cost_is_partial(&self) -> bool { self.cost_usd_ticks.is_some() && self.cost_missing_calls > 0 } fn fold_totals(&mut self, other: &UsageTotals) { let Self { input_tokens, output_tokens, cached_read_tokens, reasoning_tokens, model_calls, api_duration_ms, cost_usd_ticks, cost_missing_calls, } = other; self.input_tokens = self.input_tokens.saturating_add(*input_tokens); self.output_tokens = self.output_tokens.saturating_add(*output_tokens); self.cached_read_tokens = self.cached_read_tokens.saturating_add(*cached_read_tokens); self.reasoning_tokens = self.reasoning_tokens.saturating_add(*reasoning_tokens); self.model_calls = self.model_calls.saturating_add(*model_calls); self.api_duration_ms = self.api_duration_ms.saturating_add(*api_duration_ms); self.cost_missing_calls = self.cost_missing_calls.saturating_add(*cost_missing_calls); self.cost_usd_ticks = merge_cost_ticks(self.cost_usd_ticks, *cost_usd_ticks); } } fn merge_cost_ticks(a: Option, b: Option) -> Option { match (a, b) { (None, None) => None, (a, b) => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))), } } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct UsageLedger { pub totals: UsageTotals, pub by_model: IndexMap, /// Main-agent loop rounds for `num_turns` (subagents excluded). pub main_loop_model_calls: u64, /// Bill may under-count (drain timeout, nested subagent incomplete, apply failure). pub incomplete: bool, } impl UsageLedger { /// Fold one main-agent-loop model call. This is the only writer of /// `main_loop_model_calls` (the wire `numTurns`); side calls such as /// compaction must not use it. pub fn record_main_loop_call( &mut self, model_id: &str, usage: &TokenUsage, api_duration_ms: Option, cost_usd_ticks: Option, ) { let call = UsageTotals::from_call(usage, api_duration_ms, cost_usd_ticks); self.main_loop_model_calls = self.main_loop_model_calls.saturating_add(1); self.fold_entry(model_id, &call); } /// Fold subagent usage without incrementing `main_loop_model_calls`. pub fn record_subagent(&mut self, by_model: &[(String, UsageTotals)], incomplete: bool) { for (model_id, totals) in by_model { self.fold_entry(model_id, totals); } if incomplete { self.incomplete = true; } } pub fn mark_incomplete(&mut self) { self.incomplete = true; } fn fold_entry(&mut self, model_id: &str, totals: &UsageTotals) { self.totals.fold_totals(totals); self.by_model .entry(model_id.to_owned()) .or_default() .fold_totals(totals); } } #[cfg(test)] mod tests { use super::*; fn tu(prompt: u32, completion: u32) -> TokenUsage { TokenUsage { prompt_tokens: prompt, completion_tokens: completion, total_tokens: 999_999, reasoning_tokens: 0, cached_prompt_tokens: 0, } } #[test] fn ledger_sums_partial_subagent_and_zero_cost() { let mut ledger = UsageLedger::default(); ledger.record_main_loop_call("m", &tu(1, 1), None, Some(0)); assert_eq!(ledger.totals.cost_usd_ticks, None); assert_eq!(ledger.totals.cost_missing_calls, 1); ledger.record_main_loop_call("a", &tu(100, 10), Some(100), None); ledger.record_main_loop_call("a", &tu(50, 5), Some(50), Some(70)); assert_eq!(ledger.totals.cost_usd_ticks, Some(70)); assert!(ledger.totals.cost_is_partial()); assert_eq!(ledger.main_loop_model_calls, 3); ledger.record_subagent( &[( "b".into(), UsageTotals { input_tokens: 5, model_calls: 1, ..Default::default() }, )], false, ); assert_eq!(ledger.by_model["b"].input_tokens, 5); assert_eq!(ledger.main_loop_model_calls, 3); assert_eq!(ledger.totals.model_calls, 4); assert!(!ledger.incomplete); ledger.record_subagent(&[], true); assert!(ledger.incomplete); } }