recursive/tui/cost.rs
1//! Token usage accounting and cost estimation for the Recursive TUI.
2//!
3//! Contains [`UsageStats`] for per-session token accumulation, [`TurnState`]
4//! for in-flight turn tracking, and helpers to detect the active model and
5//! estimate dollar cost from token counts.
6
7use std::time::Instant;
8
9// ──────────────────────────────────────────────────────────────────────
10// Usage / turn telemetry
11// ──────────────────────────────────────────────────────────────────────
12
13/// Token usage and timing accumulated across the session.
14#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub struct UsageStats {
16 /// Most recent per-turn input tokens.
17 pub input_tokens: u64,
18 /// Most recent per-turn output tokens.
19 pub output_tokens: u64,
20 /// Cumulative input tokens across all turns.
21 pub total_input: u64,
22 /// Cumulative output tokens across all turns.
23 pub total_output: u64,
24 /// Most recent LLM round-trip latency, in milliseconds.
25 pub last_latency_ms: u64,
26}
27
28impl UsageStats {
29 /// Fold a `Usage` event into the stats. Treats incoming numbers as
30 /// per-turn deltas and accumulates them into the running totals.
31 pub fn record(&mut self, input_tokens: u64, output_tokens: u64) {
32 self.input_tokens = input_tokens;
33 self.output_tokens = output_tokens;
34 self.total_input = self.total_input.saturating_add(input_tokens);
35 self.total_output = self.total_output.saturating_add(output_tokens);
36 }
37}
38
39/// State of the currently in-flight turn (if any).
40#[derive(Clone, Debug, PartialEq)]
41pub struct TurnState {
42 pub running: bool,
43 pub started_at: Option<Instant>,
44 pub spinner_verb: &'static str,
45}
46
47impl Default for TurnState {
48 fn default() -> Self {
49 Self {
50 running: false,
51 started_at: None,
52 spinner_verb: "Thinking",
53 }
54 }
55}
56
57impl TurnState {
58 pub fn start(&mut self) {
59 self.running = true;
60 self.started_at = Some(Instant::now());
61 self.spinner_verb = "Thinking";
62 }
63
64 pub fn finish(&mut self) {
65 self.running = false;
66 self.started_at = None;
67 self.spinner_verb = "Thinking";
68 }
69}
70
71// ──────────────────────────────────────────────────────────────────────
72// Cost estimation
73// ──────────────────────────────────────────────────────────────────────
74
75/// Return the model name to display in the status bar.
76///
77/// Delegates to `Config::from_env()` so the TUI shows the same model the
78/// runtime will actually use — including the `provider.preset` chain added
79/// for the preset-config goal. Without this, a user with
80/// `provider.preset = "deepseek"` would see "claude-sonnet-4-6" in the
81/// status bar while the agent talked to DeepSeek.
82pub fn detect_model_name() -> String {
83 crate::config::Config::from_env()
84 .map(|c| c.model)
85 .unwrap_or_else(|_| "gpt-4o-mini".to_string())
86}
87
88/// Compute estimated cost in USD given accumulated tokens.
89/// Delegates to `pricing_for()` from the bundled provider catalog.
90/// Returns `None` when the model has no pricing entry in `providers.toml`.
91pub fn estimate_cost(model: &str, total_input: u64, total_output: u64) -> Option<f64> {
92 let pricing = crate::llm::pricing_for(model)?;
93 let in_cost = (total_input as f64) * pricing.input_per_million / 1_000_000.0;
94 let out_cost = (total_output as f64) * pricing.output_per_million / 1_000_000.0;
95 Some(in_cost + out_cost)
96}