@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/scan.rs
DELETED
|
@@ -1,593 +0,0 @@
|
|
|
1
|
-
//! Scans local logs/transcripts of AI coding agents for token usage.
|
|
2
|
-
|
|
3
|
-
use crate::price;
|
|
4
|
-
use serde_json::Value;
|
|
5
|
-
use std::ffi::OsStr;
|
|
6
|
-
use std::fs::{self, File};
|
|
7
|
-
use std::io::{BufRead, BufReader};
|
|
8
|
-
use std::path::{Path, PathBuf};
|
|
9
|
-
use std::time::{Duration, SystemTime};
|
|
10
|
-
|
|
11
|
-
#[derive(Debug, Clone, Default)]
|
|
12
|
-
pub struct UsageEvent {
|
|
13
|
-
pub provider: String,
|
|
14
|
-
pub model: String,
|
|
15
|
-
pub input_tokens: u64,
|
|
16
|
-
pub output_tokens: u64,
|
|
17
|
-
pub cache_read: u64,
|
|
18
|
-
pub cache_write: u64,
|
|
19
|
-
/// wall-clock of the request, when the log records it (opencode does)
|
|
20
|
-
pub duration_ms: u64,
|
|
21
|
-
/// when the agent itself reports a cost (Kilo/Cline/Roo do), USD
|
|
22
|
-
pub direct_cost: Option<f64>,
|
|
23
|
-
#[allow(dead_code)]
|
|
24
|
-
pub source: PathBuf,
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
#[derive(Debug, Default)]
|
|
28
|
-
pub struct Row {
|
|
29
|
-
pub provider: String,
|
|
30
|
-
pub model: String,
|
|
31
|
-
pub requests: u64,
|
|
32
|
-
pub input_tokens: u64,
|
|
33
|
-
pub output_tokens: u64,
|
|
34
|
-
pub cache_read_tokens: u64,
|
|
35
|
-
pub cache_write_tokens: u64,
|
|
36
|
-
pub cache_tokens: u64,
|
|
37
|
-
pub duration_secs: f64,
|
|
38
|
-
pub direct_cost_sum: f64,
|
|
39
|
-
pub cost: f64,
|
|
40
|
-
pub known_price: bool,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
fn home() -> PathBuf {
|
|
44
|
-
std::env::var_os("USERPROFILE")
|
|
45
|
-
.or_else(|| std::env::var_os("HOME"))
|
|
46
|
-
.map(PathBuf::from)
|
|
47
|
-
.unwrap_or_else(|| PathBuf::from("."))
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/// (directory, provider) pairs for every tool we know how to read.
|
|
51
|
-
fn scan_targets() -> Vec<(PathBuf, &'static str)> {
|
|
52
|
-
let h = home();
|
|
53
|
-
let mut targets = vec![
|
|
54
|
-
// Claude Code: JSONL transcripts per project
|
|
55
|
-
(h.join(".claude"), "claude"),
|
|
56
|
-
];
|
|
57
|
-
// OpenCode: legacy JSON storage + whatever lives in its data dir
|
|
58
|
-
let oc = std::env::var_os("XDG_DATA_HOME")
|
|
59
|
-
.map(PathBuf::from)
|
|
60
|
-
.map(|p| p.join("opencode"));
|
|
61
|
-
targets.push((
|
|
62
|
-
oc.unwrap_or_else(|| h.join(".local/share/opencode")),
|
|
63
|
-
"opencode",
|
|
64
|
-
));
|
|
65
|
-
if let Some(lad) = std::env::var_os("LOCALAPPDATA") {
|
|
66
|
-
targets.push((PathBuf::from(lad).join("opencode"), "opencode"));
|
|
67
|
-
}
|
|
68
|
-
// Cline-family VS Code agents (Kilo Code, Cline, Roo Code) log tasks here.
|
|
69
|
-
// Prefer APPDATA (where VS Code globalStorage actually lives); fall back to
|
|
70
|
-
// LOCALAPPDATA's parent so the scanner still runs on unusual setups.
|
|
71
|
-
let mut gs: Option<PathBuf> = std::env::var_os("APPDATA").map(|a| {
|
|
72
|
-
PathBuf::from(a)
|
|
73
|
-
.join("Code")
|
|
74
|
-
.join("User")
|
|
75
|
-
.join("globalStorage")
|
|
76
|
-
});
|
|
77
|
-
if gs.is_none() {
|
|
78
|
-
gs = std::env::var_os("LOCALAPPDATA").map(|l| {
|
|
79
|
-
PathBuf::from(&l)
|
|
80
|
-
.parent()
|
|
81
|
-
.map(|p| {
|
|
82
|
-
p.join("Roaming")
|
|
83
|
-
.join("Code")
|
|
84
|
-
.join("User")
|
|
85
|
-
.join("globalStorage")
|
|
86
|
-
})
|
|
87
|
-
.unwrap_or_else(|| PathBuf::from(&l))
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
if let Some(gs) = gs {
|
|
91
|
-
targets.push((gs, "cline-family"));
|
|
92
|
-
}
|
|
93
|
-
// Codex CLI session logs
|
|
94
|
-
targets.push((h.join(".codex"), "codex"));
|
|
95
|
-
targets
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
pub fn collect(since_days: u32) -> Vec<UsageEvent> {
|
|
99
|
-
let max_age = Duration::from_secs(since_days as u64 * 86_400);
|
|
100
|
-
let since_ms = (now_ms() - max_age.as_millis() as i64).max(0);
|
|
101
|
-
let mut events = Vec::new();
|
|
102
|
-
|
|
103
|
-
for (dir, provider) in scan_targets() {
|
|
104
|
-
if !dir.is_dir() {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
// v2 agents keep usage in SQLite (opencode.db); read that first.
|
|
108
|
-
let db = dir.join("opencode.db");
|
|
109
|
-
if db.is_file() {
|
|
110
|
-
scan_opencode_db(&db, provider, since_ms, &mut events);
|
|
111
|
-
}
|
|
112
|
-
walk(&dir, provider, 0, &mut |path, mtime| {
|
|
113
|
-
if let Ok(age) = SystemTime::now().duration_since(mtime) {
|
|
114
|
-
if age > max_age {
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
let ext = path.extension().and_then(OsStr::to_str).unwrap_or("");
|
|
119
|
-
if ext.eq_ignore_ascii_case("jsonl") {
|
|
120
|
-
parse_jsonl(path, provider, &mut events);
|
|
121
|
-
} else if ext.eq_ignore_ascii_case("json") {
|
|
122
|
-
parse_single_json(path, provider, &mut events);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Aider: cost lines in its chat history markdown
|
|
128
|
-
scan_aider_history(since_days, &mut events);
|
|
129
|
-
events
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/// Aider appends "> Cost: $a ask, $b send, $c total." lines to history .md files.
|
|
133
|
-
/// We trust its own numbers (one event per history line).
|
|
134
|
-
fn scan_aider_history(since_days: u32, events: &mut Vec<UsageEvent>) {
|
|
135
|
-
let h = home();
|
|
136
|
-
for name in [".aider.chat.history.md", ".aider.history.md"] {
|
|
137
|
-
let path = h.join(name);
|
|
138
|
-
let Ok(meta) = fs::metadata(&path) else {
|
|
139
|
-
continue;
|
|
140
|
-
};
|
|
141
|
-
let Ok(mtime) = meta.modified() else { continue };
|
|
142
|
-
if let Ok(age) = SystemTime::now().duration_since(mtime) {
|
|
143
|
-
if age > Duration::from_secs(since_days as u64 * 86_400) {
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
let Ok(text) = fs::read_to_string(&path) else {
|
|
148
|
-
continue;
|
|
149
|
-
};
|
|
150
|
-
for line in text.lines() {
|
|
151
|
-
if !line.contains("Cost:") {
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
// take the largest $ figure on the line (usually the total)
|
|
155
|
-
let mut biggest = 0.0f64;
|
|
156
|
-
let mut start = 0;
|
|
157
|
-
while let Some(i) = line[start..].find('$') {
|
|
158
|
-
let rest = &line[start + i + 1..];
|
|
159
|
-
let num: String = rest
|
|
160
|
-
.chars()
|
|
161
|
-
.take_while(|c| c.is_ascii_digit() || *c == '.')
|
|
162
|
-
.collect();
|
|
163
|
-
if let Ok(v) = num.parse::<f64>() {
|
|
164
|
-
biggest = biggest.max(v);
|
|
165
|
-
}
|
|
166
|
-
start += i + 1 + num.len();
|
|
167
|
-
if start >= line.len() {
|
|
168
|
-
break;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if biggest <= 0.0 {
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
events.push(UsageEvent {
|
|
175
|
-
provider: "aider".into(),
|
|
176
|
-
model: "aider-reported".into(),
|
|
177
|
-
duration_ms: 0,
|
|
178
|
-
direct_cost: Some(biggest),
|
|
179
|
-
source: path.clone(),
|
|
180
|
-
..Default::default()
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
fn now_ms() -> i64 {
|
|
187
|
-
SystemTime::now()
|
|
188
|
-
.duration_since(SystemTime::UNIX_EPOCH)
|
|
189
|
-
.map(|d| d.as_millis() as i64)
|
|
190
|
-
.unwrap_or(0)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/// OpenCode v2 keeps one `data` JSON blob per message in opencode.db.
|
|
194
|
-
fn scan_opencode_db(path: &Path, provider: &str, since_ms: i64, events: &mut Vec<UsageEvent>) {
|
|
195
|
-
use rusqlite::{Connection, OpenFlags};
|
|
196
|
-
|
|
197
|
-
let Ok(conn) = Connection::open_with_flags(
|
|
198
|
-
path,
|
|
199
|
-
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
200
|
-
) else {
|
|
201
|
-
return;
|
|
202
|
-
};
|
|
203
|
-
let Ok(mut stmt) = conn.prepare("SELECT time_created, data FROM message") else {
|
|
204
|
-
return;
|
|
205
|
-
};
|
|
206
|
-
let mut rows = match stmt.query([]) {
|
|
207
|
-
Ok(r) => r,
|
|
208
|
-
Err(_) => return,
|
|
209
|
-
};
|
|
210
|
-
while let Ok(Some(row)) = rows.next() {
|
|
211
|
-
let created: i64 = row.get(0).unwrap_or(0);
|
|
212
|
-
if created > 0 && created < since_ms {
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
let Ok(data) = row.get::<_, String>(1) else {
|
|
216
|
-
continue;
|
|
217
|
-
};
|
|
218
|
-
let Ok(v) = serde_json::from_str::<Value>(&data) else {
|
|
219
|
-
continue;
|
|
220
|
-
};
|
|
221
|
-
if let Some(ev) = extract_event(&v, provider, path) {
|
|
222
|
-
events.push(ev);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
fn walk(dir: &Path, _provider: &str, depth: usize, cb: &mut impl FnMut(&Path, SystemTime)) {
|
|
228
|
-
if depth > 8 {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
let Ok(entries) = fs::read_dir(dir) else {
|
|
232
|
-
return;
|
|
233
|
-
};
|
|
234
|
-
for e in entries.flatten() {
|
|
235
|
-
let p = e.path();
|
|
236
|
-
let name = e.file_name();
|
|
237
|
-
let name = name.to_string_lossy();
|
|
238
|
-
// skip noisy dirs that never hold transcripts
|
|
239
|
-
if matches!(
|
|
240
|
-
name.as_ref(),
|
|
241
|
-
"node_modules" | "target" | "dist" | "tmp" | "cache"
|
|
242
|
-
) {
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
if p.is_dir() {
|
|
246
|
-
walk(&p, _provider, depth + 1, cb);
|
|
247
|
-
} else if let Ok(meta) = e.metadata() {
|
|
248
|
-
if let Ok(mtime) = meta.modified() {
|
|
249
|
-
cb(&p, mtime);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
fn parse_jsonl(path: &Path, provider: &str, events: &mut Vec<UsageEvent>) {
|
|
256
|
-
let Ok(file) = File::open(path) else { return };
|
|
257
|
-
for line in BufReader::new(file).lines() {
|
|
258
|
-
let Ok(line) = line else { continue };
|
|
259
|
-
if !(line.contains("usage") || line.contains("tokens")) {
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
|
263
|
-
continue;
|
|
264
|
-
};
|
|
265
|
-
if let Some(ev) = extract_event(&v, provider, path) {
|
|
266
|
-
events.push(ev);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
fn parse_single_json(path: &Path, provider: &str, events: &mut Vec<UsageEvent>) {
|
|
272
|
-
let Ok(text) = fs::read_to_string(path) else {
|
|
273
|
-
return;
|
|
274
|
-
};
|
|
275
|
-
if text.len() > 8_000_000 {
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
|
279
|
-
return;
|
|
280
|
-
};
|
|
281
|
-
match &v {
|
|
282
|
-
// Cline-family ui_messages/task files are arrays
|
|
283
|
-
Value::Array(items) => {
|
|
284
|
-
for item in items {
|
|
285
|
-
if let Some(ev) = extract_event(item, provider, path) {
|
|
286
|
-
events.push(ev);
|
|
287
|
-
}
|
|
288
|
-
dig_for_cline_usage(item, provider, path, events, 0);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
Value::Object(_) => {
|
|
292
|
-
if let Some(ev) = extract_event(&v, provider, path) {
|
|
293
|
-
events.push(ev);
|
|
294
|
-
}
|
|
295
|
-
dig_for_cline_usage(&v, provider, path, events, 0);
|
|
296
|
-
}
|
|
297
|
-
_ => {}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/// Kilo Code / Cline / Roo Code wrap per-request cost JSON inside string
|
|
302
|
-
/// fields ("text") inside their task files — unwrap those one level.
|
|
303
|
-
fn dig_for_cline_usage(
|
|
304
|
-
v: &Value,
|
|
305
|
-
provider: &str,
|
|
306
|
-
path: &Path,
|
|
307
|
-
events: &mut Vec<UsageEvent>,
|
|
308
|
-
depth: usize,
|
|
309
|
-
) {
|
|
310
|
-
if depth > 4 {
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
match v {
|
|
314
|
-
Value::Array(items) => {
|
|
315
|
-
for i in items {
|
|
316
|
-
dig_for_cline_usage(i, provider, path, events, depth + 1);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
Value::Object(map) => {
|
|
320
|
-
for val in map.values() {
|
|
321
|
-
dig_for_cline_usage(val, provider, path, events, depth + 1);
|
|
322
|
-
}
|
|
323
|
-
if let Some(Value::String(s)) = map.get("text") {
|
|
324
|
-
if s.contains("tokensIn") || s.contains("cacheReads") || s.contains("\"cost\"") {
|
|
325
|
-
if let Ok(inner) = serde_json::from_str::<Value>(s) {
|
|
326
|
-
if let Some(ev) = extract_event(&inner, provider, path) {
|
|
327
|
-
events.push(ev);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
_ => {}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
fn num(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> u64 {
|
|
338
|
-
keys.iter()
|
|
339
|
-
.find_map(|k| {
|
|
340
|
-
obj.get(*k)
|
|
341
|
-
.and_then(Value::as_u64)
|
|
342
|
-
.or_else(|| obj.get(*k).and_then(Value::as_f64).map(|f| f as u64))
|
|
343
|
-
})
|
|
344
|
-
.unwrap_or(0)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
fn extract_event(v: &Value, provider: &str, source: &Path) -> Option<UsageEvent> {
|
|
348
|
-
// usage can live at: v.usage | v.tokens | v.message.usage
|
|
349
|
-
let usage = v
|
|
350
|
-
.get("usage")
|
|
351
|
-
.or_else(|| v.get("tokens"))
|
|
352
|
-
.or_else(|| v.get("message").and_then(|m| m.get("usage")))
|
|
353
|
-
.filter(|u| u.is_object())?;
|
|
354
|
-
|
|
355
|
-
let model = ["model", "modelID", "model_id"]
|
|
356
|
-
.iter()
|
|
357
|
-
.find_map(|k| v.get(k).and_then(Value::as_str))
|
|
358
|
-
.or_else(|| {
|
|
359
|
-
v.get("message").and_then(|m| {
|
|
360
|
-
m.get("model")
|
|
361
|
-
.and_then(Value::as_str)
|
|
362
|
-
.or_else(|| m.get("modelID").and_then(Value::as_str))
|
|
363
|
-
})
|
|
364
|
-
})?
|
|
365
|
-
.to_string();
|
|
366
|
-
|
|
367
|
-
if model.is_empty() {
|
|
368
|
-
return None;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// prefer the provider id stored next to the model (e.g. openai, anthropic)
|
|
372
|
-
let provider_name = ["providerID", "provider_id", "provider", "apiProvider"]
|
|
373
|
-
.iter()
|
|
374
|
-
.find_map(|k| v.get(k).and_then(Value::as_str))
|
|
375
|
-
.unwrap_or(provider);
|
|
376
|
-
|
|
377
|
-
let um = usage.as_object()?;
|
|
378
|
-
|
|
379
|
-
let input = num(um, &["input_tokens", "inputTokens", "tokensIn", "input"]);
|
|
380
|
-
// reasoning tokens are billed as output on most APIs — fold them in
|
|
381
|
-
let output = num(
|
|
382
|
-
um,
|
|
383
|
-
&["output_tokens", "outputTokens", "tokensOut", "output"],
|
|
384
|
-
) + num(um, &["reasoning_tokens", "reasoningTokens", "reasoning"]);
|
|
385
|
-
let mut cache_read = num(
|
|
386
|
-
um,
|
|
387
|
-
&[
|
|
388
|
-
"cache_read_input_tokens",
|
|
389
|
-
"cacheReadTokens",
|
|
390
|
-
"cacheReads",
|
|
391
|
-
"cache_read",
|
|
392
|
-
"cacheRead",
|
|
393
|
-
],
|
|
394
|
-
);
|
|
395
|
-
let mut cache_write = num(
|
|
396
|
-
um,
|
|
397
|
-
&[
|
|
398
|
-
"cache_creation_input_tokens",
|
|
399
|
-
"cacheWriteTokens",
|
|
400
|
-
"cacheWrites",
|
|
401
|
-
"cache_creation",
|
|
402
|
-
"cacheWrite",
|
|
403
|
-
],
|
|
404
|
-
);
|
|
405
|
-
|
|
406
|
-
// nested cache object: "cache": { "read": n, "write": n }
|
|
407
|
-
if let Some(c) = um.get("cache").and_then(Value::as_object) {
|
|
408
|
-
cache_read = cache_read.max(num(c, &["read", "cacheRead"]));
|
|
409
|
-
cache_write = cache_write.max(num(c, &["write", "cacheWrite"]));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Cline-family: agent-reported USD cost of the request
|
|
413
|
-
let direct_cost = v
|
|
414
|
-
.get("cost")
|
|
415
|
-
.or_else(|| um.get("cost"))
|
|
416
|
-
.and_then(fnum)
|
|
417
|
-
.filter(|c| *c > 0.0);
|
|
418
|
-
|
|
419
|
-
// OpenCode records request wall-clock; needed for per-hour pricing
|
|
420
|
-
let duration_ms = v
|
|
421
|
-
.get("time")
|
|
422
|
-
.and_then(|t| {
|
|
423
|
-
t.get("completed")
|
|
424
|
-
.and_then(Value::as_i64)
|
|
425
|
-
.zip(t.get("created").and_then(Value::as_i64))
|
|
426
|
-
})
|
|
427
|
-
.map(|(a, b)| (a - b).max(0) as u64)
|
|
428
|
-
.unwrap_or(0);
|
|
429
|
-
|
|
430
|
-
if input == 0 && output == 0 && cache_read == 0 && cache_write == 0 && direct_cost.is_none() {
|
|
431
|
-
return None;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
Some(UsageEvent {
|
|
435
|
-
provider: provider_name.to_string(),
|
|
436
|
-
model,
|
|
437
|
-
input_tokens: input,
|
|
438
|
-
output_tokens: output,
|
|
439
|
-
cache_read,
|
|
440
|
-
cache_write,
|
|
441
|
-
duration_ms,
|
|
442
|
-
direct_cost,
|
|
443
|
-
source: source.to_path_buf(),
|
|
444
|
-
})
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/// numbers that arrive as strings (Cline serializes tons as strings)
|
|
448
|
-
fn fnum(v: &Value) -> Option<f64> {
|
|
449
|
-
match v {
|
|
450
|
-
Value::Number(n) => n.as_f64(),
|
|
451
|
-
Value::String(s) => s.trim().parse().ok(),
|
|
452
|
-
_ => None,
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
pub fn aggregate(events: &[UsageEvent], db: &price::PriceDB) -> Vec<Row> {
|
|
457
|
-
let mut rows: Vec<Row> = Vec::new();
|
|
458
|
-
let bump = |r: &mut Row, e: &UsageEvent| {
|
|
459
|
-
r.requests += 1;
|
|
460
|
-
r.input_tokens += e.input_tokens;
|
|
461
|
-
r.output_tokens += e.output_tokens;
|
|
462
|
-
r.cache_read_tokens += e.cache_read;
|
|
463
|
-
r.cache_write_tokens += e.cache_write;
|
|
464
|
-
r.cache_tokens += e.cache_read + e.cache_write;
|
|
465
|
-
r.duration_secs += e.duration_ms as f64 / 1000.0;
|
|
466
|
-
if let Some(c) = e.direct_cost {
|
|
467
|
-
r.direct_cost_sum += c;
|
|
468
|
-
}
|
|
469
|
-
};
|
|
470
|
-
for e in events {
|
|
471
|
-
if let Some(r) = rows
|
|
472
|
-
.iter_mut()
|
|
473
|
-
.find(|r| r.provider == e.provider && r.model == e.model)
|
|
474
|
-
{
|
|
475
|
-
bump(r, e);
|
|
476
|
-
} else {
|
|
477
|
-
rows.push(Row {
|
|
478
|
-
provider: e.provider.clone(),
|
|
479
|
-
model: e.model.clone(),
|
|
480
|
-
requests: 1,
|
|
481
|
-
input_tokens: e.input_tokens,
|
|
482
|
-
output_tokens: e.output_tokens,
|
|
483
|
-
cache_read_tokens: e.cache_read,
|
|
484
|
-
cache_write_tokens: e.cache_write,
|
|
485
|
-
cache_tokens: e.cache_read + e.cache_write,
|
|
486
|
-
duration_secs: e.duration_ms as f64 / 1000.0,
|
|
487
|
-
direct_cost_sum: e.direct_cost.unwrap_or(0.0),
|
|
488
|
-
cost: 0.0,
|
|
489
|
-
known_price: false,
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
for r in &mut rows {
|
|
494
|
-
// 1) agent-reported costs win (Kilo/Cline/Roo say the price themselves)
|
|
495
|
-
if r.direct_cost_sum > 0.0 {
|
|
496
|
-
r.known_price = true;
|
|
497
|
-
r.cost = r.direct_cost_sum;
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
// 2) token + wall-clock pricing from the database
|
|
501
|
-
match db.lookup(&r.model) {
|
|
502
|
-
Some(p) => {
|
|
503
|
-
r.known_price = true;
|
|
504
|
-
r.cost = (r.input_tokens as f64 / 1e6) * p.input
|
|
505
|
-
+ (r.output_tokens as f64 / 1e6) * p.output
|
|
506
|
-
+ (r.cache_read_tokens as f64 / 1e6) * p.cache_read
|
|
507
|
-
+ (r.cache_write_tokens as f64 / 1e6) * p.cache_write
|
|
508
|
-
+ p.per_hour * (r.duration_secs / 3600.0);
|
|
509
|
-
}
|
|
510
|
-
None => {
|
|
511
|
-
r.known_price = false;
|
|
512
|
-
r.cost = 0.0;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
rows.sort_by(|a, b| {
|
|
517
|
-
b.cost
|
|
518
|
-
.partial_cmp(&a.cost)
|
|
519
|
-
.unwrap_or(std::cmp::Ordering::Equal)
|
|
520
|
-
});
|
|
521
|
-
rows
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
#[cfg(test)]
|
|
525
|
-
mod tests {
|
|
526
|
-
use super::*;
|
|
527
|
-
|
|
528
|
-
fn ev(model: &str, i: u64, o: u64) -> UsageEvent {
|
|
529
|
-
UsageEvent {
|
|
530
|
-
provider: "test".into(),
|
|
531
|
-
model: model.into(),
|
|
532
|
-
input_tokens: i,
|
|
533
|
-
output_tokens: o,
|
|
534
|
-
cache_read: 0,
|
|
535
|
-
cache_write: 0,
|
|
536
|
-
duration_ms: 0,
|
|
537
|
-
direct_cost: None,
|
|
538
|
-
source: "t".into(),
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
#[test]
|
|
543
|
-
fn aggregate_sums_and_sorts_by_cost() {
|
|
544
|
-
let db = price::PriceDB::builtin();
|
|
545
|
-
let rows = aggregate(
|
|
546
|
-
&[ev("gpt-5", 1_000_000, 0), ev("gpt-5", 500_000, 100_000)],
|
|
547
|
-
&db,
|
|
548
|
-
);
|
|
549
|
-
assert_eq!(rows.len(), 1);
|
|
550
|
-
assert_eq!(rows[0].requests, 2);
|
|
551
|
-
assert_eq!(rows[0].input_tokens, 1_500_000);
|
|
552
|
-
assert_eq!(rows[0].output_tokens, 100_000);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
#[test]
|
|
556
|
-
fn unknown_models_are_flagged_not_billed() {
|
|
557
|
-
let db = price::PriceDB::builtin();
|
|
558
|
-
let rows = aggregate(&[ev("totally-made-up-model", 1_000_000, 1_000)], &db);
|
|
559
|
-
assert!(!rows[0].known_price);
|
|
560
|
-
assert_eq!(rows[0].cost, 0.0);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
#[test]
|
|
564
|
-
fn agent_reported_cost_wins_over_estimate() {
|
|
565
|
-
let db = price::PriceDB::builtin();
|
|
566
|
-
let mut e = ev("gpt-5", 1_000_000, 0);
|
|
567
|
-
e.direct_cost = Some(0.42);
|
|
568
|
-
let rows = aggregate(&[e], &db);
|
|
569
|
-
assert!(rows[0].known_price);
|
|
570
|
-
assert!((rows[0].cost - 0.42).abs() < 1e-9);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
#[test]
|
|
574
|
-
fn pricing_prefix_match_hits_longest() {
|
|
575
|
-
let db = price::PriceDB::builtin();
|
|
576
|
-
let p = db.lookup("claude-opus-4-5-20990101").unwrap();
|
|
577
|
-
assert_eq!(p.input, 15.0);
|
|
578
|
-
let p = db.lookup("glm-4-air").unwrap();
|
|
579
|
-
assert_eq!(p.input, 0.6);
|
|
580
|
-
// OpenRouter-style slug resolves to the same entry
|
|
581
|
-
let p = db.lookup("anthropic/claude-opus-4-1").unwrap();
|
|
582
|
-
assert_eq!(p.input, 15.0);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
#[test]
|
|
586
|
-
fn dot_slash_underscore_separators_match() {
|
|
587
|
-
let db = price::PriceDB::builtin();
|
|
588
|
-
// OpenRouter slugs use dots; some agents use underscores
|
|
589
|
-
assert!(db.lookup("anthropic/claude.opus.4.5").is_some());
|
|
590
|
-
assert!(db.lookup("openai/gpt_5_2").is_some());
|
|
591
|
-
assert!(db.lookup("zai/glm-5.3").is_some());
|
|
592
|
-
}
|
|
593
|
-
}
|