@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.
- package/.github/workflows/ci.yml +21 -0
- package/.github/workflows/npm-release.yml +107 -0
- package/.github/workflows/prices-monthly.yml +58 -0
- package/.github/workflows/release.yml +45 -0
- package/Cargo.lock +980 -0
- package/Cargo.toml +22 -0
- package/LICENSE +21 -0
- package/README.md +255 -2
- package/assets/advise.png +0 -0
- package/assets/board.png +0 -0
- package/assets/catalog.png +0 -0
- package/assets/demo.png +0 -0
- package/assets/help.png +0 -0
- package/assets/mascot.png +0 -0
- package/assets/prices.png +0 -0
- package/assets/scan.png +0 -0
- package/assets/spend.png +0 -0
- package/assets/ui.png +0 -0
- package/bin.js +43 -0
- package/data/prices.json +1 -0
- package/dist/costcrew-linux-x86_64.tar.gz +0 -0
- package/dist/costcrew-macos-aarch64.tar.gz +0 -0
- package/dist/costcrew-macos-x86_64.tar.gz +0 -0
- package/dist/costcrew-windows-x86_64.zip +0 -0
- package/install.ps1 +38 -0
- package/install.sh +56 -0
- package/package.json +18 -4
- package/packaging/npm/bin.js +43 -0
- package/packaging/npm/package.json +20 -0
- package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +15 -0
- package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +28 -0
- package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +5 -0
- package/packaging/winget/README.md +31 -0
- package/scripts/render_screen.py +221 -0
- package/src/advise.rs +154 -0
- package/src/args.rs +239 -0
- package/src/board.rs +227 -0
- package/src/catalog.rs +136 -0
- package/src/demo.rs +79 -0
- package/src/fetch.rs +155 -0
- package/src/main.rs +371 -0
- package/src/price.rs +218 -0
- package/src/prices.rs +182 -0
- package/src/report.rs +194 -0
- package/src/scan.rs +593 -0
- package/src/spend.rs +452 -0
- package/src/ui.rs +367 -0
- package/work/costcrew-darwin-arm64/costcrew +0 -0
- package/work/costcrew-darwin-arm64/package.json +9 -0
- package/work/costcrew-darwin-x64/costcrew +0 -0
- package/work/costcrew-darwin-x64/package.json +9 -0
- package/work/costcrew-linux-x64/costcrew +0 -0
- package/work/costcrew-linux-x64/package.json +9 -0
- package/work/costcrew-win32-x64/costcrew.exe +0 -0
- 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
|
+
}
|