lean_ctx/core/finops_export/
cbf.rs1use super::{DailyCostRow, csv_field};
14
15pub const HEADER: &[&str] = &[
16 "lineitem/type",
17 "time/usage_start",
18 "resource/service",
19 "resource/id",
20 "resource/account",
21 "usage/amount",
22 "usage/units",
23 "cost/cost",
24 "resource/tag:project",
25 "resource/tag:agent_role",
26 "resource/tag:model",
27 "resource/tag:tool",
28];
29
30const SERVICE: &str = "LeanCTX";
31
32fn record(row: &DailyCostRow, line_type: &str, amount: u64, cost: f64) -> Vec<String> {
33 vec![
34 line_type.to_string(),
35 format!("{}T00:00:00Z", row.date),
36 SERVICE.to_string(),
37 format!("leanctx/{}/{}/{}", row.project, row.agent_role, row.model),
38 row.project.clone(),
39 amount.to_string(),
40 "tokens".to_string(),
41 format!("{cost:.6}"),
42 row.project.clone(),
43 row.agent_role.clone(),
44 row.model.clone(),
45 row.tool.clone(),
46 ]
47}
48
49fn records(rows: &[DailyCostRow]) -> Vec<Vec<String>> {
50 let mut out = Vec::new();
51 for row in rows {
52 out.push(record(row, "Usage", row.tokens_actual, row.cost_usd));
53 if row.tokens_saved > 0 {
54 out.push(record(row, "Discount", row.tokens_saved, -row.savings_usd));
55 }
56 }
57 out
58}
59
60pub fn to_csv(rows: &[DailyCostRow]) -> String {
62 let mut out = HEADER.join(",");
63 out.push('\n');
64 for rec in records(rows) {
65 let line = rec
66 .iter()
67 .map(|f| csv_field(f))
68 .collect::<Vec<_>>()
69 .join(",");
70 out.push_str(&line);
71 out.push('\n');
72 }
73 out
74}
75
76pub fn to_stream_body(rows: &[DailyCostRow], month: &str) -> serde_json::Value {
79 let data: Vec<serde_json::Value> = records(rows)
80 .into_iter()
81 .map(|rec| {
82 let mut obj = serde_json::Map::new();
83 for (key, val) in HEADER.iter().zip(rec) {
84 obj.insert((*key).to_string(), serde_json::Value::String(val));
85 }
86 serde_json::Value::Object(obj)
87 })
88 .collect();
89 serde_json::json!({
90 "month": month,
91 "operation": "replace_drop",
92 "data": data,
93 })
94}
95
96pub fn upload(rows: &[DailyCostRow], month: &str) -> Result<String, String> {
101 let api_key =
102 std::env::var("CLOUDZERO_API_KEY").map_err(|_| "CLOUDZERO_API_KEY not set".to_string())?;
103 let connection = std::env::var("CLOUDZERO_CONNECTION_ID")
104 .map_err(|_| "CLOUDZERO_CONNECTION_ID not set".to_string())?;
105 let url = format!(
106 "https://api.cloudzero.com/v2/connections/billing/anycost/{connection}/billing_drops"
107 );
108
109 let body = serde_json::to_vec(&to_stream_body(rows, month)).map_err(|e| e.to_string())?;
110 let agent = crate::core::http_client::ureq_agent(
111 ureq::config::Config::builder()
112 .tls_config(crate::core::http_client::platform_tls_config())
113 .timeout_global(Some(std::time::Duration::from_secs(30)))
114 .http_status_as_error(false)
115 .build(),
116 );
117 let resp = agent
118 .post(&url)
119 .header("Content-Type", "application/json")
120 .header("Authorization", api_key.trim())
121 .send(body.as_slice())
122 .map_err(|e| format!("cloudzero unreachable: {e}"))?;
123
124 let status = resp.status().as_u16();
125 let text = resp.into_body().read_to_string().unwrap_or_default();
126 if (200..300).contains(&status) {
127 Ok(format!("CloudZero accepted month {month} ({status})"))
128 } else {
129 Err(format!("CloudZero rejected ({status}): {text}"))
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn row() -> DailyCostRow {
138 DailyCostRow {
139 date: "2026-06-01".into(),
140 project: "proj_a".into(),
141 agent_role: "coder".into(),
142 model: "claude".into(),
143 tool: "ctx_read".into(),
144 tokens_actual: 400,
145 tokens_saved: 1600,
146 cost_usd: 0.001,
147 savings_usd: 0.004,
148 }
149 }
150
151 #[test]
152 fn usage_and_discount_rows() {
153 let csv = to_csv(&[row()]);
154 let lines: Vec<&str> = csv.lines().collect();
155 assert_eq!(lines.len(), 3);
156 assert!(lines[1].starts_with("Usage,2026-06-01T00:00:00Z"));
157 assert!(lines[2].starts_with("Discount,"));
158 assert!(
159 lines[2].contains("-0.004"),
160 "discount negative: {}",
161 lines[2]
162 );
163 }
164
165 #[test]
166 fn stream_body_is_idempotent_replace_drop() {
167 let body = to_stream_body(&[row()], "2026-06");
168 assert_eq!(body["month"], "2026-06");
169 assert_eq!(body["operation"], "replace_drop");
170 let data = body["data"].as_array().unwrap();
171 assert_eq!(data.len(), 2);
172 assert_eq!(data[0]["lineitem/type"], "Usage");
173 assert_eq!(data[0]["cost/cost"], "0.001000");
174 assert_eq!(data[1]["resource/tag:project"], "proj_a");
175 }
176
177 #[test]
178 fn required_cbf_columns_present() {
179 let body = to_stream_body(&[row()], "2026-06");
180 let first = &body["data"][0];
181 assert!(first.get("time/usage_start").is_some());
182 assert!(first.get("cost/cost").is_some());
183 }
184}