@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/args.rs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
//! Simple arg parsing — no clap, keep the binary lean.
|
|
2
|
+
|
|
3
|
+
#[derive(Debug)]
|
|
4
|
+
pub struct Args {
|
|
5
|
+
pub cmd: Cmd,
|
|
6
|
+
pub json: bool,
|
|
7
|
+
pub since_days: u32,
|
|
8
|
+
pub help: bool,
|
|
9
|
+
pub key: Option<String>,
|
|
10
|
+
pub prices: Option<String>,
|
|
11
|
+
pub sort: Option<String>,
|
|
12
|
+
pub top: usize,
|
|
13
|
+
/// positional words after the command ("prices claude", "spend add 20")
|
|
14
|
+
pub pos: Vec<String>,
|
|
15
|
+
pub service: Option<String>,
|
|
16
|
+
pub model: Option<String>,
|
|
17
|
+
pub note: Option<String>,
|
|
18
|
+
pub at: Option<String>,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Debug, PartialEq)]
|
|
22
|
+
pub enum Cmd {
|
|
23
|
+
Overview,
|
|
24
|
+
Scan,
|
|
25
|
+
Demo,
|
|
26
|
+
Md,
|
|
27
|
+
Advise,
|
|
28
|
+
Catalog,
|
|
29
|
+
Leaderboard,
|
|
30
|
+
OpenRouter,
|
|
31
|
+
Prices,
|
|
32
|
+
Spend,
|
|
33
|
+
SpendAdd,
|
|
34
|
+
Fetch,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// two-word commands, checked before single-word matching
|
|
38
|
+
pub fn split_cmd(argv: &[String]) -> Option<(Cmd, &[String])> {
|
|
39
|
+
if argv.len() >= 2 {
|
|
40
|
+
match (argv[0].as_str(), argv[1].as_str()) {
|
|
41
|
+
("spend", "add") | ("s", "add") => return Some((Cmd::SpendAdd, &argv[2..])),
|
|
42
|
+
_ => {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
None
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub fn parse(argv: &[String]) -> Result<Args, String> {
|
|
49
|
+
if let Some((sub, rest)) = split_cmd(argv) {
|
|
50
|
+
let mut args = def(sub);
|
|
51
|
+
parse_into(&mut args, rest)?;
|
|
52
|
+
return Ok(args);
|
|
53
|
+
}
|
|
54
|
+
let mut args = def(Cmd::Overview);
|
|
55
|
+
parse_inner(&mut args, argv, &mut false)?; // commands may set cmd; default overview
|
|
56
|
+
if args.help {
|
|
57
|
+
print_help();
|
|
58
|
+
std::process::exit(0);
|
|
59
|
+
}
|
|
60
|
+
Ok(args)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn def(cmd: Cmd) -> Args {
|
|
64
|
+
Args {
|
|
65
|
+
cmd,
|
|
66
|
+
json: false,
|
|
67
|
+
since_days: 30,
|
|
68
|
+
help: false,
|
|
69
|
+
key: None,
|
|
70
|
+
prices: None,
|
|
71
|
+
sort: None,
|
|
72
|
+
top: 25,
|
|
73
|
+
pos: Vec::new(),
|
|
74
|
+
service: None,
|
|
75
|
+
model: None,
|
|
76
|
+
note: None,
|
|
77
|
+
at: None,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn parse_into(args: &mut Args, argv: &[String]) -> Result<(), String> {
|
|
82
|
+
let mut seen_cmd = true; // command already consumed ("spend add X")
|
|
83
|
+
parse_inner(args, argv, &mut seen_cmd)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pub fn parse_inner(args: &mut Args, argv: &[String], seen_cmd: &mut bool) -> Result<(), String> {
|
|
87
|
+
let mut it = argv.iter();
|
|
88
|
+
while let Some(a) = it.next() {
|
|
89
|
+
match a.as_str() {
|
|
90
|
+
"prices" | "db" if !*seen_cmd => {
|
|
91
|
+
args.cmd = Cmd::Prices;
|
|
92
|
+
*seen_cmd = true;
|
|
93
|
+
}
|
|
94
|
+
"spend" if !*seen_cmd => {
|
|
95
|
+
args.cmd = Cmd::Spend;
|
|
96
|
+
*seen_cmd = true;
|
|
97
|
+
}
|
|
98
|
+
"scan" | "c" if !*seen_cmd => {
|
|
99
|
+
args.cmd = Cmd::Scan;
|
|
100
|
+
*seen_cmd = true;
|
|
101
|
+
}
|
|
102
|
+
"demo" | "d" if !*seen_cmd => {
|
|
103
|
+
args.cmd = Cmd::Demo;
|
|
104
|
+
*seen_cmd = true;
|
|
105
|
+
}
|
|
106
|
+
"md" if !*seen_cmd => {
|
|
107
|
+
args.cmd = Cmd::Md;
|
|
108
|
+
*seen_cmd = true;
|
|
109
|
+
}
|
|
110
|
+
"advise" | "a" if !*seen_cmd => {
|
|
111
|
+
args.cmd = Cmd::Advise;
|
|
112
|
+
*seen_cmd = true;
|
|
113
|
+
}
|
|
114
|
+
"leaderboard" | "lb" | "board" if !*seen_cmd => {
|
|
115
|
+
args.cmd = Cmd::Leaderboard;
|
|
116
|
+
*seen_cmd = true;
|
|
117
|
+
}
|
|
118
|
+
"catalog" if !*seen_cmd => {
|
|
119
|
+
args.cmd = Cmd::Catalog;
|
|
120
|
+
*seen_cmd = true;
|
|
121
|
+
}
|
|
122
|
+
"openrouter" | "or" if !*seen_cmd => {
|
|
123
|
+
args.cmd = Cmd::OpenRouter;
|
|
124
|
+
*seen_cmd = true;
|
|
125
|
+
}
|
|
126
|
+
"fetch-prices" if !*seen_cmd => {
|
|
127
|
+
args.cmd = Cmd::Fetch;
|
|
128
|
+
*seen_cmd = true;
|
|
129
|
+
}
|
|
130
|
+
"--json" => args.json = true,
|
|
131
|
+
"--sort" => {
|
|
132
|
+
args.sort = Some(it.next().ok_or("--sort needs a value")?.to_string());
|
|
133
|
+
}
|
|
134
|
+
"--top" => {
|
|
135
|
+
let v = it.next().ok_or("--top needs a number")?;
|
|
136
|
+
args.top = v
|
|
137
|
+
.parse()
|
|
138
|
+
.map_err(|_| format!("--top must be a number, got {v}"))?;
|
|
139
|
+
}
|
|
140
|
+
"--service" => {
|
|
141
|
+
args.service = Some(it.next().ok_or("--service needs a value")?.to_string());
|
|
142
|
+
}
|
|
143
|
+
"--model" => {
|
|
144
|
+
args.model = Some(it.next().ok_or("--model needs a value")?.to_string());
|
|
145
|
+
}
|
|
146
|
+
"--note" => {
|
|
147
|
+
args.note = Some(it.next().ok_or("--note needs a value")?.to_string());
|
|
148
|
+
}
|
|
149
|
+
"--at" => {
|
|
150
|
+
args.at = Some(it.next().ok_or("--at needs YYYY-MM-DD")?.to_string());
|
|
151
|
+
}
|
|
152
|
+
"--help" | "-h" | "help" => args.help = true,
|
|
153
|
+
"--version" | "-V" | "-v" => {
|
|
154
|
+
println!("costcrew {}", env!("CARGO_PKG_VERSION"));
|
|
155
|
+
std::process::exit(0);
|
|
156
|
+
}
|
|
157
|
+
"--key" => {
|
|
158
|
+
args.key = Some(it.next().ok_or("--key needs a value")?.to_string());
|
|
159
|
+
}
|
|
160
|
+
"--prices" => {
|
|
161
|
+
args.prices = Some(it.next().ok_or("--prices needs a path")?.to_string());
|
|
162
|
+
}
|
|
163
|
+
"--since" => {
|
|
164
|
+
let v = it.next().ok_or("--since needs a value like 7d")?;
|
|
165
|
+
args.since_days = parse_since(v)?;
|
|
166
|
+
}
|
|
167
|
+
_ => {
|
|
168
|
+
if let Some(rest) = a.strip_prefix("--since=") {
|
|
169
|
+
args.since_days = parse_since(rest)?;
|
|
170
|
+
} else if a.starts_with('-') {
|
|
171
|
+
return Err(format!("unknown argument: {a}\ntry `costcrew --help`"));
|
|
172
|
+
} else if a == "add" && args.cmd == Cmd::Spend {
|
|
173
|
+
// tolerate `costcrew spend add 20` ordering variants
|
|
174
|
+
} else {
|
|
175
|
+
args.pos.push(a.to_string());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
Ok(())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn parse_since(v: &str) -> Result<u32, String> {
|
|
184
|
+
let v = v.trim().to_lowercase();
|
|
185
|
+
let (num, unit) = v.split_at(v.len().saturating_sub(1));
|
|
186
|
+
let n: u32 = num.parse().map_err(|_| format!("bad --since value: {v}"))?;
|
|
187
|
+
match unit {
|
|
188
|
+
"d" => Ok(n.max(1)),
|
|
189
|
+
"w" => Ok(n * 7),
|
|
190
|
+
"m" => Ok(n * 30),
|
|
191
|
+
_ => Err(format!("--since units are d/w/m, got: {v}")),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
pub fn print_help() {
|
|
196
|
+
println!(
|
|
197
|
+
"costcrew {} — the AI coding price database, plus your spend
|
|
198
|
+
|
|
199
|
+
USAGE — THE DATABASE:
|
|
200
|
+
costcrew opens the interactive app (splash + menu);
|
|
201
|
+
any argument instead uses classic flag mode
|
|
202
|
+
costcrew prices [filter] browse {} models (bundled, offline):
|
|
203
|
+
every input/output/cache rate per 1M tokens
|
|
204
|
+
--sort name|price|cheap --top N --json
|
|
205
|
+
costcrew leaderboard arena Elo ranked with prices:
|
|
206
|
+
--sort elo|value|price --top N --json
|
|
207
|
+
costcrew catalog which agent ships with which models
|
|
208
|
+
costcrew fetch-prices refresh the DB from OpenRouter live
|
|
209
|
+
(cached at %LOCALAPPDATA%\\costcrew)
|
|
210
|
+
|
|
211
|
+
USAGE — YOUR SPEND:
|
|
212
|
+
costcrew spend what YOU pay for AI: monthly totals,
|
|
213
|
+
per-service breakdown (you record it)
|
|
214
|
+
costcrew spend add 19.99 record a payment:
|
|
215
|
+
--service \"Claude Pro\" --model opus
|
|
216
|
+
--note \"monthly\" --at 2026-11-10
|
|
217
|
+
costcrew openrouter actual per-model spend from an API key
|
|
218
|
+
(env OPENROUTER_API_KEY or --key sk-or-v1-...)
|
|
219
|
+
|
|
220
|
+
USAGE — OPTIONAL ESTIMATES:
|
|
221
|
+
costcrew [scan] estimate per-token cost from local agent logs
|
|
222
|
+
costcrew advise could you have used a cheaper model? verdicts
|
|
223
|
+
costcrew md > report.md shareable markdown report
|
|
224
|
+
costcrew demo sample report from synthetic data
|
|
225
|
+
|
|
226
|
+
FLAGS:
|
|
227
|
+
--since <Nd|Nw|Nm> lookback for scan/advise/spend (default 30d)
|
|
228
|
+
--prices <file.json> custom price overrides, e.g. a rented GPU:
|
|
229
|
+
[{{\"prefix\":\"local/llama-3\",\"per_hour\":2.19}}]
|
|
230
|
+
per_hour bills wall-clock (opencode logs the ms)
|
|
231
|
+
--json machine-readable output
|
|
232
|
+
-h, --help this help
|
|
233
|
+
|
|
234
|
+
UNKNOWN PRICES STAY '?':
|
|
235
|
+
the database never invents a number — a free/unpriced model shows ?",
|
|
236
|
+
env!("CARGO_PKG_VERSION"),
|
|
237
|
+
509,
|
|
238
|
+
);
|
|
239
|
+
}
|
package/src/board.rs
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
//! `costcrew leaderboard` — arena-style model ranking, priced.
|
|
2
|
+
//!
|
|
3
|
+
//! Scores: bundled snapshot of LMArena's Coding category (blind pairwise votes,
|
|
4
|
+
//! Bradley-Terry/Elo). Prices: merged from the built-in table + the cached
|
|
5
|
+
//! OpenRouter list (see `costcrew fetch-prices`).
|
|
6
|
+
//!
|
|
7
|
+
//! The value column is tuned for how coding AGENTS actually burn tokens:
|
|
8
|
+
//! agent_blend = 0.2·input + 0.1·output + 0.7·cache_read (per 1M tokens),
|
|
9
|
+
//! from real OpenCode session mixes (cache dwarfs everything in agentic loops).
|
|
10
|
+
//! value = (elo − 1400) / agent_blend → points per dollar.
|
|
11
|
+
|
|
12
|
+
use crate::price::PriceDB;
|
|
13
|
+
use crate::report::{BOLD, DIM, GREEN, RED, RESET, YELLOW};
|
|
14
|
+
|
|
15
|
+
pub struct BoardEntry {
|
|
16
|
+
/// prefix that must resolve through the price DB
|
|
17
|
+
pub prefix: &'static str,
|
|
18
|
+
pub display: &'static str,
|
|
19
|
+
pub elo: f64,
|
|
20
|
+
pub votes: u64,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Snapshot: LMArena Coding category, Nov 2026 (~2.0M votes). Best-effort row
|
|
24
|
+
/// per model (highest reasoning effort). Elo only — margins are ±4–19 pts.
|
|
25
|
+
#[rustfmt::skip]
|
|
26
|
+
static BOARD: &[BoardEntry] = &[
|
|
27
|
+
BoardEntry { prefix: "gemini-4-carbon", display: "Gemini 4 Carbon · Google", elo: 1564.0, votes: 1069 },
|
|
28
|
+
BoardEntry { prefix: "gemini-4-argon", display: "Gemini 4 Argon · Google", elo: 1553.0, votes: 4440 },
|
|
29
|
+
BoardEntry { prefix: "claude-fable-5-1", display: "Claude Fable 5.1 · Anthropic", elo: 1534.0, votes: 3870 },
|
|
30
|
+
BoardEntry { prefix: "claude-fable-5", display: "Claude Fable 5 · Anthropic", elo: 1551.0, votes: 11686 },
|
|
31
|
+
BoardEntry { prefix: "claude-opus-4-7", display: "Claude Opus 4.7 · Anthropic", elo: 1546.0, votes: 18359 },
|
|
32
|
+
BoardEntry { prefix: "claude-opus-4-6", display: "Claude Opus 4.6 · Anthropic", elo: 1547.0, votes: 24575 },
|
|
33
|
+
BoardEntry { prefix: "claude-opus-4-5", display: "Claude Opus 4.5 · Anthropic", elo: 1530.0, votes: 7752 },
|
|
34
|
+
BoardEntry { prefix: "claude-opus-5-5", display: "Claude Opus 5.5 · Anthropic", elo: 1546.0, votes: 3257 },
|
|
35
|
+
BoardEntry { prefix: "claude-opus-5", display: "Claude Opus 5 · Anthropic", elo: 1532.0, votes: 18838 },
|
|
36
|
+
BoardEntry { prefix: "claude-sonnet-5-5", display: "Claude Sonnet 5.5 · Anthropic", elo: 1517.0, votes: 3109 },
|
|
37
|
+
BoardEntry { prefix: "claude-sonnet-5", display: "Claude Sonnet 5 · Anthropic", elo: 1520.0, votes: 14082 },
|
|
38
|
+
BoardEntry { prefix: "claude-sonnet-4-6", display: "Claude Sonnet 4.6 · Anthropic", elo: 1528.0, votes: 19474 },
|
|
39
|
+
BoardEntry { prefix: "claude-haiku-5-5", display: "Claude Haiku 5.5 · Anthropic", elo: 1455.0, votes: 3003 },
|
|
40
|
+
BoardEntry { prefix: "claude-haiku-4-5", display: "Claude Haiku 4.5 · Anthropic", elo: 1453.0, votes: 39588 },
|
|
41
|
+
BoardEntry { prefix: "gpt-6-astra", display: "GPT-6 Astra · OpenAI", elo: 1548.0, votes: 4096 },
|
|
42
|
+
BoardEntry { prefix: "gpt-6-1-sol", display: "GPT-6.1 Sol · OpenAI", elo: 1541.0, votes: 2668 },
|
|
43
|
+
BoardEntry { prefix: "gpt-6-sol", display: "GPT-6 Sol · OpenAI", elo: 1519.0, votes: 4530 },
|
|
44
|
+
BoardEntry { prefix: "gpt-6-luna", display: "GPT-6 Luna · OpenAI", elo: 1501.0, votes: 24306 },
|
|
45
|
+
BoardEntry { prefix: "gpt-5-6-sol", display: "GPT-5.6 Sol · OpenAI", elo: 1533.0, votes: 11621 },
|
|
46
|
+
BoardEntry { prefix: "gpt-5-5", display: "GPT-5.5 · OpenAI", elo: 1510.0, votes: 19751 },
|
|
47
|
+
BoardEntry { prefix: "gpt-5-4", display: "GPT-5.4 · OpenAI", elo: 1521.0, votes: 17419 },
|
|
48
|
+
BoardEntry { prefix: "gpt-5-2", display: "GPT-5.2 · OpenAI", elo: 1442.0, votes: 12151 },
|
|
49
|
+
BoardEntry { prefix: "muse-wonder", display: "Muse Wonder · Meta", elo: 1546.0, votes: 2780 },
|
|
50
|
+
BoardEntry { prefix: "muse-spark-1-3", display: "Muse Spark 1.3 · Meta", elo: 1532.0, votes: 6575 },
|
|
51
|
+
BoardEntry { prefix: "kimi-k3", display: "Kimi K3 · Moonshot", elo: 1537.0, votes: 10072 },
|
|
52
|
+
BoardEntry { prefix: "kimi-k2-6", display: "Kimi K2.6 · Moonshot", elo: 1516.0, votes: 10982 },
|
|
53
|
+
BoardEntry { prefix: "glm-5-3", display: "GLM-5.3 · Z.ai", elo: 1522.0, votes: 7164 },
|
|
54
|
+
BoardEntry { prefix: "glm-5-3-flash", display: "GLM-5.3-Flash · Z.ai", elo: 1527.0, votes: 12519 },
|
|
55
|
+
BoardEntry { prefix: "glm-5-2", display: "GLM-5.2 · Z.ai", elo: 1515.0, votes: 14375 },
|
|
56
|
+
BoardEntry { prefix: "glm-4-6", display: "GLM 4.6 · Z.ai", elo: 1449.0, votes: 7577 },
|
|
57
|
+
BoardEntry { prefix: "deepseek-v4-1-flash", display: "DeepSeek V4.1 Flash · DeepSeek", elo: 1525.0, votes: 3719 },
|
|
58
|
+
BoardEntry { prefix: "deepseek-v4-pro", display: "DeepSeek V4 Pro · DeepSeek", elo: 1503.0, votes: 2756 },
|
|
59
|
+
BoardEntry { prefix: "qwen3-8-max", display: "Qwen3.8-Max · Alibaba", elo: 1524.0, votes: 9596 },
|
|
60
|
+
BoardEntry { prefix: "qwen3-8-27b", display: "Qwen3.8-27B · Alibaba", elo: 1478.0, votes: 8592 },
|
|
61
|
+
BoardEntry { prefix: "grok-4-6", display: "Grok 4.6 · xAI", elo: 1508.0, votes: 8571 },
|
|
62
|
+
BoardEntry { prefix: "mimo-v2-6-pro", display: "MiMo-V2.6-Pro · Xiaomi", elo: 1538.0, votes: 1095 },
|
|
63
|
+
BoardEntry { prefix: "mimo-v2-6-flash", display: "MiMo-V2.6-Flash · Xiaomi", elo: 1511.0, votes: 5033 },
|
|
64
|
+
BoardEntry { prefix: "gemini-3-8-flash", display: "Gemini 3.8 Flash · Google", elo: 1530.0, votes: 15174 },
|
|
65
|
+
BoardEntry { prefix: "gemini-3-7-flash", display: "Gemini 3.7 Flash · Google", elo: 1520.0, votes: 8973 },
|
|
66
|
+
BoardEntry { prefix: "gemini-3-6-flash", display: "Gemini 3.6 Flash · Google", elo: 1518.0, votes: 10179 },
|
|
67
|
+
BoardEntry { prefix: "gemini-2-5-pro", display: "Gemini 2.5 Pro · Google", elo: 1452.0, votes: 26810 },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/// a representative agentic token blend (fractions of billable tokens)
|
|
71
|
+
const BLEND_IN: f64 = 0.2;
|
|
72
|
+
const BLEND_OUT: f64 = 0.1;
|
|
73
|
+
const BLEND_CACHE: f64 = 0.7;
|
|
74
|
+
const ELO_BASELINE: f64 = 1400.0;
|
|
75
|
+
|
|
76
|
+
fn agent_blend(p: crate::price::Pricing) -> f64 {
|
|
77
|
+
BLEND_IN * p.input + BLEND_OUT * p.output + BLEND_CACHE * p.cache_read
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[derive(Clone, Copy, PartialEq)]
|
|
81
|
+
pub enum Sort {
|
|
82
|
+
Elo,
|
|
83
|
+
Value,
|
|
84
|
+
Blend,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pub fn parse_sort(s: &str) -> Result<Sort, String> {
|
|
88
|
+
match s {
|
|
89
|
+
"elo" | "score" => Ok(Sort::Elo),
|
|
90
|
+
"value" => Ok(Sort::Value),
|
|
91
|
+
"price" | "cost" | "blend" => Ok(Sort::Blend),
|
|
92
|
+
_ => Err("--sort is elo | value | price".into()),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pub fn print_board(db: &PriceDB, sort: Sort, top: usize, json: bool) {
|
|
97
|
+
let mut rows: Vec<(&BoardEntry, Option<f64>, Option<f64>)> = BOARD
|
|
98
|
+
.iter()
|
|
99
|
+
.map(|b| {
|
|
100
|
+
let blend = db.lookup(b.prefix).map(agent_blend);
|
|
101
|
+
let value = blend.map(|blend| (b.elo - ELO_BASELINE) / blend);
|
|
102
|
+
(b, blend, value)
|
|
103
|
+
})
|
|
104
|
+
.collect();
|
|
105
|
+
|
|
106
|
+
match sort {
|
|
107
|
+
Sort::Elo => rows.sort_by(|a, b| {
|
|
108
|
+
b.0.elo
|
|
109
|
+
.partial_cmp(&a.0.elo)
|
|
110
|
+
.unwrap_or(std::cmp::Ordering::Equal)
|
|
111
|
+
}),
|
|
112
|
+
Sort::Value => rows.sort_by(|a, b| {
|
|
113
|
+
b.2.unwrap_or(f64::NEG_INFINITY)
|
|
114
|
+
.partial_cmp(&a.2.unwrap_or(f64::NEG_INFINITY))
|
|
115
|
+
.unwrap_or(std::cmp::Ordering::Equal)
|
|
116
|
+
}),
|
|
117
|
+
Sort::Blend => rows.sort_by(|a, b| {
|
|
118
|
+
a.1.unwrap_or(f64::INFINITY)
|
|
119
|
+
.partial_cmp(&b.1.unwrap_or(f64::INFINITY))
|
|
120
|
+
.unwrap_or(std::cmp::Ordering::Equal)
|
|
121
|
+
}),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if json {
|
|
125
|
+
use serde_json::json;
|
|
126
|
+
let arr: Vec<_> = rows
|
|
127
|
+
.iter()
|
|
128
|
+
.take(top)
|
|
129
|
+
.map(|(b, blend, value)| {
|
|
130
|
+
json!({
|
|
131
|
+
"model": b.display.trim(),
|
|
132
|
+
"prefix": b.prefix,
|
|
133
|
+
"arena_elo": b.elo,
|
|
134
|
+
"votes": b.votes,
|
|
135
|
+
"agent_blend_usd_per_1m": blend.map(|x| (x * 100.0).round() / 100.0),
|
|
136
|
+
"value_elo_per_dollar": value.map(|x| (x * 10.0).round() / 10.0),
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
.collect();
|
|
140
|
+
println!(
|
|
141
|
+
"{}",
|
|
142
|
+
json!({
|
|
143
|
+
"source": "LMArena Coding snapshot Nov 2026",
|
|
144
|
+
"blend": {"input": BLEND_IN, "output": BLEND_OUT, "cache_read": BLEND_CACHE},
|
|
145
|
+
"rows": arr,
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
println!();
|
|
152
|
+
println!(" {BOLD}costcrew leaderboard{RESET} {DIM}· LMArena Coding snapshot Nov 2026 · ~2.0M blind votes{RESET}");
|
|
153
|
+
println!();
|
|
154
|
+
println!(
|
|
155
|
+
" {:>3} {:<30} {:>5} {:>7} {:>8} {:>8} {:>9} {:>10}",
|
|
156
|
+
"#", "model", "elo", "votes", "in $", "out $", "agent$", "elo/$"
|
|
157
|
+
);
|
|
158
|
+
println!(
|
|
159
|
+
" {}",
|
|
160
|
+
"-".repeat(3 + 1 + 30 + 1 + 5 + 1 + 7 + 1 + 8 + 1 + 8 + 1 + 9 + 1 + 10)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
for (i, (b, blend, value)) in rows.iter().take(top).enumerate() {
|
|
164
|
+
let blend = *blend;
|
|
165
|
+
let value = *value;
|
|
166
|
+
let (bi, bo) = match blend {
|
|
167
|
+
Some(_) => {
|
|
168
|
+
let p = db.lookup(b.prefix).expect("blend implies price");
|
|
169
|
+
(p.input, p.output)
|
|
170
|
+
}
|
|
171
|
+
None => (f64::NAN, f64::NAN),
|
|
172
|
+
};
|
|
173
|
+
let price_str = |v: f64| {
|
|
174
|
+
if blend.is_some() {
|
|
175
|
+
format!("{:>7.2}", v)
|
|
176
|
+
} else {
|
|
177
|
+
" ?".to_string()
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
let blend_str = match blend {
|
|
181
|
+
Some(blend) => format!("{:.2}", blend),
|
|
182
|
+
None => "?".into(),
|
|
183
|
+
};
|
|
184
|
+
let value_str = match value {
|
|
185
|
+
Some(v) if v.is_finite() => format!("{:.1}", v),
|
|
186
|
+
_ => "-".into(),
|
|
187
|
+
};
|
|
188
|
+
let color = match value {
|
|
189
|
+
Some(v) if blend.is_some_and(|blend| blend > 0.0) && v >= 25.0 => GREEN,
|
|
190
|
+
Some(v) if blend.is_some_and(|blend| blend > 0.0) && v >= 8.0 => YELLOW,
|
|
191
|
+
_ if value.is_some() => RED,
|
|
192
|
+
_ => DIM,
|
|
193
|
+
};
|
|
194
|
+
println!(
|
|
195
|
+
" {:>3} {:<30} {:>5.0} {:>7} {} {} {:>9} {color}{:>10}{RESET}",
|
|
196
|
+
i + 1,
|
|
197
|
+
b.display,
|
|
198
|
+
b.elo,
|
|
199
|
+
format_votes(b.votes),
|
|
200
|
+
price_str(bi),
|
|
201
|
+
price_str(bo),
|
|
202
|
+
blend_str,
|
|
203
|
+
value_str,
|
|
204
|
+
color = color,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if rows.iter().all(|(_, _, _)| true) && rows.iter().any(|(_, blend, _)| blend.is_none()) {
|
|
209
|
+
println!();
|
|
210
|
+
println!(" {DIM}? = model not in your price sources yet. `costcrew fetch-prices` may fix it.{RESET}");
|
|
211
|
+
}
|
|
212
|
+
println!();
|
|
213
|
+
println!(
|
|
214
|
+
" {DIM}agent$ = typical agentic blend: 20% in + 10% out + 70% cache-read (per 1M).{RESET}"
|
|
215
|
+
);
|
|
216
|
+
println!(" {DIM}elo/$ = (elo − 1400) ÷ agent$ — value points per dollar{RESET}");
|
|
217
|
+
println!(" {DIM}snapshot: arena.ai / lmarena.ai — bump `src/board.rs` with a PR{RESET}");
|
|
218
|
+
println!();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fn format_votes(v: u64) -> String {
|
|
222
|
+
if v >= 1000 {
|
|
223
|
+
format!("{:.1}k", v as f64 / 1000.0)
|
|
224
|
+
} else {
|
|
225
|
+
format!("{v}")
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/catalog.rs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
//! `costcrew catalog` — what every coding agent can run on, and how it bills.
|
|
2
|
+
|
|
3
|
+
use crate::report::{BOLD, CYAN, DIM, RESET};
|
|
4
|
+
|
|
5
|
+
pub struct Agent {
|
|
6
|
+
pub name: &'static str,
|
|
7
|
+
/// how it bills
|
|
8
|
+
pub billing: &'static str,
|
|
9
|
+
/// can it run on arbitrary API keys?
|
|
10
|
+
pub byok: &'static str,
|
|
11
|
+
/// whether costcrew can read its logs
|
|
12
|
+
pub scanned: &'static str,
|
|
13
|
+
pub models: &'static str,
|
|
14
|
+
pub url: &'static str,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CATALOG: &[Agent] = &[
|
|
18
|
+
Agent {
|
|
19
|
+
name: "Claude Code",
|
|
20
|
+
billing: "Pro/Max subscription (weekly caps) OR pay-per-token API",
|
|
21
|
+
byok: "yes (Anthropic key)",
|
|
22
|
+
scanned: "yes (JSONL transcripts)",
|
|
23
|
+
models: "claude-opus / sonnet / haiku (all tiers)",
|
|
24
|
+
url: "docs.anthropic.com/claude-code",
|
|
25
|
+
},
|
|
26
|
+
Agent {
|
|
27
|
+
name: "OpenCode",
|
|
28
|
+
billing: "free, open source; you pay the model",
|
|
29
|
+
byok: "yes — Anthropic, OpenAI, Google, OpenRouter, GLM, local",
|
|
30
|
+
scanned: "yes (sqlite + JSON)",
|
|
31
|
+
models: "any provider; public models include big-pickle (free tier)",
|
|
32
|
+
url: "opencode.ai",
|
|
33
|
+
},
|
|
34
|
+
Agent {
|
|
35
|
+
name: "Codex CLI",
|
|
36
|
+
billing: "bundled with ChatGPT Plus/Pro; API per-token otherwise",
|
|
37
|
+
byok: "OpenAI key",
|
|
38
|
+
scanned: "best-effort (JSONL)",
|
|
39
|
+
models: "gpt-5 codex line (fast/slow lanes)",
|
|
40
|
+
url: "github.com/openai/codex",
|
|
41
|
+
},
|
|
42
|
+
Agent {
|
|
43
|
+
name: "Gemini CLI",
|
|
44
|
+
billing: "free tier ~1,000 req/day with Google login; Vertex/Studio per-token",
|
|
45
|
+
byok: "yes (GCP key)",
|
|
46
|
+
scanned: "not yet",
|
|
47
|
+
models: "gemini 3 / 2 nano",
|
|
48
|
+
url: "github.com/google-gemini/gemini-cli",
|
|
49
|
+
},
|
|
50
|
+
Agent {
|
|
51
|
+
name: "GitHub Copilot CLI",
|
|
52
|
+
billing: "Copilot Pro/Pro+ subscription; some models bill per premium-request",
|
|
53
|
+
byok: "no",
|
|
54
|
+
scanned: "not yet",
|
|
55
|
+
models: "gpt + claude + gemini, managed by GitHub policy",
|
|
56
|
+
url: "github.com/features/copilot",
|
|
57
|
+
},
|
|
58
|
+
Agent {
|
|
59
|
+
name: "Kilo Code",
|
|
60
|
+
billing: "extension, free; pay via OpenRouter or your own API keys",
|
|
61
|
+
byok: "yes (OpenRouter recommended)",
|
|
62
|
+
scanned: "yes-best-effort (task JSON)",
|
|
63
|
+
models: "anything on OpenRouter incl. free deepseek/qwen lanes",
|
|
64
|
+
url: "kilocode.ai",
|
|
65
|
+
},
|
|
66
|
+
Agent {
|
|
67
|
+
name: "Cline",
|
|
68
|
+
billing: "extension, free; bring API keys (incl. Anthropic/OpenRouter)",
|
|
69
|
+
byok: "yes — per-task cost shown in-app",
|
|
70
|
+
scanned: "yes-best-effort (task JSON)",
|
|
71
|
+
models: "any; plus auto-mode model picking",
|
|
72
|
+
url: "cline.bot",
|
|
73
|
+
},
|
|
74
|
+
Agent {
|
|
75
|
+
name: "Roo Code",
|
|
76
|
+
billing: "extension, free; BYOK",
|
|
77
|
+
byok: "yes; models scoped per mode (ask/code/architect)",
|
|
78
|
+
scanned: "yes-best-effort (task JSON)",
|
|
79
|
+
models: "anything through the major providers",
|
|
80
|
+
url: "roocode.com",
|
|
81
|
+
},
|
|
82
|
+
Agent {
|
|
83
|
+
name: "Aider",
|
|
84
|
+
billing: "open source CLI; pay per API token; total printed per commit",
|
|
85
|
+
byok: "yes — literally any openai-compatible endpoint incl. local Ollama",
|
|
86
|
+
scanned: "yes (Cost lines in .aider.chat.history.md)",
|
|
87
|
+
models: "any; pairs well with deepseek-chat",
|
|
88
|
+
url: "aider.chat",
|
|
89
|
+
},
|
|
90
|
+
Agent {
|
|
91
|
+
name: "Cursor",
|
|
92
|
+
billing: "Pro subscription ($20) + usage-based add-ons for frontier models",
|
|
93
|
+
byok: "limited",
|
|
94
|
+
scanned: "not yet",
|
|
95
|
+
models: "bundled: claude, gpt, gemini, cursor's own mini models",
|
|
96
|
+
url: "cursor.com",
|
|
97
|
+
},
|
|
98
|
+
Agent {
|
|
99
|
+
name: "Windsurf",
|
|
100
|
+
billing: "sub plans with monthly credit pool; overage billed",
|
|
101
|
+
byok: "yes on some tiers",
|
|
102
|
+
scanned: "not yet",
|
|
103
|
+
models: "bundled claude/gpt + their ide models",
|
|
104
|
+
url: "windsurf.com",
|
|
105
|
+
},
|
|
106
|
+
Agent {
|
|
107
|
+
name: "self-hosted (vLLM/ollama/llama.cpp)",
|
|
108
|
+
billing: "GPU rental from $1–3/hr (cloud) to free (local); electricity",
|
|
109
|
+
byok: "n/a — it IS the server",
|
|
110
|
+
scanned: "not yet (roadmap: opencode sessions cover it)",
|
|
111
|
+
models: "llama, qwen, deepseek, glm, mistral — open weights",
|
|
112
|
+
url: "docs.vllm.ai",
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
pub fn print_catalog() {
|
|
117
|
+
println!();
|
|
118
|
+
println!(
|
|
119
|
+
" {BOLD}costcrew catalog{RESET} {DIM}· who runs what · list-price snapshot mid-2026{RESET}"
|
|
120
|
+
);
|
|
121
|
+
println!();
|
|
122
|
+
|
|
123
|
+
for a in CATALOG {
|
|
124
|
+
println!(" {CYAN}{BOLD}{:<33}{RESET}", a.name);
|
|
125
|
+
println!(" {DIM}billing :{RESET} {}", a.billing);
|
|
126
|
+
println!(" {DIM}models :{RESET} {}", a.models);
|
|
127
|
+
println!(" {DIM}byok :{RESET} {}", a.byok);
|
|
128
|
+
println!(" {DIM}scanned :{RESET} {}", a.scanned);
|
|
129
|
+
println!(" {DIM}link :{RESET} {}", a.url);
|
|
130
|
+
println!();
|
|
131
|
+
}
|
|
132
|
+
println!(
|
|
133
|
+
" {DIM}got a lane we're missing? open an issue: github.com/Arch087146/costcrew{RESET}"
|
|
134
|
+
);
|
|
135
|
+
println!();
|
|
136
|
+
}
|
package/src/demo.rs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//! Synthetic data so users can see the report before they've used any agent.
|
|
2
|
+
|
|
3
|
+
use crate::scan::{aggregate, Row, UsageEvent};
|
|
4
|
+
|
|
5
|
+
/// Roughly realistic fake week of usage.
|
|
6
|
+
pub fn demo_events() -> Vec<UsageEvent> {
|
|
7
|
+
fn ev(provider: &str, model: &str, i: u64, o: u64, cr: u64, cw: u64) -> UsageEvent {
|
|
8
|
+
UsageEvent {
|
|
9
|
+
provider: provider.into(),
|
|
10
|
+
model: model.into(),
|
|
11
|
+
input_tokens: i,
|
|
12
|
+
output_tokens: o,
|
|
13
|
+
cache_read: cr,
|
|
14
|
+
cache_write: cw,
|
|
15
|
+
duration_ms: 0,
|
|
16
|
+
direct_cost: None,
|
|
17
|
+
source: "demo".into(),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
vec![
|
|
22
|
+
// a heavy week of Claude Code refactoring
|
|
23
|
+
ev(
|
|
24
|
+
"claude",
|
|
25
|
+
"claude-opus-4-5-20260115",
|
|
26
|
+
40_000,
|
|
27
|
+
9_000,
|
|
28
|
+
2_100_000,
|
|
29
|
+
310_000,
|
|
30
|
+
),
|
|
31
|
+
ev(
|
|
32
|
+
"claude",
|
|
33
|
+
"claude-opus-4-5-20260115",
|
|
34
|
+
31_000,
|
|
35
|
+
7_400,
|
|
36
|
+
1_850_000,
|
|
37
|
+
280_000,
|
|
38
|
+
),
|
|
39
|
+
ev(
|
|
40
|
+
"claude",
|
|
41
|
+
"claude-sonnet-4-5",
|
|
42
|
+
22_000,
|
|
43
|
+
5_100,
|
|
44
|
+
980_000,
|
|
45
|
+
140_000,
|
|
46
|
+
),
|
|
47
|
+
ev(
|
|
48
|
+
"claude",
|
|
49
|
+
"claude-sonnet-4-5",
|
|
50
|
+
18_500,
|
|
51
|
+
4_300,
|
|
52
|
+
870_000,
|
|
53
|
+
120_000,
|
|
54
|
+
),
|
|
55
|
+
ev(
|
|
56
|
+
"claude",
|
|
57
|
+
"claude-sonnet-4-5",
|
|
58
|
+
15_000,
|
|
59
|
+
3_800,
|
|
60
|
+
760_000,
|
|
61
|
+
98_000,
|
|
62
|
+
),
|
|
63
|
+
// OpenCode doing feature work on GLM
|
|
64
|
+
ev("opencode", "glm-5", 48_000, 12_000, 240_000, 60_000),
|
|
65
|
+
ev("opencode", "glm-5", 39_000, 9_800, 190_000, 44_000),
|
|
66
|
+
ev("opencode", "glm-5", 27_000, 6_900, 130_000, 31_000),
|
|
67
|
+
// Codex quick sessions
|
|
68
|
+
ev("codex", "gpt-5.2-codex", 12_000, 2_400, 410_000, 80_000),
|
|
69
|
+
ev("codex", "gpt-5.2-codex", 8_000, 1_600, 260_000, 51_000),
|
|
70
|
+
// deepseek via an API key (Kilo Code / OpenRouter lane)
|
|
71
|
+
ev("kilo", "deepseek-chat", 33_000, 7_200, 640_000, 96_000),
|
|
72
|
+
// one model we don't have a price for
|
|
73
|
+
ev("opencode", "mystery-coder-xl", 30_000, 8_000, 0, 10_000),
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub fn demo_rows(db: &super::price::PriceDB) -> Vec<Row> {
|
|
78
|
+
aggregate(&demo_events(), db)
|
|
79
|
+
}
|