@archeagle087/costcrew 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/.github/workflows/npm-release.yml +107 -0
  3. package/.github/workflows/prices-monthly.yml +58 -0
  4. package/.github/workflows/release.yml +45 -0
  5. package/Cargo.lock +980 -0
  6. package/Cargo.toml +22 -0
  7. package/LICENSE +21 -0
  8. package/README.md +255 -2
  9. package/assets/advise.png +0 -0
  10. package/assets/board.png +0 -0
  11. package/assets/catalog.png +0 -0
  12. package/assets/demo.png +0 -0
  13. package/assets/help.png +0 -0
  14. package/assets/mascot.png +0 -0
  15. package/assets/prices.png +0 -0
  16. package/assets/scan.png +0 -0
  17. package/assets/spend.png +0 -0
  18. package/assets/ui.png +0 -0
  19. package/bin.js +43 -0
  20. package/data/prices.json +1 -0
  21. package/dist/costcrew-linux-x86_64.tar.gz +0 -0
  22. package/dist/costcrew-macos-aarch64.tar.gz +0 -0
  23. package/dist/costcrew-macos-x86_64.tar.gz +0 -0
  24. package/dist/costcrew-windows-x86_64.zip +0 -0
  25. package/install.ps1 +38 -0
  26. package/install.sh +56 -0
  27. package/package.json +18 -4
  28. package/packaging/npm/bin.js +43 -0
  29. package/packaging/npm/package.json +20 -0
  30. package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +15 -0
  31. package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +28 -0
  32. package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +5 -0
  33. package/packaging/winget/README.md +31 -0
  34. package/scripts/render_screen.py +221 -0
  35. package/src/advise.rs +154 -0
  36. package/src/args.rs +239 -0
  37. package/src/board.rs +227 -0
  38. package/src/catalog.rs +136 -0
  39. package/src/demo.rs +79 -0
  40. package/src/fetch.rs +155 -0
  41. package/src/main.rs +371 -0
  42. package/src/price.rs +218 -0
  43. package/src/prices.rs +182 -0
  44. package/src/report.rs +194 -0
  45. package/src/scan.rs +593 -0
  46. package/src/spend.rs +452 -0
  47. package/src/ui.rs +367 -0
  48. package/work/costcrew-darwin-arm64/costcrew +0 -0
  49. package/work/costcrew-darwin-arm64/package.json +9 -0
  50. package/work/costcrew-darwin-x64/costcrew +0 -0
  51. package/work/costcrew-darwin-x64/package.json +9 -0
  52. package/work/costcrew-linux-x64/costcrew +0 -0
  53. package/work/costcrew-linux-x64/package.json +9 -0
  54. package/work/costcrew-win32-x64/costcrew.exe +0 -0
  55. package/work/costcrew-win32-x64/package.json +9 -0
