@archeagle087/costcrew 0.4.1 → 0.4.3

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.
Files changed (54) hide show
  1. package/README.md +1 -0
  2. package/bin.js +10 -6
  3. package/package.json +8 -5
  4. package/.github/workflows/ci.yml +0 -21
  5. package/.github/workflows/npm-release.yml +0 -123
  6. package/.github/workflows/prices-monthly.yml +0 -58
  7. package/.github/workflows/release.yml +0 -45
  8. package/Cargo.lock +0 -980
  9. package/Cargo.toml +0 -22
  10. package/assets/advise.png +0 -0
  11. package/assets/board.png +0 -0
  12. package/assets/catalog.png +0 -0
  13. package/assets/demo.png +0 -0
  14. package/assets/help.png +0 -0
  15. package/assets/mascot.png +0 -0
  16. package/assets/prices.png +0 -0
  17. package/assets/scan.png +0 -0
  18. package/assets/spend.png +0 -0
  19. package/assets/ui.png +0 -0
  20. package/data/prices.json +0 -1
  21. package/dist/costcrew-linux-x86_64.tar.gz +0 -0
  22. package/dist/costcrew-macos-aarch64.tar.gz +0 -0
  23. package/dist/costcrew-macos-x86_64.tar.gz +0 -0
  24. package/dist/costcrew-windows-x86_64.zip +0 -0
  25. package/install.ps1 +0 -38
  26. package/install.sh +0 -56
  27. package/packaging/npm/bin.js +0 -46
  28. package/packaging/npm/package.json +0 -20
  29. package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +0 -15
  30. package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +0 -28
  31. package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +0 -5
  32. package/packaging/winget/README.md +0 -31
  33. package/scripts/render_screen.py +0 -221
  34. package/src/advise.rs +0 -154
  35. package/src/args.rs +0 -239
  36. package/src/board.rs +0 -227
  37. package/src/catalog.rs +0 -136
  38. package/src/demo.rs +0 -79
  39. package/src/fetch.rs +0 -155
  40. package/src/main.rs +0 -371
  41. package/src/price.rs +0 -218
  42. package/src/prices.rs +0 -182
  43. package/src/report.rs +0 -194
  44. package/src/scan.rs +0 -593
  45. package/src/spend.rs +0 -452
  46. package/src/ui.rs +0 -367
  47. package/work/costcrew-darwin-arm64/costcrew +0 -0
  48. package/work/costcrew-darwin-arm64/package.json +0 -9
  49. package/work/costcrew-darwin-x64/costcrew +0 -0
  50. package/work/costcrew-darwin-x64/package.json +0 -9
  51. package/work/costcrew-linux-x64/costcrew +0 -0
  52. package/work/costcrew-linux-x64/package.json +0 -9
  53. package/work/costcrew-win32-x64/costcrew.exe +0 -0
  54. 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
- }