Skip to main content

life_cli/
cost.rs

1//! Cost tracking dashboard for deployed agents.
2//!
3//! Queries Haima's finance API for per-agent compute + LLM costs,
4//! and supplements with Autonomic economic state.
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use tabled::{Table, Tabled};
9
10use crate::cli::CostArgs;
11use crate::deploy::DeploymentState;
12
13/// Cost data from Haima finance API.
14#[derive(Debug, Deserialize)]
15struct HaimaCostReport {
16    /// Total cost in micro-credits for the window.
17    total_micro_credits: i64,
18    /// Per-service cost breakdown.
19    #[serde(default)]
20    services: Vec<ServiceCost>,
21    /// Economic mode from Autonomic.
22    economic_mode: Option<String>,
23    /// Balance remaining.
24    balance_micro_credits: Option<i64>,
25    /// Estimated monthly burn.
26    monthly_burn_estimate: Option<i64>,
27}
28
29#[derive(Debug, Deserialize)]
30struct ServiceCost {
31    name: String,
32    /// LLM token costs (micro-credits).
33    llm_cost: i64,
34    /// Compute time costs (micro-credits).
35    compute_cost: i64,
36    /// Total cost.
37    total_cost: i64,
38}
39
40#[derive(Tabled)]
41struct CostRow {
42    #[tabled(rename = "Service")]
43    name: String,
44    #[tabled(rename = "LLM Cost")]
45    llm_cost: String,
46    #[tabled(rename = "Compute Cost")]
47    compute_cost: String,
48    #[tabled(rename = "Total")]
49    total: String,
50}
51
52/// Format micro-credits as human-readable credits (1 credit = 1,000,000 μcr).
53fn format_credits(micro_credits: i64) -> String {
54    let credits = micro_credits as f64 / 1_000_000.0;
55    if credits >= 1.0 {
56        format!("{credits:.2} cr")
57    } else {
58        format!("{micro_credits} μcr")
59    }
60}
61
62pub async fn run(args: CostArgs) -> Result<()> {
63    let state = DeploymentState::load(&args.agent)
64        .with_context(|| format!("no deployment found for agent '{}'", args.agent))?;
65
66    // Try to reach Haima API for live cost data
67    let haima_url = state.services.get("haima").and_then(|s| s.url.as_deref());
68
69    let report = if let Some(url) = haima_url {
70        match fetch_cost_report(url, &args.window).await {
71            Ok(r) => Some(r),
72            Err(e) => {
73                eprintln!("Warning: could not reach Haima API at {url}: {e}");
74                None
75            }
76        }
77    } else {
78        None
79    };
80
81    // Also try to get Autonomic economic state
82    let autonomic_url = state
83        .services
84        .get("autonomic")
85        .and_then(|s| s.url.as_deref());
86
87    let economic_mode = if let Some(url) = autonomic_url {
88        fetch_economic_mode(url).await.ok()
89    } else {
90        report.as_ref().and_then(|r| r.economic_mode.clone())
91    };
92
93    match &args.format[..] {
94        "json" => {
95            let output = serde_json::json!({
96                "agent": state.agent_name,
97                "window": args.window,
98                "economic_mode": economic_mode,
99                "cost_report": report.as_ref().map(|r| serde_json::json!({
100                    "total_credits": format_credits(r.total_micro_credits),
101                    "total_micro_credits": r.total_micro_credits,
102                    "balance_credits": r.balance_micro_credits.map(format_credits),
103                    "monthly_burn_credits": r.monthly_burn_estimate.map(format_credits),
104                    "services": r.services.iter().map(|s| serde_json::json!({
105                        "name": s.name,
106                        "llm_cost": format_credits(s.llm_cost),
107                        "compute_cost": format_credits(s.compute_cost),
108                        "total": format_credits(s.total_cost),
109                    })).collect::<Vec<_>>(),
110                })),
111            });
112            println!("{}", serde_json::to_string_pretty(&output)?);
113        }
114        _ => {
115            println!(
116                "Cost Report: {} (window: {})",
117                state.agent_name, args.window
118            );
119            println!("═══════════════════════════════════════════");
120
121            if let Some(mode) = &economic_mode {
122                println!("Economic Mode: {mode}");
123            }
124
125            if let Some(report) = &report {
126                println!("Total Cost: {}", format_credits(report.total_micro_credits));
127
128                if let Some(balance) = report.balance_micro_credits {
129                    println!("Balance: {}", format_credits(balance));
130                }
131                if let Some(burn) = report.monthly_burn_estimate {
132                    println!("Monthly Burn Est.: {}", format_credits(burn));
133                }
134
135                println!();
136
137                if !report.services.is_empty() {
138                    let rows: Vec<CostRow> = report
139                        .services
140                        .iter()
141                        .map(|s| CostRow {
142                            name: s.name.clone(),
143                            llm_cost: format_credits(s.llm_cost),
144                            compute_cost: format_credits(s.compute_cost),
145                            total: format_credits(s.total_cost),
146                        })
147                        .collect();
148
149                    println!("{}", Table::new(rows));
150                }
151            } else {
152                println!();
153                println!("No live cost data available.");
154                println!("Ensure the Haima service is deployed and reachable.");
155                println!("  Template: {}", state.template_name);
156                let has_haima = state.services.contains_key("haima");
157                if !has_haima {
158                    println!("  Note: this agent template does not include Haima.");
159                    println!("  Use 'coding-agent' or 'data-agent' template for cost tracking.");
160                }
161            }
162        }
163    }
164
165    Ok(())
166}
167
168/// Fetch cost report from Haima finance API.
169async fn fetch_cost_report(base_url: &str, window: &str) -> Result<HaimaCostReport> {
170    let url = format!("{base_url}/v1/cost?window={window}");
171    let resp = reqwest::get(&url).await.context("failed to reach Haima")?;
172
173    if !resp.status().is_success() {
174        anyhow::bail!("Haima returned HTTP {}", resp.status());
175    }
176
177    resp.json()
178        .await
179        .context("failed to parse Haima cost report")
180}
181
182/// Fetch current economic mode from Autonomic.
183async fn fetch_economic_mode(base_url: &str) -> Result<String> {
184    #[derive(Deserialize)]
185    struct GatingResponse {
186        economic_mode: String,
187    }
188
189    let url = format!("{base_url}/gating/default");
190    let resp = reqwest::get(&url)
191        .await
192        .context("failed to reach Autonomic")?;
193
194    if !resp.status().is_success() {
195        anyhow::bail!("Autonomic returned HTTP {}", resp.status());
196    }
197
198    let data: GatingResponse = resp.json().await?;
199    Ok(data.economic_mode)
200}