Skip to main content

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}