Skip to main content

orch/cli/
cost.rs

1use crate::cli;
2use crate::cli::task_ref;
3use crate::store::{self, TaskStore};
4use std::sync::Arc;
5
6/// Show cost breakdown for a specific task.
7pub async fn show_task(id: &str) -> anyhow::Result<()> {
8    let owned = cli::init_store().await?;
9    let resolved = task_ref::resolve(&owned, id).await?;
10    let store: Option<Arc<TaskStore>> = Some(Arc::new(owned));
11    let repo = resolved.repo.clone();
12    let id = resolved.external_id.as_str();
13
14    let usage = store::get_token_usage(&store, &repo, id).await;
15    let cost = store::get_reported_cost(&store, &repo, id).await;
16    let model = store::opt_store_get_task(&store, &repo, id)
17        .await
18        .and_then(|t| t.model)
19        .unwrap_or_default();
20
21    if usage.total_tokens() == 0 {
22        println!("No token data available for task #{id}");
23        println!("Run the task first to collect token usage.");
24        return Ok(());
25    }
26
27    println!("Task #{id}: cost breakdown");
28    println!(
29        "├── Input tokens:  {:>12}",
30        format_tokens(usage.input_tokens)
31    );
32    println!(
33        "├── Output tokens: {:>12}",
34        format_tokens(usage.output_tokens)
35    );
36    println!(
37        "└── Cost:          {:>12}",
38        format_cost(cost.unwrap_or(0.0))
39    );
40    println!();
41    println!(
42        "Model: {}",
43        if model.is_empty() { "unknown" } else { &model }
44    );
45
46    Ok(())
47}
48
49/// Show aggregate cost summary over time periods (24h, 7d, 30d).
50pub async fn show_summary() -> anyhow::Result<()> {
51    let store = cli::init_store().await?;
52
53    let summary = store.get_cost_summary().await?;
54
55    println!();
56    println!("Cost Summary");
57    println!("{}", "=".repeat(60));
58    println!();
59    println!(
60        "{:<8} {:>10} {:>10} {:>10} {:>8}",
61        "PERIOD", "INPUT", "OUTPUT", "COST", "TASKS"
62    );
63    println!("{}", "-".repeat(60));
64
65    for period in &summary.periods {
66        println!(
67            "{:<8} {:>10} {:>10} {:>10} {:>8}",
68            period.label,
69            format_tokens(u64::try_from(period.input_tokens).unwrap_or(0)),
70            format_tokens(u64::try_from(period.output_tokens).unwrap_or(0)),
71            format_cost(period.total_cost_usd),
72            period.task_count,
73        );
74    }
75    println!();
76
77    Ok(())
78}
79
80/// Show cost breakdown by agent.
81pub async fn show_by_agent() -> anyhow::Result<()> {
82    let store = cli::init_store().await?;
83
84    let groups = store.get_cost_by_agent().await?;
85
86    if groups.is_empty() {
87        println!("No cost data available yet.");
88        return Ok(());
89    }
90
91    println!();
92    println!("Cost by Agent");
93    println!("{}", "=".repeat(60));
94    println!();
95    println!(
96        "{:<12} {:>10} {:>10} {:>10} {:>8}",
97        "AGENT", "INPUT", "OUTPUT", "COST", "TASKS"
98    );
99    println!("{}", "-".repeat(60));
100
101    let mut total_cost = 0.0;
102    for group in &groups {
103        total_cost += group.total_cost_usd;
104        println!(
105            "{:<12} {:>10} {:>10} {:>10} {:>8}",
106            group.name,
107            format_tokens(u64::try_from(group.input_tokens).unwrap_or(0)),
108            format_tokens(u64::try_from(group.output_tokens).unwrap_or(0)),
109            format_cost(group.total_cost_usd),
110            group.task_count,
111        );
112    }
113    println!("{}", "-".repeat(60));
114    println!(
115        "{:<12} {:>10} {:>10} {:>10}",
116        "",
117        "",
118        "",
119        format_cost(total_cost)
120    );
121    println!();
122
123    Ok(())
124}
125
126/// Show cost breakdown by model.
127pub async fn show_by_model() -> anyhow::Result<()> {
128    let store = cli::init_store().await?;
129
130    let groups = store.get_cost_by_model().await?;
131
132    if groups.is_empty() {
133        println!("No cost data available yet.");
134        return Ok(());
135    }
136
137    println!();
138    println!("Cost by Model");
139    println!("{}", "=".repeat(60));
140    println!();
141    println!(
142        "{:<20} {:>10} {:>10} {:>10} {:>8}",
143        "MODEL", "INPUT", "OUTPUT", "COST", "TASKS"
144    );
145    println!("{}", "-".repeat(60));
146
147    let mut total_cost = 0.0;
148    for group in &groups {
149        total_cost += group.total_cost_usd;
150        println!(
151            "{:<20} {:>10} {:>10} {:>10} {:>8}",
152            group.name,
153            format_tokens(u64::try_from(group.input_tokens).unwrap_or(0)),
154            format_tokens(u64::try_from(group.output_tokens).unwrap_or(0)),
155            format_cost(group.total_cost_usd),
156            group.task_count,
157        );
158    }
159    println!("{}", "-".repeat(60));
160    println!(
161        "{:<20} {:>10} {:>10} {:>10}",
162        "",
163        "",
164        "",
165        format_cost(total_cost)
166    );
167    println!();
168
169    Ok(())
170}
171
172/// Format token counts with thousands separators.
173/// A cost the agents reported, or "not reported" when none did. orch never estimates one.
174pub(crate) fn format_cost(cost_usd: f64) -> String {
175    if cost_usd > 0.0 {
176        format!("${cost_usd:.4}")
177    } else {
178        "not reported".to_string()
179    }
180}
181
182fn format_tokens(n: u64) -> String {
183    if n == 0 {
184        return "0".to_string();
185    }
186    let s = n.to_string();
187    let mut result = String::new();
188    for (i, c) in s.chars().rev().enumerate() {
189        if i > 0 && i % 3 == 0 {
190            result.push(',');
191        }
192        result.push(c);
193    }
194    result.chars().rev().collect()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn format_tokens_zero() {
203        assert_eq!(format_tokens(0), "0");
204    }
205
206    #[test]
207    fn format_tokens_small() {
208        assert_eq!(format_tokens(123), "123");
209    }
210
211    #[test]
212    fn format_tokens_thousands() {
213        assert_eq!(format_tokens(1_234), "1,234");
214    }
215
216    #[test]
217    fn format_tokens_millions() {
218        assert_eq!(format_tokens(1_234_567), "1,234,567");
219    }
220}