1use anyhow::{Context, Result};
7use serde::Deserialize;
8use tabled::{Table, Tabled};
9
10use crate::cli::CostArgs;
11use crate::deploy::DeploymentState;
12
13#[derive(Debug, Deserialize)]
15struct HaimaCostReport {
16 total_micro_credits: i64,
18 #[serde(default)]
20 services: Vec<ServiceCost>,
21 economic_mode: Option<String>,
23 balance_micro_credits: Option<i64>,
25 monthly_burn_estimate: Option<i64>,
27}
28
29#[derive(Debug, Deserialize)]
30struct ServiceCost {
31 name: String,
32 llm_cost: i64,
34 compute_cost: i64,
36 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
52fn 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 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 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
168async 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
182async 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}