package/src/fetch.rs ADDED
@@ -0,0 +1,155 @@
1
+ //! Live pricing + spend from OpenRouter.
2
+ //!
3
+ //! Two uses:
4
+ //! 1. `--fetch-prices` pulls every OpenRouter model's price (public endpoint,
5
+ //! no key) and caches it locally — covers "every model, automatically".
6
+ //! 2. `costcrew openrouter` reads your OPENROUTER_API_KEY and reports what
7
+ //! you actually spent, per model, from the activity endpoint.
8
+
9
+ use crate::price::{PriceDB, Pricing};
10
+ use serde_json::Value;
11
+ use std::path::PathBuf;
12
+ use std::time::{Duration, SystemTime};
13
+
14
+ const MODELS_URL: &str = "https://openrouter.ai/api/v1/models";
15
+ const ACTIVITY_URL: &str = "https://openrouter.ai/api/v1/activity?limit=200";
16
+ const CACHE_VERSION: u64 = 1;
17
+
18
+ pub fn cache_dir() -> PathBuf {
19
+ if let Some(lad) = std::env::var_os("LOCALAPPDATA") {
20
+ return PathBuf::from(lad).join("costcrew");
21
+ }
22
+ std::env::var_os("HOME")
23
+ .map(PathBuf::from)
24
+ .unwrap_or_else(|| PathBuf::from("."))
25
+ .join(".cache/costcrew")
26
+ }
27
+
28
+ fn num(s: &Value) -> f64 {
29
+ match s {
30
+ Value::Number(n) => n.as_f64().unwrap_or(0.0),
31
+ Value::String(s) => s.trim().parse().unwrap_or(0.0),
32
+ _ => 0.0,
33
+ }
34
+ }
35
+
36
+ /// Public models endpoint -> per-1M USD prices, cached on disk.
37
+ pub fn force_refresh() -> Result<usize, String> {
38
+ let resp = ureq::get(MODELS_URL)
39
+ .timeout(Duration::from_secs(30))
40
+ .call()
41
+ .map_err(|e| format!("fetch failed: {e}"))?;
42
+ let text = resp.into_string().map_err(|e| e.to_string())?;
43
+ let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
44
+
45
+ let mut models: Vec<(String, [f64; 4])> = Vec::new();
46
+ if let Some(list) = v.get("data").and_then(Value::as_array) {
47
+ for m in list {
48
+ let id = match m.get("id").and_then(Value::as_str) {
49
+ Some(id) => id.to_string(),
50
+ None => continue,
51
+ };
52
+ // pricing is per-token strings; scale to per-1M
53
+ let p = m.get("pricing").cloned().unwrap_or(Value::Null);
54
+ let scale = 1_000_000.0;
55
+ let price = [
56
+ num(&p.get("prompt").cloned().unwrap_or(Value::Null)) * scale,
57
+ num(&p.get("completion").cloned().unwrap_or(Value::Null)) * scale,
58
+ num(&p.get("input_cache_read").cloned().unwrap_or(Value::Null)) * scale,
59
+ num(&p.get("input_cache_write").cloned().unwrap_or(Value::Null)) * scale,
60
+ ];
61
+ models.push((id, price));
62
+ }
63
+ }
64
+ if models.is_empty() {
65
+ return Err("openrouter returned no models".into());
66
+ }
67
+ let doc = serde_json::json!({
68
+ "version": CACHE_VERSION,
69
+ "fetched_at_ms": now_ms(),
70
+ "models": models.iter().map(|(id, p)| serde_json::json!({
71
+ "id": id, "prompt": p[0], "completion": p[1], "cache_read": p[2], "cache_write": p[3],
72
+ })).collect::<Vec<_>>(),
73
+ });
74
+ let dir = cache_dir();
75
+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
76
+ std::fs::write(dir.join("openrouter-prices.json"), doc.to_string())
77
+ .map_err(|e| e.to_string())?;
78
+ Ok(models.len())
79
+ }
80
+
81
+ pub fn cached_into_db(db: &mut PriceDB) -> usize {
82
+ let path = cache_dir().join("openrouter-prices.json");
83
+ let Ok(text) = std::fs::read_to_string(&path) else {
84
+ return 0;
85
+ };
86
+ let Ok(v) = serde_json::from_str::<Value>(&text) else {
87
+ return 0;
88
+ };
89
+ if v.get("version").and_then(Value::as_u64) != Some(CACHE_VERSION) {
90
+ return 0;
91
+ }
92
+ let mut n = 0usize;
93
+ if let Some(list) = v.get("models").and_then(Value::as_array) {
94
+ for m in list {
95
+ let id = match m.get("id").and_then(Value::as_str) {
96
+ Some(s) => s.to_string(),
97
+ None => continue,
98
+ };
99
+ db.push(
100
+ &id,
101
+ "cache",
102
+ Pricing {
103
+ input: num(&m["prompt"]),
104
+ output: num(&m["completion"]),
105
+ cache_read: num(&m["cache_read"]),
106
+ cache_write: num(&m["cache_write"]),
107
+ per_hour: 0.0,
108
+ },
109
+ );
110
+ n += 1;
111
+ }
112
+ }
113
+ db.cache_count = n;
114
+ n
115
+ }
116
+
117
+ /// Actual spend per model for an OpenRouter API key.
118
+ pub fn activity(key: &str) -> Result<Vec<(String, u64, f64)>, String> {
119
+ let resp = ureq::get(ACTIVITY_URL)
120
+ .timeout(Duration::from_secs(30))
121
+ .set("Authorization", &format!("Bearer {key}"))
122
+ .call()
123
+ .map_err(|e| format!("openrouter request failed: {e}"))?;
124
+ let text = resp.into_string().map_err(|e| e.to_string())?;
125
+ let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
126
+ if let Some(msg) = v.get("error") {
127
+ return Err(format!("openrouter error: {msg}"));
128
+ }
129
+
130
+ let mut by_model: Vec<(String, u64, f64)> = Vec::new();
131
+ if let Some(list) = v.get("data").and_then(Value::as_array) {
132
+ for ev in list {
133
+ let model = match ev.get("model").and_then(Value::as_str) {
134
+ Some(m) => m.to_string(),
135
+ None => continue,
136
+ };
137
+ let cost = num(ev.get("cost").unwrap_or(&Value::Null));
138
+ if let Some(row) = by_model.iter_mut().find(|(m, _, _)| *m == model) {
139
+ row.1 += 1;
140
+ row.2 += cost;
141
+ } else {
142
+ by_model.push((model, 1, cost));
143
+ }
144
+ }
145
+ }
146
+ by_model.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
147
+ Ok(by_model)
148
+ }
149
+
150
+ fn now_ms() -> u64 {
151
+ SystemTime::now()
152
+ .duration_since(SystemTime::UNIX_EPOCH)
153
+ .map(|d| d.as_millis() as u64)
154
+ .unwrap_or(0)
155
+ }
package/src/main.rs ADDED
@@ -0,0 +1,371 @@
1
+ mod advise;
2
+ mod args;
3
+ mod board;
4
+ mod catalog;
5
+ mod demo;
6
+ mod fetch;
7
+ mod price;
8
+ mod prices;
9
+ mod report;
10
+ mod scan;
11
+ mod spend;
12
+ mod ui;
13
+
14
+ use args::{parse, Args, Cmd};
15
+ use price::PriceDB;
16
+ use report::{print_markdown, print_table, Totals, BOLD, DIM, RESET};
17
+ use scan::{aggregate, collect};
18
+
19
+ fn main() {
20
+ let argv: Vec<String> = std::env::args().skip(1).collect();
21
+
22
+ // bare `costcrew` = the interactive app (splash + arrow-key menu).
23
+ // any argument at all = classic flag mode for scripting.
24
+ if argv.is_empty() {
25
+ ui::run_app();
26
+ return;
27
+ }
28
+
29
+ let args = match parse(&argv) {
30
+ Ok(a) => a,
31
+ Err(e) => {
32
+ eprintln!("error: {e}");
33
+ std::process::exit(2);
34
+ }
35
+ };
36
+ flow(args);
37
+ }
38
+
39
+ /// one dispatch point, shared by the flag CLI and the interactive menu
40
+ pub fn flow(args: Args) {
41
+ match args.cmd {
42
+ Cmd::Overview => overview(&args),
43
+ Cmd::Prices => {
44
+ let mut db = PriceDB::builtin();
45
+ fetch::cached_into_db(&mut db);
46
+ if let Some(path) = &args.prices {
47
+ load_custom(&mut db, path);
48
+ }
49
+ let sort = args.sort.as_deref().unwrap_or("name").to_string();
50
+ let query = args.pos.first().cloned();
51
+ prices::print_prices(&db, query.as_deref(), &sort, args.top, args.json);
52
+ }
53
+ Cmd::Spend => spend::print_spend(args.json, args.since_days),
54
+ Cmd::SpendAdd => spend::add(&args),
55
+ Cmd::Demo => {
56
+ let db = PriceDB::builtin();
57
+ run(demo::demo_rows(&db), args, 7, &db);
58
+ }
59
+ Cmd::Catalog => catalog::print_catalog(),
60
+ Cmd::Leaderboard => {
61
+ let mut db = PriceDB::builtin();
62
+ fetch::cached_into_db(&mut db);
63
+ if let Some(path) = &args.prices {
64
+ load_custom(&mut db, path);
65
+ }
66
+ let sort = match &args.sort {
67
+ Some(s) => match board::parse_sort(s) {
68
+ Ok(s) => s,
69
+ Err(e) => {
70
+ eprintln!("error: {e}");
71
+ std::process::exit(2);
72
+ }
73
+ },
74
+ None => board::Sort::Elo,
75
+ };
76
+ board::print_board(&db, sort, args.top, args.json);
77
+ }
78
+ Cmd::OpenRouter => {
79
+ run_openrouter(&args);
80
+ }
81
+ Cmd::Fetch => {
82
+ println!("fetching OpenRouter price list ...");
83
+ match fetch::force_refresh() {
84
+ Ok(n) => println!(
85
+ "cached {} models to {}\nnext `costcrew` run prices against them automatically",
86
+ n,
87
+ fetch::cache_dir().join("openrouter-prices.json").display()
88
+ ),
89
+ Err(e) => {
90
+ eprintln!("error: {e}");
91
+ std::process::exit(1);
92
+ }
93
+ }
94
+ }
95
+ Cmd::Scan | Cmd::Md | Cmd::Advise => {
96
+ let mut db = PriceDB::builtin();
97
+ fetch::cached_into_db(&mut db);
98
+ if let Some(path) = &args.prices {
99
+ load_custom(&mut db, path);
100
+ }
101
+ let events = collect(args.since_days);
102
+ if events.is_empty() {
103
+ if args.cmd == Cmd::Advise {
104
+ // advise is understood without a scan — show it on demo data
105
+ let db = PriceDB::builtin();
106
+ println!(" {DIM}(no local usage found — showing advise on demo data){RESET}");
107
+ advise::print_advise(&demo::demo_rows(&db), &db, 7);
108
+ return;
109
+ }
110
+ eprintln!("no agent usage found on this machine.");
111
+ eprintln!("scanned ~/.claude, opencode data dirs, ~/.codex, vscode globalStorage, aider history");
112
+ eprintln!("try `costcrew demo` to see a sample report.");
113
+ std::process::exit(1);
114
+ }
115
+ let days = args.since_days;
116
+ let rows = aggregate(&events, &db);
117
+ if args.cmd == Cmd::Advise && !rows.iter().any(|r| r.known_price) {
118
+ // advise is understood without priced scans — show it on demo data
119
+ println!(
120
+ "{DIM}(no priced usage found locally — showing advise on demo data){RESET}"
121
+ );
122
+ advise::print_advise(&demo::demo_rows(&db), &db, 7);
123
+ return;
124
+ }
125
+ run(rows, args, days, &db);
126
+ }
127
+ }
128
+ }
129
+
130
+ fn run(rows: Vec<scan::Row>, args: args::Args, since_days: u32, db: &PriceDB) {
131
+ let total_cost: f64 = rows.iter().filter(|r| r.known_price).map(|r| r.cost).sum();
132
+ let total_requests: u64 = rows.iter().map(|r| r.requests).sum();
133
+ let unknown_models = rows.iter().filter(|r| !r.known_price).count();
134
+ let totals = Totals {
135
+ total_cost,
136
+ total_requests,
137
+ unknown_models,
138
+ };
139
+
140
+ if args.json {
141
+ print_json(&rows, &totals);
142
+ return;
143
+ }
144
+
145
+ match args.cmd {
146
+ Cmd::Md => print_markdown(&rows, since_days, &totals),
147
+ Cmd::Advise => advise::print_advise(&rows, db, since_days),
148
+ _ => print_table(&rows, since_days, &totals),
149
+ }
150
+ }
151
+
152
+ /// Bare `costcrew` — the two things the tool is about:
153
+ /// the bundled price database, and YOUR spend on AI.
154
+ fn overview(args: &args::Args) {
155
+ use report::{CYAN, GREEN, YELLOW};
156
+ let mut db = PriceDB::builtin();
157
+ fetch::cached_into_db(&mut db);
158
+ if let Some(path) = &args.prices {
159
+ load_custom(&mut db, path);
160
+ }
161
+ let total = db.entries().len();
162
+
163
+ println!();
164
+ println!(" {BOLD}costcrew{RESET} {DIM}· the AI coding price database + your spend{RESET}");
165
+ println!();
166
+
167
+ println!(" {BOLD}THE DATABASE{RESET} {GREEN}{total} models priced{RESET} {DIM}(bundled — works offline){RESET}");
168
+ println!(" {DIM}per-model: input / output / cache rates per 1M tokens{RESET}");
169
+ println!();
170
+ println!(
171
+ " {CYAN}costcrew prices{RESET} browse everything {DIM}({} curated + {} embedded){RESET}",
172
+ db.entries().iter().filter(|e| e.src == "curated").count(),
173
+ db.embedded_count
174
+ );
175
+ println!(" {CYAN}costcrew prices claude{RESET} filter by name {DIM}(--sort price|cheap|name){RESET}");
176
+ println!(" {CYAN}costcrew leaderboard{RESET} arena Elo, priced: elo-per-dollar value ranking");
177
+ println!(" {CYAN}costcrew catalog{RESET} which agent ships with which models");
178
+ println!(
179
+ " {CYAN}costcrew fetch-prices{RESET} refresh from OpenRouter live (~{} models)",
180
+ db.embedded_count.max(465)
181
+ );
182
+
183
+ // ---- your spend ----
184
+ let entries = spend_load_entries();
185
+ let today = today_str();
186
+ let this_month = entries
187
+ .iter()
188
+ .filter(|e| e.date.chars().take(7).collect::<String>() == today[..7])
189
+ .map(|e| e.amount)
190
+ .sum::<f64>();
191
+ println!();
192
+ println!(" {BOLD}YOUR SPEND ON AI{RESET}");
193
+ if entries.is_empty() {
194
+ println!(
195
+ " {YELLOW}nothing recorded yet{RESET} {DIM}— log what you actually pay (subscriptions, credits, GPUs):{RESET}"
196
+ );
197
+ println!(" {CYAN}costcrew spend add 20 --service \"Claude Pro\"{RESET}");
198
+ } else {
199
+ println!(
200
+ " {DIM}this month{RESET} {BOLD}${this_month:>7.2}{RESET} {DIM}· {} entries{RESET}",
201
+ entries.len()
202
+ );
203
+ }
204
+ println!(" {CYAN}costcrew spend{RESET} monthly totals + per-service breakdown");
205
+ println!(" {CYAN}costcrew spend add 25{RESET} record a payment {DIM}--service --model --note --at{RESET}");
206
+ println!(" {CYAN}costcrew openrouter{RESET} actual spend per model from your API key");
207
+
208
+ // ---- optional estimates ----
209
+ println!();
210
+ println!(" {DIM}optional: {RESET}{DIM}estimate per-token usage from local logs → {RESET}{CYAN}costcrew scan{RESET}");
211
+ println!(
212
+ " {DIM}cheaper-model advice on your own token mix → {RESET}{CYAN}costcrew advise{RESET}"
213
+ );
214
+ if args.json {
215
+ use serde_json::json;
216
+ println!(
217
+ "{}",
218
+ json!({
219
+ "database_models": total,
220
+ "curated": db.entries().iter().filter(|e| e.src == "curated").count(),
221
+ "embedded": db.embedded_count,
222
+ "cache": db.cache_count,
223
+ "custom": db.custom_count,
224
+ "spend_this_month_usd": (this_month * 100.0).round() / 100.0,
225
+ "spend_entries": entries.len(),
226
+ })
227
+ );
228
+ }
229
+ println!();
230
+ }
231
+
232
+ fn spend_load_entries() -> Vec<spend::Entry> {
233
+ spend::all_entries()
234
+ }
235
+
236
+ fn today_str() -> String {
237
+ spend::today()
238
+ }
239
+
240
+ fn run_openrouter(args: &args::Args) {
241
+ let key = args
242
+ .key
243
+ .clone()
244
+ .or_else(|| std::env::var("OPENROUTER_API_KEY").ok())
245
+ .unwrap_or_else(|| {
246
+ eprintln!("error: set OPENROUTER_API_KEY or pass --key sk-or-v1-...");
247
+ std::process::exit(2);
248
+ });
249
+
250
+ println!("querying OpenRouter activity (limit 200) ...");
251
+ match fetch::activity(&key) {
252
+ Ok(rows) if rows.is_empty() => println!("no recorded spend — nothing on this key yet."),
253
+ Ok(rows) => {
254
+ println!();
255
+ println!(" {BOLD}costcrew{RESET} {DIM}· OPENROUTER actual spend (API key){RESET}");
256
+ println!();
257
+ println!(" {:>40} {:>6} {:>10}", "model", "reqs", "cost usd");
258
+ println!(" {}:{}", "-".repeat(40), "-".repeat(19));
259
+ let total: f64 = rows.iter().map(|(_, _, c)| c).sum();
260
+ for (model, reqs, cost) in &rows {
261
+ println!(
262
+ " {:>40} {:>6} {:>10.4}",
263
+ if model.len() > 40 {
264
+ &model[..40]
265
+ } else {
266
+ model
267
+ },
268
+ reqs,
269
+ cost
270
+ );
271
+ }
272
+ println!(" {:>40} {:>6} {:>10.4}", "TOTAL", "", total);
273
+ println!();
274
+ println!(" {DIM}this is act bill-truth from the provider — the scan is the estimate.{RESET}");
275
+ }
276
+ Err(e) => {
277
+ eprintln!("error: {e}");
278
+ std::process::exit(1);
279
+ }
280
+ }
281
+ }
282
+
283
+ fn load_custom(db: &mut PriceDB, path: &str) {
284
+ use serde_json::Value;
285
+ let Ok(text) = std::fs::read_to_string(path) else {
286
+ eprintln!("error: can't read {path}");
287
+ std::process::exit(2);
288
+ };
289
+ let Ok(v) = serde_json::from_str::<Value>(&text) else {
290
+ eprintln!("error: {path} is not valid JSON");
291
+ std::process::exit(2);
292
+ };
293
+ let n = match v.get("prices") {
294
+ Some(list) => load_list(db, list),
295
+ None => {
296
+ if v.is_array() {
297
+ load_list(db, &v)
298
+ } else {
299
+ 0
300
+ }
301
+ }
302
+ };
303
+ db.custom_count = n;
304
+ println!(
305
+ "using {n} custom price entr{} from {path}",
306
+ if n == 1 { "y" } else { "ies" }
307
+ );
308
+ }
309
+
310
+ fn load_list(db: &mut PriceDB, list: &serde_json::Value) -> usize {
311
+ let mut n = 0;
312
+ if let Some(items) = list.as_array() {
313
+ for it in items {
314
+ let prefix = match it.get("prefix").and_then(serde_json::Value::as_str) {
315
+ Some(p) => p,
316
+ None => continue,
317
+ };
318
+ let get = |k: &str| {
319
+ it.get(k)
320
+ .and_then(serde_json::Value::as_f64)
321
+ .or_else(|| {
322
+ it.get(k)
323
+ .and_then(serde_json::Value::as_str)
324
+ .and_then(|s| s.trim().parse().ok())
325
+ })
326
+ .unwrap_or(0.0)
327
+ };
328
+ db.push(
329
+ prefix,
330
+ "custom",
331
+ price::Pricing {
332
+ input: get("input"),
333
+ output: get("output"),
334
+ cache_read: get("cache_read"),
335
+ cache_write: get("cache_write"),
336
+ per_hour: get("per_hour"),
337
+ },
338
+ );
339
+ n += 1;
340
+ }
341
+ }
342
+ n
343
+ }
344
+
345
+ fn print_json(rows: &[scan::Row], t: &Totals) {
346
+ use serde_json::json;
347
+ let arr: Vec<_> = rows
348
+ .iter()
349
+ .map(|r| {
350
+ json!({
351
+ "provider": r.provider,
352
+ "model": r.model,
353
+ "requests": r.requests,
354
+ "input_tokens": r.input_tokens,
355
+ "output_tokens": r.output_tokens,
356
+ "cache_tokens": r.cache_tokens,
357
+ "duration_secs": (r.duration_secs * 10.0).round() / 10.0,
358
+ "cost_usd": if r.known_price { serde_json::Value::from((r.cost * 10_000.0).round() / 10_000.0) } else { serde_json::Value::Null },
359
+ })
360
+ })
361
+ .collect();
362
+ println!(
363
+ "{}",
364
+ json!({
365
+ "total_cost_usd": (t.total_cost * 100.0).round() / 100.0,
366
+ "requests": t.total_requests,
367
+ "unpriced_models": t.unknown_models,
368
+ "rows": arr,
369
+ })
370
+ );
371
+ }