@archeagle087/costcrew 0.4.0 → 0.4.2
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/bin.js +12 -5
- package/package.json +8 -5
- package/.github/workflows/ci.yml +0 -21
- package/.github/workflows/npm-release.yml +0 -107
- package/.github/workflows/prices-monthly.yml +0 -58
- package/.github/workflows/release.yml +0 -45
- package/Cargo.lock +0 -980
- package/Cargo.toml +0 -22
- 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/data/prices.json +0 -1
- 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 +0 -38
- package/install.sh +0 -56
- package/packaging/npm/bin.js +0 -43
- package/packaging/npm/package.json +0 -20
- package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +0 -15
- package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +0 -28
- package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +0 -5
- package/packaging/winget/README.md +0 -31
- package/scripts/render_screen.py +0 -221
- package/src/advise.rs +0 -154
- package/src/args.rs +0 -239
- package/src/board.rs +0 -227
- package/src/catalog.rs +0 -136
- package/src/demo.rs +0 -79
- package/src/fetch.rs +0 -155
- package/src/main.rs +0 -371
- package/src/price.rs +0 -218
- package/src/prices.rs +0 -182
- package/src/report.rs +0 -194
- package/src/scan.rs +0 -593
- package/src/spend.rs +0 -452
- package/src/ui.rs +0 -367
- package/work/costcrew-darwin-arm64/costcrew +0 -0
- package/work/costcrew-darwin-arm64/package.json +0 -9
- package/work/costcrew-darwin-x64/costcrew +0 -0
- package/work/costcrew-darwin-x64/package.json +0 -9
- package/work/costcrew-linux-x64/costcrew +0 -0
- package/work/costcrew-linux-x64/package.json +0 -9
- package/work/costcrew-win32-x64/costcrew.exe +0 -0
- package/work/costcrew-win32-x64/package.json +0 -9
package/src/main.rs
DELETED
|
@@ -1,371 +0,0 @@
|
|
|
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
|
-
}
|
package/src/price.rs
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
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
|
-
}
|