@icntswm/skillcheck 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # skillcheck
2
2
 
3
+ [![CI](https://github.com/icntswm/skillcheck/actions/workflows/ci.yml/badge.svg)](https://github.com/icntswm/skillcheck/actions/workflows/ci.yml)
3
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
5
  [![npm](https://img.shields.io/npm/v/@icntswm/skillcheck.svg)](https://www.npmjs.com/package/@icntswm/skillcheck)
5
6
  ![Node 20+](https://img.shields.io/badge/node-20%2B-339933.svg)
@@ -12,6 +12,23 @@ export const DEFAULT_DIRECTIVE =
12
12
  function stringList(v) {
13
13
  return Array.isArray(v) ? v.filter((s) => typeof s === "string") : null;
14
14
  }
15
+ function count(v) {
16
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
17
+ }
18
+ /** Sum API `usage` objects; cache writes without a 1h/5m split count as 5m. */
19
+ function sumUsage(model, usages) {
20
+ const total = { model, input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
21
+ for (const u of usages) {
22
+ const split = u.cache_creation;
23
+ const write1h = count(split?.ephemeral_1h_input_tokens);
24
+ total.input += count(u.input_tokens);
25
+ total.output += count(u.output_tokens);
26
+ total.cacheRead += count(u.cache_read_input_tokens);
27
+ total.cacheWrite1h += write1h;
28
+ total.cacheWrite5m += Math.max(0, count(u.cache_creation_input_tokens) - write1h);
29
+ }
30
+ return total;
31
+ }
15
32
  /**
16
33
  * Incremental parser of claude's stream-json stdout. One event per line.
17
34
  * Pushing lines also computes the early-stop signal, so the adapter can
@@ -23,6 +40,10 @@ export class ClaudeStream {
23
40
  resultText = "";
24
41
  sawResult = false;
25
42
  costUsd = null;
43
+ model = null;
44
+ /** latest usage per API message; a message streams as several events */
45
+ messageUsage = new Map();
46
+ resultUsage = null;
26
47
  availableSkills = null;
27
48
  initSkillList = null;
28
49
  initSlashList = null;
@@ -88,12 +109,20 @@ export class ClaudeStream {
88
109
  }
89
110
  if (typeof ev.total_cost_usd === "number")
90
111
  this.costUsd = ev.total_cost_usd;
112
+ if (typeof ev.usage === "object" && ev.usage !== null)
113
+ this.resultUsage = ev.usage;
91
114
  return true;
92
115
  }
93
116
  return false;
94
117
  }
95
118
  handleAssistant(ev) {
96
119
  const message = ev.message;
120
+ if (typeof message?.model === "string")
121
+ this.model = message.model;
122
+ if (typeof message?.usage === "object" && message.usage !== null) {
123
+ const id = typeof message.id === "string" ? message.id : `#${this.messageUsage.size}`;
124
+ this.messageUsage.set(id, message.usage);
125
+ }
97
126
  const blocks = message?.content;
98
127
  if (!Array.isArray(blocks))
99
128
  return false;
@@ -118,6 +147,18 @@ export class ClaudeStream {
118
147
  this.sawSkill = true;
119
148
  return sawSkillHere;
120
149
  }
150
+ /**
151
+ * Billed tokens: the result event's total, or the sum over assistant
152
+ * messages when the run was killed first. Streamed output counts are
153
+ * partial, so the latter runs a little low.
154
+ */
155
+ get usage() {
156
+ if (this.resultUsage)
157
+ return sumUsage(this.model, [this.resultUsage]);
158
+ if (this.messageUsage.size === 0)
159
+ return null;
160
+ return sumUsage(this.model, [...this.messageUsage.values()]);
161
+ }
121
162
  /** Error reported by claude itself in the result event, null if none. */
122
163
  get error() {
123
164
  return this.failure;
@@ -265,7 +306,8 @@ export class ClaudeAdapter {
265
306
  });
266
307
  const { loaded, text, costUsd, availableSkills } = out.stream.result;
267
308
  const error = withLoginHint(out.error, opts.configDir);
268
- return { loaded, text, costUsd, availableSkills, error, stoppedEarly: out.stoppedEarly, durationMs: out.durationMs };
309
+ const usage = out.stream.usage;
310
+ return { loaded, text, costUsd, usage, availableSkills, error, stoppedEarly: out.stoppedEarly, durationMs: out.durationMs };
269
311
  }
270
312
  finally {
271
313
  await rm(workdir, { recursive: true, force: true });
package/dist/budget.js CHANGED
@@ -1,27 +1,64 @@
1
1
  /**
2
- * Running spend estimate for --budget. Runs killed by early-stop never report
3
- * a cost, so unknown costs are estimated at the average known cost (0 until
4
- * the first known cost arrives); without this the budget could never trigger.
2
+ * Tokens in input-token equivalents. Anthropic prices every model the same
3
+ * way relative to its input price: output 5x, cache reads 0.1x, 5-minute
4
+ * cache writes 1.25x, 1-hour cache writes 2x.
5
+ */
6
+ export function inputEquivalent(u) {
7
+ return u.input + 5 * u.output + 0.1 * u.cacheRead + 1.25 * u.cacheWrite5m + 2 * u.cacheWrite1h;
8
+ }
9
+ /**
10
+ * USD per input token until a finished run shows the real rate. Rounded up
11
+ * where unsure: the budget is a cap, so overestimating is the safe side.
12
+ */
13
+ function listInputPrice(model) {
14
+ if (model?.includes("haiku"))
15
+ return 1e-6;
16
+ if (model?.includes("sonnet"))
17
+ return 3e-6;
18
+ return 5e-6;
19
+ }
20
+ /**
21
+ * Running spend estimate for --budget. claude usually reports its cost even
22
+ * when early-stop kills it, but a run killed before the result event does
23
+ * not; such runs are priced from their token usage: at the rate seen in
24
+ * finished runs, or at list price before any run finishes. Runs with neither
25
+ * cost nor usage count at the average known cost.
5
26
  */
6
27
  export class Budget {
7
28
  limitUsd;
8
29
  knownSum = 0;
9
30
  knownCount = 0;
10
31
  unknownCount = 0;
32
+ rateCost = 0;
33
+ rateTokens = 0;
34
+ unpriced = [];
11
35
  constructor(limitUsd) {
12
36
  this.limitUsd = limitUsd;
13
37
  }
14
- add(cost) {
15
- if (cost === null)
16
- this.unknownCount++;
17
- else {
38
+ add(cost, usage = null) {
39
+ const tokens = usage ? inputEquivalent(usage) : 0;
40
+ if (cost !== null) {
18
41
  this.knownSum += cost;
19
42
  this.knownCount++;
43
+ if (tokens > 0) {
44
+ this.rateCost += cost;
45
+ this.rateTokens += tokens;
46
+ }
47
+ }
48
+ else if (usage && tokens > 0) {
49
+ this.unpriced.push(usage);
50
+ }
51
+ else {
52
+ this.unknownCount++;
20
53
  }
21
54
  }
22
55
  get spent() {
23
56
  const avg = this.knownCount > 0 ? this.knownSum / this.knownCount : 0;
24
- return this.knownSum + this.unknownCount * avg;
57
+ const estimated = this.unpriced.reduce((sum, u) => {
58
+ const rate = this.rateTokens > 0 ? this.rateCost / this.rateTokens : listInputPrice(u.model);
59
+ return sum + inputEquivalent(u) * rate;
60
+ }, 0);
61
+ return this.knownSum + estimated + this.unknownCount * avg;
25
62
  }
26
63
  get exceeded() {
27
64
  return this.spent >= this.limitUsd;
package/dist/cli.js CHANGED
@@ -440,7 +440,7 @@ class FatalStop {
440
440
  this.message = error;
441
441
  }
442
442
  canStart(budget) {
443
- return this.message === null && (!budget || !budget.exceeded);
443
+ return this.message === null && !budget.exceeded;
444
444
  }
445
445
  }
446
446
  async function runCommand(file, flags, io, deps) {
@@ -466,7 +466,8 @@ async function runCommand(file, flags, io, deps) {
466
466
  const n = c.repeat ?? repeat;
467
467
  return { c, jobIndex: i, threshold: c.threshold ?? threshold, runs: Array.from({ length: n }), done: 0 };
468
468
  });
469
- const budget = flags.budget !== undefined ? new Budget(flags.budget) : null;
469
+ // without --budget it only estimates the spend for the report
470
+ const budget = new Budget(flags.budget ?? Infinity);
470
471
  // "--json -" owns stdout, so the human report has to go to stderr
471
472
  const reporter = new Reporter(flags.json === "-" ? io.stderr : io.stdout);
472
473
  const startedAtMs = Date.now();
@@ -492,7 +493,8 @@ async function runCommand(file, flags, io, deps) {
492
493
  unavailable,
493
494
  confusion: pairs,
494
495
  skipped: skipped.length,
495
- budget: skipped.length > 0 && budget ? { limitUsd: flags.budget, spent: budget.spent, notStartedRuns } : undefined,
496
+ budget: skipped.length > 0 ? { limitUsd: flags.budget, spent: budget.spent, notStartedRuns } : undefined,
497
+ estimatedUsd: budget.spent,
496
498
  });
497
499
  if (flags.batch) {
498
500
  // cases that only errored have no answer to confirm
@@ -511,6 +513,7 @@ async function runCommand(file, flags, io, deps) {
511
513
  cases: items.map((item) => ({ c: item.c, threshold: item.threshold, result: outcome.results[item.jobIndex] ?? null })),
512
514
  unavailable,
513
515
  confusion: pairs,
516
+ estimatedCostUsd: budget.spent,
514
517
  budgetUsd: flags.budget ?? null,
515
518
  budgetReached: skipped.length > 0,
516
519
  });
@@ -558,7 +561,7 @@ async function runIndividually(adapter, items, flags, model, directive, repeat,
558
561
  available.add(name);
559
562
  }
560
563
  const verdict = judge(job.item.c, r);
561
- budget?.add(verdict.costUsd);
564
+ budget.add(r.costUsd, r.usage);
562
565
  job.item.runs[job.runIndex] = verdict;
563
566
  job.item.done++;
564
567
  if (job.item.done === job.item.runs.length) {
@@ -606,7 +609,7 @@ async function runBatched(runBatch, items, flags, model, repeat, reporter, budge
606
609
  durationMs: br.durationMs,
607
610
  };
608
611
  const verdict = judge(item.c, r);
609
- budget?.add(verdict.costUsd);
612
+ budget.add(verdict.costUsd);
610
613
  item.runs[job.round] = verdict;
611
614
  item.done++;
612
615
  if (item.done === item.runs.length) {
package/dist/junit.js CHANGED
@@ -14,9 +14,10 @@ export function toJunit(report) {
14
14
  if (report.model !== null)
15
15
  properties.push(` <property name="model" value="${esc(report.model)}"/>`);
16
16
  properties.push(` <property name="costUsd" value="${report.summary.costUsd.toFixed(2)}"/>`);
17
- // early-stopped runs report no cost, so costUsd alone understates the spend
17
+ // a run killed before its result event reports no cost, so costUsd alone understates the spend
18
18
  if (report.summary.unknownCostRuns > 0) {
19
19
  properties.push(` <property name="unknownCostRuns" value="${report.summary.unknownCostRuns}"/>`);
20
+ properties.push(` <property name="estimatedCostUsd" value="${report.summary.estimatedCostUsd.toFixed(2)}"/>`);
20
21
  }
21
22
  properties.push(" </properties>");
22
23
  const cases = rows.map(({ c, kind }) => testcase(c, kind, suiteName));
package/dist/report.js CHANGED
@@ -68,7 +68,7 @@ export class Reporter {
68
68
  const failed = results.filter((r) => !r.ok).length;
69
69
  const skipped = extra?.skipped ?? 0;
70
70
  const skippedPart = skipped > 0 ? `, ${skipped} skipped` : "";
71
- this.write(`${failed} failed${skippedPart} of ${results.length + skipped} · runs ${verdicts.length} · ${costLine(verdicts.map((v) => v.costUsd))}\n`);
71
+ this.write(`${failed} failed${skippedPart} of ${results.length + skipped} · runs ${verdicts.length} · ${costLine(verdicts.map((v) => v.costUsd), extra?.estimatedUsd)}\n`);
72
72
  }
73
73
  paint(code, text) {
74
74
  return this.useColor ? `\u001b[${code}m${text}${RESET}` : text;
@@ -77,12 +77,15 @@ export class Reporter {
77
77
  this.out.write(text);
78
78
  }
79
79
  }
80
- function costLine(costs) {
80
+ function costLine(costs, estimated) {
81
81
  const known = costs.filter((c) => c !== null);
82
- if (known.length === 0)
83
- return "cost ?";
84
82
  const sum = known.reduce((a, b) => a + b, 0);
85
83
  const unknown = costs.length - known.length;
84
+ if (unknown > 0 && estimated !== undefined && estimated > sum) {
85
+ return `cost ~$${estimated.toFixed(2)} (${unknown} runs estimated from tokens)`;
86
+ }
87
+ if (known.length === 0)
88
+ return "cost ?";
86
89
  const tail = unknown > 0 ? ` (+? for ${unknown} runs)` : "";
87
90
  return `cost $${sum.toFixed(2)}${tail}`;
88
91
  }
package/dist/results.js CHANGED
@@ -19,6 +19,7 @@ export function buildReport(input) {
19
19
  runs: verdicts.length,
20
20
  costUsd: known.reduce((a, b) => a + b, 0),
21
21
  unknownCostRuns: costs.length - known.length,
22
+ estimatedCostUsd: input.estimatedCostUsd,
22
23
  diagnoses: verdicts.filter((v) => v.diagnosis).length,
23
24
  budgetUsd: input.budgetUsd,
24
25
  budgetReached: input.budgetReached,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icntswm/skillcheck",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Regression tests for Claude Code skills: check that every request still loads the right skill",
5
5
  "type": "module",
6
6
  "bin": {