@archeagle087/costcrew 0.4.1 → 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 +10 -6
- package/package.json +8 -5
- package/.github/workflows/ci.yml +0 -21
- package/.github/workflows/npm-release.yml +0 -123
- 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 -46
- 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/board.rs
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
}
|
package/src/fetch.rs
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
//! Live pricing + spend from OpenRouter.
|
|
2
|
-
//!
|
|
3
|
-
//! Two uses:
|
|
4
|
-
//! 1. `--fetch-prices` pulls every OpenRouter model's price (public endpoint,
|
|
5
|
-
//! no key) and caches it locally — covers "every model, automatically".
|
|
6
|
-
//! 2. `costcrew openrouter` reads your OPENROUTER_API_KEY and reports what
|
|
7
|
-
//! you actually spent, per model, from the activity endpoint.
|
|
8
|
-
|
|
9
|
-
use crate::price::{PriceDB, Pricing};
|
|
10
|
-
use serde_json::Value;
|
|
11
|
-
use std::path::PathBuf;
|
|
12
|
-
use std::time::{Duration, SystemTime};
|
|
13
|
-
|
|
14
|
-
const MODELS_URL: &str = "https://openrouter.ai/api/v1/models";
|
|
15
|
-
const ACTIVITY_URL: &str = "https://openrouter.ai/api/v1/activity?limit=200";
|
|
16
|
-
const CACHE_VERSION: u64 = 1;
|
|
17
|
-
|
|
18
|
-
pub fn cache_dir() -> PathBuf {
|
|
19
|
-
if let Some(lad) = std::env::var_os("LOCALAPPDATA") {
|
|
20
|
-
return PathBuf::from(lad).join("costcrew");
|
|
21
|
-
}
|
|
22
|
-
std::env::var_os("HOME")
|
|
23
|
-
.map(PathBuf::from)
|
|
24
|
-
.unwrap_or_else(|| PathBuf::from("."))
|
|
25
|
-
.join(".cache/costcrew")
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
fn num(s: &Value) -> f64 {
|
|
29
|
-
match s {
|
|
30
|
-
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
|
31
|
-
Value::String(s) => s.trim().parse().unwrap_or(0.0),
|
|
32
|
-
_ => 0.0,
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/// Public models endpoint -> per-1M USD prices, cached on disk.
|
|
37
|
-
pub fn force_refresh() -> Result<usize, String> {
|
|
38
|
-
let resp = ureq::get(MODELS_URL)
|
|
39
|
-
.timeout(Duration::from_secs(30))
|
|
40
|
-
.call()
|
|
41
|
-
.map_err(|e| format!("fetch failed: {e}"))?;
|
|
42
|
-
let text = resp.into_string().map_err(|e| e.to_string())?;
|
|
43
|
-
let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
|
44
|
-
|
|
45
|
-
let mut models: Vec<(String, [f64; 4])> = Vec::new();
|
|
46
|
-
if let Some(list) = v.get("data").and_then(Value::as_array) {
|
|
47
|
-
for m in list {
|
|
48
|
-
let id = match m.get("id").and_then(Value::as_str) {
|
|
49
|
-
Some(id) => id.to_string(),
|
|
50
|
-
None => continue,
|
|
51
|
-
};
|
|
52
|
-
// pricing is per-token strings; scale to per-1M
|
|
53
|
-
let p = m.get("pricing").cloned().unwrap_or(Value::Null);
|
|
54
|
-
let scale = 1_000_000.0;
|
|
55
|
-
let price = [
|
|
56
|
-
num(&p.get("prompt").cloned().unwrap_or(Value::Null)) * scale,
|
|
57
|
-
num(&p.get("completion").cloned().unwrap_or(Value::Null)) * scale,
|
|
58
|
-
num(&p.get("input_cache_read").cloned().unwrap_or(Value::Null)) * scale,
|
|
59
|
-
num(&p.get("input_cache_write").cloned().unwrap_or(Value::Null)) * scale,
|
|
60
|
-
];
|
|
61
|
-
models.push((id, price));
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if models.is_empty() {
|
|
65
|
-
return Err("openrouter returned no models".into());
|
|
66
|
-
}
|
|
67
|
-
let doc = serde_json::json!({
|
|
68
|
-
"version": CACHE_VERSION,
|
|
69
|
-
"fetched_at_ms": now_ms(),
|
|
70
|
-
"models": models.iter().map(|(id, p)| serde_json::json!({
|
|
71
|
-
"id": id, "prompt": p[0], "completion": p[1], "cache_read": p[2], "cache_write": p[3],
|
|
72
|
-
})).collect::<Vec<_>>(),
|
|
73
|
-
});
|
|
74
|
-
let dir = cache_dir();
|
|
75
|
-
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
76
|
-
std::fs::write(dir.join("openrouter-prices.json"), doc.to_string())
|
|
77
|
-
.map_err(|e| e.to_string())?;
|
|
78
|
-
Ok(models.len())
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
pub fn cached_into_db(db: &mut PriceDB) -> usize {
|
|
82
|
-
let path = cache_dir().join("openrouter-prices.json");
|
|
83
|
-
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
84
|
-
return 0;
|
|
85
|
-
};
|
|
86
|
-
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
|
87
|
-
return 0;
|
|
88
|
-
};
|
|
89
|
-
if v.get("version").and_then(Value::as_u64) != Some(CACHE_VERSION) {
|
|
90
|
-
return 0;
|
|
91
|
-
}
|
|
92
|
-
let mut n = 0usize;
|
|
93
|
-
if let Some(list) = v.get("models").and_then(Value::as_array) {
|
|
94
|
-
for m in list {
|
|
95
|
-
let id = match m.get("id").and_then(Value::as_str) {
|
|
96
|
-
Some(s) => s.to_string(),
|
|
97
|
-
None => continue,
|
|
98
|
-
};
|
|
99
|
-
db.push(
|
|
100
|
-
&id,
|
|
101
|
-
"cache",
|
|
102
|
-
Pricing {
|
|
103
|
-
input: num(&m["prompt"]),
|
|
104
|
-
output: num(&m["completion"]),
|
|
105
|
-
cache_read: num(&m["cache_read"]),
|
|
106
|
-
cache_write: num(&m["cache_write"]),
|
|
107
|
-
per_hour: 0.0,
|
|
108
|
-
},
|
|
109
|
-
);
|
|
110
|
-
n += 1;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
db.cache_count = n;
|
|
114
|
-
n
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/// Actual spend per model for an OpenRouter API key.
|
|
118
|
-
pub fn activity(key: &str) -> Result<Vec<(String, u64, f64)>, String> {
|
|
119
|
-
let resp = ureq::get(ACTIVITY_URL)
|
|
120
|
-
.timeout(Duration::from_secs(30))
|
|
121
|
-
.set("Authorization", &format!("Bearer {key}"))
|
|
122
|
-
.call()
|
|
123
|
-
.map_err(|e| format!("openrouter request failed: {e}"))?;
|
|
124
|
-
let text = resp.into_string().map_err(|e| e.to_string())?;
|
|
125
|
-
let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
|
126
|
-
if let Some(msg) = v.get("error") {
|
|
127
|
-
return Err(format!("openrouter error: {msg}"));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
let mut by_model: Vec<(String, u64, f64)> = Vec::new();
|
|
131
|
-
if let Some(list) = v.get("data").and_then(Value::as_array) {
|
|
132
|
-
for ev in list {
|
|
133
|
-
let model = match ev.get("model").and_then(Value::as_str) {
|
|
134
|
-
Some(m) => m.to_string(),
|
|
135
|
-
None => continue,
|
|
136
|
-
};
|
|
137
|
-
let cost = num(ev.get("cost").unwrap_or(&Value::Null));
|
|
138
|
-
if let Some(row) = by_model.iter_mut().find(|(m, _, _)| *m == model) {
|
|
139
|
-
row.1 += 1;
|
|
140
|
-
row.2 += cost;
|
|
141
|
-
} else {
|
|
142
|
-
by_model.push((model, 1, cost));
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
by_model.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
|
147
|
-
Ok(by_model)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
fn now_ms() -> u64 {
|
|
151
|
-
SystemTime::now()
|
|
152
|
-
.duration_since(SystemTime::UNIX_EPOCH)
|
|
153
|
-
.map(|d| d.as_millis() as u64)
|
|
154
|
-
.unwrap_or(0)
|
|
155
|
-
}
|