@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/price.rs ADDED
@@ -0,0 +1,218 @@
1
+ //! Per-model pricing. Approximate list prices, per 1M tokens, USD.
2
+ //! Prefix-matched, longest prefix wins. Match also works on OpenRouter-style
3
+ //! slugs ("anthropic/claude-opus-..." -> "claude-opus-...").
4
+
5
+ #[derive(Debug, Clone, Copy, Default)]
6
+ pub struct Pricing {
7
+ /// USD per 1M tokens
8
+ pub input: f64,
9
+ pub output: f64,
10
+ pub cache_read: f64,
11
+ pub cache_write: f64,
12
+ /// USD per hour of wall-clock usage (rented GPU / self-hosted inference)
13
+ pub per_hour: f64,
14
+ }
15
+
16
+ #[derive(Debug, Clone)]
17
+ pub struct PriceEntry {
18
+ pub prefix: String,
19
+ /// where this row came from: "curated" | "embedded" | "cache" | "custom"
20
+ pub src: String,
21
+ pub price: Pricing,
22
+ }
23
+
24
+ pub struct PriceDB {
25
+ entries: Vec<PriceEntry>,
26
+ pub embedded_count: usize,
27
+ pub cache_count: usize,
28
+ pub custom_count: usize,
29
+ pub embedded_fetched_at_ms: u64,
30
+ }
31
+
32
+ /// The bundled price database: a full OpenRouter catalog snapshot, refreshed
33
+ /// monthly by CI and committed to `data/prices.json`. Ships inside the binary —
34
+ /// every install knows every model's price with zero network, zero scanning.
35
+ const EMBEDDED: &str = include_str!("../data/prices.json");
36
+
37
+ fn prices_from_doc(v: &serde_json::Value, src: &str, db: &mut PriceDB) -> (usize, u64) {
38
+ let fetched = v
39
+ .get("fetched_at_ms")
40
+ .and_then(serde_json::Value::as_u64)
41
+ .unwrap_or(0);
42
+ let mut n = 0usize;
43
+ if let Some(list) = v.get("models").and_then(serde_json::Value::as_array) {
44
+ for m in list {
45
+ let id = match m.get("id").and_then(serde_json::Value::as_str) {
46
+ Some(s) => s,
47
+ None => continue,
48
+ };
49
+ let get = |k: &str| match m.get(k) {
50
+ Some(serde_json::Value::Number(n)) => n.as_f64().unwrap_or(0.0),
51
+ Some(serde_json::Value::String(s)) => s.trim().parse().unwrap_or(0.0),
52
+ _ => 0.0,
53
+ };
54
+ db.entries.push(PriceEntry {
55
+ prefix: id.to_string(),
56
+ src: src.to_string(),
57
+ price: Pricing {
58
+ input: get("prompt"),
59
+ output: get("completion"),
60
+ cache_read: get("cache_read"),
61
+ cache_write: get("cache_write"),
62
+ per_hour: 0.0,
63
+ },
64
+ });
65
+ n += 1;
66
+ }
67
+ }
68
+ (n, fetched)
69
+ }
70
+
71
+ /// (prefix, input, output, cache_read, cache_write) — curated list-price table.
72
+ /// Sources: vendor price pages as of mid-2026. Treat as approximate.
73
+ const TABLE: &[(&str, f64, f64, f64, f64)] = &[
74
+ // --- Anthropic ---
75
+ ("claude-opus-4", 15.0, 75.0, 1.5, 18.75),
76
+ ("claude-sonnet-4", 3.0, 15.0, 0.3, 3.75),
77
+ ("claude-haiku-4", 1.0, 5.0, 0.1, 1.25),
78
+ ("claude-3-7-sonnet", 3.0, 15.0, 0.3, 3.75),
79
+ ("claude-3-5-sonnet", 3.0, 15.0, 0.3, 3.75),
80
+ ("claude-3-5-haiku", 0.8, 4.0, 0.08, 1.0),
81
+ ("claude-3-opus", 15.0, 75.0, 1.5, 18.75),
82
+ // --- OpenAI ---
83
+ ("gpt-5", 1.25, 10.0, 0.125, 1.25),
84
+ ("gpt-4.1", 2.0, 8.0, 0.5, 2.0),
85
+ ("gpt-4o", 2.5, 10.0, 1.25, 2.5),
86
+ ("o3", 2.0, 8.0, 0.5, 2.0),
87
+ ("o4-mini", 1.1, 4.4, 0.28, 1.1),
88
+ ("codex", 1.25, 10.0, 0.125, 1.25),
89
+ ("chatgpt", 5.0, 20.0, 0.5, 5.0),
90
+ // --- Google ---
91
+ ("gemini-3", 2.0, 12.0, 0.2, 2.0),
92
+ ("gemini-2", 1.25, 10.0, 0.31, 1.25),
93
+ ("gemini", 1.25, 10.0, 0.31, 1.25),
94
+ // --- Zhipu / GLM ---
95
+ ("glm-5", 1.0, 3.2, 0.11, 1.0),
96
+ ("glm-4.6", 0.6, 2.2, 0.11, 0.6),
97
+ ("glm-4", 0.6, 2.2, 0.11, 0.6),
98
+ // --- DeepSeek ---
99
+ ("deepseek-chat", 0.27, 1.1, 0.07, 0.27),
100
+ ("deepseek-reasoner", 0.55, 2.19, 0.14, 0.55),
101
+ ("deepseek", 0.27, 1.1, 0.07, 0.27),
102
+ // --- Alibaba ---
103
+ ("qwen3", 0.4, 1.2, 0.04, 0.4),
104
+ ("qwen", 0.4, 1.2, 0.04, 0.4),
105
+ // --- Moonshot ---
106
+ ("kimi-k2", 0.6, 2.5, 0.07, 0.6),
107
+ ("kimi", 0.6, 2.5, 0.07, 0.6),
108
+ // --- xAI ---
109
+ ("grok-4", 3.0, 15.0, 0.3, 3.0),
110
+ ("grok-3", 3.0, 15.0, 0.3, 3.0),
111
+ ("grok", 3.0, 15.0, 0.3, 3.0),
112
+ // --- Meta ---
113
+ ("llama-4", 0.5, 0.8, 0.05, 0.5),
114
+ ("llama-3", 0.9, 0.9, 0.09, 0.9),
115
+ ("llama", 0.3, 0.3, 0.03, 0.3),
116
+ // --- Mistral ---
117
+ ("mistral-large", 2.0, 6.0, 0.2, 2.0),
118
+ ("mistral-small", 0.1, 0.3, 0.01, 0.1),
119
+ ("devstral", 0.1, 0.3, 0.01, 0.1),
120
+ ("codestral", 0.3, 0.9, 0.03, 0.3),
121
+ ("mistral", 0.15, 0.6, 0.015, 0.15),
122
+ // --- Others ---
123
+ ("minimax", 0.3, 1.2, 0.03, 0.3),
124
+ ("command", 2.5, 10.0, 0.25, 2.5),
125
+ ("phi", 0.25, 1.0, 0.03, 0.25),
126
+ ];
127
+
128
+ impl PriceDB {
129
+ /// Curated vendor list + the full embedded catalog — the "everything" DB.
130
+ pub fn builtin() -> Self {
131
+ let mut db = Self::curated();
132
+ let (n, fetched) = prices_from_doc(
133
+ &serde_json::from_str(EMBEDDED).expect("embedded data/prices.json must parse"),
134
+ "embedded",
135
+ &mut db,
136
+ );
137
+ db.embedded_count = n;
138
+ db.embedded_fetched_at_ms = fetched;
139
+ db
140
+ }
141
+
142
+ /// Just the curated list-price table (used by tests / lean paths).
143
+ pub fn curated() -> Self {
144
+ let entries = TABLE
145
+ .iter()
146
+ .map(|(prefix, i, o, cr, cw)| PriceEntry {
147
+ prefix: prefix.to_string(),
148
+ src: "curated".to_string(),
149
+ price: Pricing {
150
+ input: *i,
151
+ output: *o,
152
+ cache_read: *cr,
153
+ cache_write: *cw,
154
+ per_hour: 0.0,
155
+ },
156
+ })
157
+ .collect();
158
+ PriceDB {
159
+ entries,
160
+ embedded_count: 0,
161
+ cache_count: 0,
162
+ custom_count: 0,
163
+ embedded_fetched_at_ms: 0,
164
+ }
165
+ }
166
+
167
+ /// `src` tags the origin and doubles as precedence: on equal-length
168
+ /// prefix ties the LATEST entry wins, so the load order
169
+ /// curated → embedded → cache → custom is also the override order.
170
+ pub fn push(&mut self, prefix: &str, src: &str, price: Pricing) {
171
+ self.entries.push(PriceEntry {
172
+ prefix: prefix.to_string(),
173
+ src: src.to_string(),
174
+ price,
175
+ });
176
+ }
177
+
178
+ pub fn entries(&self) -> &[PriceEntry] {
179
+ &self.entries
180
+ }
181
+
182
+ fn key_of(model: &str) -> &str {
183
+ // "anthropic/claude-opus-4" -> "claude-opus-4" so OpenRouter slugs match
184
+ model.rsplit('/').next().unwrap_or(model)
185
+ }
186
+
187
+ pub fn lookup(&self, model: &str) -> Option<Pricing> {
188
+ // normalize: OpenRouter uses dots ("claude-fable-5.1"), lmarena/agents
189
+ // use hyphens — treat both separators as equivalent
190
+ let norm = |s: &str| {
191
+ s.to_lowercase()
192
+ .chars()
193
+ .map(|c| {
194
+ if c == '.' || c == '_' || c == '-' {
195
+ '-'
196
+ } else {
197
+ c
198
+ }
199
+ })
200
+ .collect::<String>()
201
+ };
202
+ let full = norm(model);
203
+ let seg = Self::key_of(&full).to_string();
204
+
205
+ let mut best: Option<(usize, Pricing)> = None; // (prefix_len, price)
206
+ for e in &self.entries {
207
+ let p = norm(&e.prefix);
208
+ let hit = seg.starts_with(&p) || full.starts_with(&p);
209
+ if hit {
210
+ let len = p.len();
211
+ if best.is_none_or(|(l, _)| len >= l) {
212
+ best = Some((len, e.price));
213
+ }
214
+ }
215
+ }
216
+ best.map(|(_, v)| v)
217
+ }
218
+ }
package/src/prices.rs ADDED
@@ -0,0 +1,182 @@
1
+ //! `costcrew prices` — browse the bundled price database.
2
+ //!
3
+ //! The database ships with the binary: a curated vendor table plus the full
4
+ //! embedded OpenRouter catalog (data/prices.json), optionally enriched by the
5
+ //! on-disk cache (`fetch-prices`) and custom overrides (`--prices`). No
6
+ //! network, no log scanning — it's just a database you can read.
7
+
8
+ use crate::price::PriceDB;
9
+ use crate::report::{BOLD, CYAN, DIM, GREEN, RESET, YELLOW};
10
+
11
+ pub fn print_prices(db: &PriceDB, query: Option<&str>, sort: &str, top: usize, json: bool) {
12
+ // filter + dedupe: the same model can appear in curated + embedded +
13
+ // cache + custom; the LATEST entry is authoritative (matches lookup order)
14
+ let mut latest: std::collections::HashMap<String, &crate::price::PriceEntry> =
15
+ std::collections::HashMap::new();
16
+ for e in db.entries() {
17
+ if let Some(q) = query {
18
+ if !e.prefix.to_lowercase().contains(&q.to_lowercase()) {
19
+ continue;
20
+ }
21
+ }
22
+ latest.insert(e.prefix.clone(), e);
23
+ }
24
+ let mut rows: Vec<&crate::price::PriceEntry> = latest.into_values().collect();
25
+
26
+ match sort {
27
+ "price" | "cost" => rows.sort_by(|a, b| {
28
+ let ka = a.price.input + a.price.output;
29
+ let kb = b.price.input + b.price.output;
30
+ kb.partial_cmp(&ka).unwrap_or(std::cmp::Ordering::Equal) // priciest first
31
+ }),
32
+ "cheap" => rows.sort_by(|a, b| {
33
+ let ka = a.price.input + a.price.output;
34
+ let kb = b.price.input + b.price.output;
35
+ ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal) // cheapest first
36
+ }),
37
+ _ => rows.sort_by(|a, b| a.prefix.cmp(&b.prefix)), // name
38
+ }
39
+
40
+ if json {
41
+ use serde_json::json;
42
+ let arr: Vec<_> = rows
43
+ .iter()
44
+ .take(top.max(1))
45
+ .map(|e| {
46
+ json!({
47
+ "model": e.prefix,
48
+ "input_per_1m": e.price.input,
49
+ "output_per_1m": e.price.output,
50
+ "cache_read_per_1m": e.price.cache_read,
51
+ "cache_write_per_1m": e.price.cache_write,
52
+ "per_hour": if e.price.per_hour > 0.0 { serde_json::Value::from(e.price.per_hour) } else { serde_json::Value::Null },
53
+ "source": e.src,
54
+ })
55
+ })
56
+ .collect();
57
+ println!(
58
+ "{}",
59
+ json!({
60
+ "total_models": db.entries().len(),
61
+ "matches": rows.len(),
62
+ "rows": arr,
63
+ })
64
+ );
65
+ return;
66
+ }
67
+
68
+ println!();
69
+ println!(
70
+ " {BOLD}costcrew price database{RESET} {DIM}· bundled, {} models{RESET}",
71
+ db.entries().len()
72
+ );
73
+ println!(
74
+ " {DIM}{} curated table · {} embedded snapshot · {} live cache{} · {} custom{RESET}",
75
+ db.entries().iter().filter(|e| e.src == "curated").count(),
76
+ db.embedded_count,
77
+ db.cache_count,
78
+ if db.embedded_fetched_at_ms > 0 {
79
+ format!(" (snapshot {})", date_of(db.embedded_fetched_at_ms))
80
+ } else {
81
+ String::new()
82
+ },
83
+ db.custom_count,
84
+ );
85
+ println!(" {DIM}refresh anytime: {RESET}{CYAN}costcrew fetch-prices{RESET}");
86
+ println!();
87
+ if let Some(q) = query {
88
+ println!(" {BOLD}{} matches for \"{q}\"{RESET}{DIM} (--sort name|price|cheap, --top N, --json){RESET}", rows.len());
89
+ println!();
90
+ }
91
+ println!(
92
+ " {:<44} {:>9} {:>9} {:>10} {:<8}",
93
+ "model", "in $/1M", "out $/1M", "cache $/1M", "source"
94
+ );
95
+ println!(" {}", "-".repeat(44 + 3 + 9 + 3 + 9 + 3 + 10 + 2 + 8));
96
+
97
+ for e in rows.iter().take(top) {
98
+ let (i, o, c) = (e.price.input, e.price.output, e.price.cache_read);
99
+ let fmt = |v: f64| {
100
+ if v > 0.0 {
101
+ format!("{v:>9.3}")
102
+ } else {
103
+ " ?".to_string()
104
+ }
105
+ };
106
+ let src_col = match e.src.as_str() {
107
+ "curated" => GREEN,
108
+ "embedded" => CYAN,
109
+ "cache" => YELLOW,
110
+ _ => DIM,
111
+ };
112
+ println!(
113
+ " {:<44} {} {} {:>10} ({src_col}{:<6}{DIM}){RESET}",
114
+ truncate(&e.prefix, 44),
115
+ fmt(i),
116
+ fmt(o),
117
+ fmt(c),
118
+ e.src,
119
+ );
120
+ }
121
+
122
+ if rows.len() > top {
123
+ println!();
124
+ println!(
125
+ " {DIM}... {} more. narrower filter, or --top {}. e.g.:{RESET}",
126
+ rows.len() - top,
127
+ rows.len()
128
+ );
129
+ println!(
130
+ " {DIM} costcrew prices {}{RESET}",
131
+ query.unwrap_or("anthropic")
132
+ );
133
+ }
134
+ if rows.iter().take(top).any(|e| e.price.input == 0.0) {
135
+ println!();
136
+ println!(
137
+ " {DIM}? = free tier or unpriced. Unknown stays unknown — no invented prices.{RESET}"
138
+ );
139
+ }
140
+ println!();
141
+ }
142
+
143
+ fn truncate(s: &str, n: usize) -> String {
144
+ if s.chars().count() <= n {
145
+ s.to_string()
146
+ } else {
147
+ s.chars().take(n - 1).collect::<String>() + "…"
148
+ }
149
+ }
150
+
151
+ /// epoch ms → "Nov 10 2026" (civil-from-days, no chrono dependency)
152
+ fn date_of(ms: u64) -> String {
153
+ if ms == 0 {
154
+ return "undated".into();
155
+ }
156
+ let days = ms / 86_400_000;
157
+ // Howard Hinnant's civil_from_days
158
+ let z = days as i64 + 719_468;
159
+ let era = z.div_euclid(146_097);
160
+ let doe = z.rem_euclid(146_097);
161
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
162
+ let y = yoe + era * 400;
163
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
164
+ let mp = (5 * doy + 2) / 153;
165
+ let d = doy - (153 * mp + 2) / 5 + 1;
166
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
167
+ let y = if m <= 2 { y + 1 } else { y };
168
+ const MON: [&str; 12] = [
169
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
170
+ ];
171
+ format!("{} {} {}", MON[(m - 1) as usize], d, y)
172
+ }
173
+
174
+ #[cfg(test)]
175
+ mod tests {
176
+ use super::date_of;
177
+ #[test]
178
+ fn epoch_to_civil() {
179
+ assert_eq!(date_of(1_794_274_886_057), "Nov 10 2026");
180
+ assert_eq!(date_of(0), "undated");
181
+ }
182
+ }
package/src/report.rs ADDED
@@ -0,0 +1,194 @@
1
+ //! Terminal table + shareable markdown report.
2
+
3
+ use crate::scan::Row;
4
+
5
+ pub const RESET: &str = "\x1b[0m";
6
+ pub const DIM: &str = "\x1b[2m";
7
+ pub const BOLD: &str = "\x1b[1m";
8
+ pub const CYAN: &str = "\x1b[36m";
9
+ pub const YELLOW: &str = "\x1b[33m";
10
+ pub const GREEN: &str = "\x1b[32m";
11
+ pub const RED: &str = "\x1b[31m";
12
+
13
+ pub struct Totals {
14
+ pub total_cost: f64,
15
+ pub total_requests: u64,
16
+ pub unknown_models: usize,
17
+ }
18
+
19
+ pub fn print_table(rows: &[Row], since_days: u32, totals: &Totals) {
20
+ let period = if since_days == 1 {
21
+ "last 24h".to_string()
22
+ } else {
23
+ format!("last {since_days}d")
24
+ };
25
+
26
+ println!();
27
+ println!(" {BOLD}costcrew{RESET} {DIM}· AI agent spend · {period}{RESET}");
28
+ println!();
29
+
30
+ let widths = col_widths(rows);
31
+ let cost_w = 10;
32
+
33
+ // header
34
+ print!(" ");
35
+ for (i, h) in [
36
+ "provider",
37
+ "model",
38
+ "reqs",
39
+ "tokens in",
40
+ "tokens out",
41
+ "cache",
42
+ ]
43
+ .iter()
44
+ .enumerate()
45
+ {
46
+ print!("{DIM}{:>width$}{RESET} ", h, width = widths[i]);
47
+ }
48
+ println!("{DIM}{:>width$}{RESET}", "cost (usd)", width = cost_w);
49
+
50
+ // separator
51
+ print!(" ");
52
+ for w in &widths {
53
+ print!("{DIM}{}{RESET} ", "-".repeat(*w));
54
+ }
55
+ println!("{DIM}{}{RESET}", "-".repeat(cost_w));
56
+
57
+ // rows
58
+ for r in rows {
59
+ print!(" ");
60
+ print!("{:>w$} ", r.provider, w = widths[0]);
61
+ print!("{:>w$} ", truncate(&r.model, widths[1]), w = widths[1]);
62
+ print!("{:>w$} ", r.requests, w = widths[2]);
63
+ print!("{:>w$} ", r.input_tokens, w = widths[3]);
64
+ print!("{:>w$} ", r.output_tokens, w = widths[4]);
65
+ print!("{:>w$} ", r.cache_tokens, w = widths[5]);
66
+ if r.known_price {
67
+ let color = cost_color(r.cost);
68
+ print!("{}{:>10.4}{}", color, r.cost, RESET);
69
+ } else {
70
+ print!("{:>10}", "?");
71
+ }
72
+ println!();
73
+ }
74
+
75
+ // totals
76
+ let t_in: u64 = rows.iter().map(|r| r.input_tokens).sum();
77
+ let t_out: u64 = rows.iter().map(|r| r.output_tokens).sum();
78
+ let t_cache: u64 = rows.iter().map(|r| r.cache_tokens).sum();
79
+ print!(" ");
80
+ for w in &widths[..2] {
81
+ print!("{DIM}{}{RESET} ", "-".repeat(*w));
82
+ }
83
+ print!("{BOLD}total{RESET} ");
84
+ print!("{:>w$} ", "", w = widths[2] + 1);
85
+ print!("{:>w$} ", t_in, w = widths[3] + 1);
86
+ print!("{:>w$} ", t_out, w = widths[4] + 1);
87
+ print!("{:>w$} ", t_cache, w = widths[5] + 1);
88
+ // -0.0 guard: an all-unpriced table shouldn't render as negative money
89
+ let shown = if totals.total_cost.abs() < 0.000_1 {
90
+ 0.0
91
+ } else {
92
+ totals.total_cost
93
+ };
94
+ println!("{BOLD}{GREEN}${:>8.2}{RESET}", shown);
95
+ println!();
96
+
97
+ // bar chart of spend share
98
+ let max = rows.iter().map(|r| r.cost).fold(0.0_f64, f64::max);
99
+ if max > 0.0 {
100
+ for r in rows.iter().filter(|r| r.known_price) {
101
+ let scale = ((r.cost / max) * 24.0).round() as usize;
102
+ let units = if r.cost > 0.0 { scale.max(1) } else { 0 };
103
+ let bar: String = std::iter::repeat_n('#', units).collect();
104
+ let color = cost_color(r.cost);
105
+ println!(
106
+ " {:>26} {color}{bar}{RESET} ${:.2}",
107
+ truncate(&r.model, 26),
108
+ r.cost
109
+ );
110
+ }
111
+ println!();
112
+ }
113
+
114
+ if totals.unknown_models > 0 {
115
+ println!(
116
+ " {DIM}{} model(s) not priced yet — saw them, didn't bill them.{RESET}",
117
+ totals.unknown_models
118
+ );
119
+ }
120
+ }
121
+
122
+ fn cost_color(cost: f64) -> &'static str {
123
+ if cost < 1.0 {
124
+ GREEN
125
+ } else if cost < 10.0 {
126
+ YELLOW
127
+ } else {
128
+ RED
129
+ }
130
+ }
131
+
132
+ fn truncate(s: &str, max: usize) -> String {
133
+ if s.len() <= max {
134
+ s.to_string()
135
+ } else {
136
+ format!("{}…", &s[..max.saturating_sub(1)])
137
+ }
138
+ }
139
+
140
+ fn col_widths(rows: &[Row]) -> [usize; 6] {
141
+ let mut w = [
142
+ "provider".len(),
143
+ "model".len(),
144
+ "reqs".len(),
145
+ "tokens in".len(),
146
+ "tokens out".len(),
147
+ "cache".len(),
148
+ ];
149
+ for r in rows {
150
+ w[0] = w[0].max(r.provider.len());
151
+ w[1] = w[1].max(r.model.len().min(40));
152
+ w[2] = w[2].max(r.requests.to_string().len());
153
+ w[3] = w[3].max(r.input_tokens.to_string().len());
154
+ w[4] = w[4].max(r.output_tokens.to_string().len());
155
+ w[5] = w[5].max(r.cache_tokens.to_string().len());
156
+ }
157
+ w
158
+ }
159
+
160
+ pub fn print_markdown(rows: &[Row], since_days: u32, totals: &Totals) {
161
+ println!("# costcrew report");
162
+ println!();
163
+ println!(
164
+ "AI agent spend, last {since_days} day(s). Generated by [costcrew](https://github.com/Arch087146/costcrew)."
165
+ );
166
+ println!();
167
+ println!("| provider | model | reqs | tokens in | tokens out | cached | cost (USD) |");
168
+ println!("|---|---|---:|---:|---:|---:|---:|");
169
+ for r in rows {
170
+ let cost = if r.known_price {
171
+ format!("${:.4}", r.cost)
172
+ } else {
173
+ "?".to_string()
174
+ };
175
+ println!(
176
+ "| {} | `{}` | {} | {} | {} | {} | {} |",
177
+ r.provider, r.model, r.requests, r.input_tokens, r.output_tokens, r.cache_tokens, cost
178
+ );
179
+ }
180
+ let t_in: u64 = rows.iter().map(|r| r.input_tokens).sum();
181
+ let t_out: u64 = rows.iter().map(|r| r.output_tokens).sum();
182
+ let t_cache: u64 = rows.iter().map(|r| r.cache_tokens).sum();
183
+ println!(
184
+ "| **total** | | **{}** | **{}** | **{}** | **{}** | **${:.2}** |",
185
+ totals.total_requests, t_in, t_out, t_cache, totals.total_cost
186
+ );
187
+ if totals.unknown_models > 0 {
188
+ println!();
189
+ println!(
190
+ "> {} model(s) had no price entry and were excluded from totals.",
191
+ totals.unknown_models
192
+ );
193
+ }
194
+ }