@workos/quickstudy 0.1.1 → 0.2.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/README.md CHANGED
@@ -41,7 +41,7 @@ modules), so it requires [Bun](https://bun.sh) ≥ 1.1 — there is no Node
41
41
  build. Add it to the repository that holds your evals and experiments:
42
42
 
43
43
  ```bash
44
- bun add quickstudy # Bun >= 1.1 required; no Node build exists
44
+ bun add @workos/quickstudy # Bun >= 1.1 required; no Node build exists
45
45
  bunx quickstudy --version
46
46
  ```
47
47
 
@@ -261,7 +261,6 @@ bun run ui:e2e # Playwright smoke: served routes + offline file:// loa
261
261
  - **[docs/getting-started.md](./docs/getting-started.md)** — install to first report.
262
262
  - **[docs/writing-an-eval.md](./docs/writing-an-eval.md)** — authoring evals and experiments.
263
263
  - **[docs/publishing-a-benchmark.md](./docs/publishing-a-benchmark.md)** — gates for publishing results.
264
- - **[docs/releasing.md](./docs/releasing.md)** — cutting prebuilt binaries.
265
264
 
266
265
  ## License
267
266
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -115,3 +115,57 @@ export function parseCodexStream(raw: string): AttemptMetrics {
115
115
  return metrics;
116
116
  }
117
117
 
118
+ /** Pricing needs complete usage, including the cache subset; partial telemetry cannot be priced. */
119
+ export interface CodexCostUsage {
120
+ input_tokens: number;
121
+ cached_input_tokens: number;
122
+ output_tokens: number;
123
+ }
124
+
125
+ function costUsage(value: unknown): CodexCostUsage | null {
126
+ const usage = record(value);
127
+ const input = usage["input_tokens"], cached = usage["cached_input_tokens"], output = usage["output_tokens"];
128
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
129
+ if (!count(input) || !count(cached) || !count(output) || cached > input) return null;
130
+ const reasoning = usage["reasoning_output_tokens"];
131
+ if (reasoning !== undefined && (!count(reasoning) || reasoning > output)) return null;
132
+ // The three-rate model does not cover separately billed cache writes.
133
+ if (usage["cache_write_input_tokens"] !== undefined && usage["cache_write_input_tokens"] !== 0) return null;
134
+ return { input_tokens: input, cached_input_tokens: cached, output_tokens: output };
135
+ }
136
+
137
+ /** Strict counterpart to parseCodexStream: malformed/truncated usage stays unpriced. */
138
+ export function parseCodexCostUsage(raw: string): CodexCostUsage | null {
139
+ let total: CodexCostUsage | null = null;
140
+ let complete = false;
141
+ let dialect: "thread" | "legacy" | undefined;
142
+ for (const line of raw.split("\n")) {
143
+ if (!line.trim().startsWith("{")) continue;
144
+ let obj: Record<string, unknown>;
145
+ try { obj = record(JSON.parse(line)); } catch { return null; }
146
+ const msg = record(obj["msg"]);
147
+ const type = obj["type"] ?? msg["type"];
148
+ if (type === "turn.failed" || type === "error") return null;
149
+ if (type === "turn.started" || type === "task_started") complete = false;
150
+ if (type === "task_complete" && dialect === "legacy") complete = true;
151
+ if (type !== "turn.completed" && type !== "token_count") continue;
152
+ const nextDialect = type === "turn.completed" ? "thread" : "legacy";
153
+ if (dialect !== undefined && dialect !== nextDialect) return null;
154
+ dialect = nextDialect;
155
+ const usage = costUsage(dialect === "thread" ? obj["usage"] : record(msg["info"])["total_token_usage"]);
156
+ if (usage === null) return null;
157
+ if (dialect === "legacy") {
158
+ total = usage; // Cumulative: never sum token_count snapshots.
159
+ complete = false;
160
+ } else {
161
+ total = total === null ? usage : {
162
+ input_tokens: total.input_tokens + usage.input_tokens,
163
+ cached_input_tokens: total.cached_input_tokens + usage.cached_input_tokens,
164
+ output_tokens: total.output_tokens + usage.output_tokens,
165
+ };
166
+ complete = true;
167
+ }
168
+ }
169
+ return complete && total !== null ? costUsage(total) : null;
170
+ }
171
+
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
9
9
  // (the `x-release-please-version` marker anchors the replacement). It is
10
10
  // restored as-is after a binary build (scripts/build-binary.ts), so the
11
11
  // committed source `--version` stays in lockstep with package.json.
12
- version: "0.1.1", // x-release-please-version
12
+ version: "0.2.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
package/src/cli.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  type ReportJson,
48
48
  } from "./report/report.ts";
49
49
  import { ExportError, exportSite } from "./export.ts";
50
+ import { PricingError, readPricing } from "./report/costs.ts";
50
51
  import { renderDiagnosisText } from "./diagnose/render.ts";
51
52
  import { diagnosisJsonPath, runDiagnose } from "./diagnose/run.ts";
52
53
  import { DiagnoseError } from "./diagnose/types.ts";
@@ -85,6 +86,7 @@ Usage:
85
86
  [--latest] Report the most recent run instead of naming one
86
87
  [--json] Print report.json instead of the text report
87
88
  [--strict] Exit non-zero when incomplete or any attempt errored (CI/publication)
89
+ [--pricing <file>] Estimate missing Codex token costs from a runtime pricing JSON
88
90
  [--db <path>] [--results <dir>] Stores (default: results.db, results/)
89
91
  quickstudy diagnose <run-id> LLM-diagnose a run's failing pairs into diagnosis.json
90
92
  [--latest] Diagnose the most recent run instead of naming one
@@ -92,6 +94,7 @@ Usage:
92
94
  quickstudy export <run-id>... Materialize a run as a self-contained static site
93
95
  [--runs <a,b|all>] More run ids (comma-separated), or "all" for every run in the DB
94
96
  [--out <dir>] Output directory (default: ./site)
97
+ [--pricing <file>] Recalculate token-cost estimates; otherwise retain saved pricing
95
98
  [--db <path>] [--results <dir>] Stores (default: results.db, results/)
96
99
  quickstudy ui Export to a temp dir and serve the UI locally
97
100
  [--runs <a,b>] Runs to include (default: every run in the DB)
@@ -103,10 +106,10 @@ Usage:
103
106
  const COMMAND_HELP: Record<string, string> = {
104
107
  validate: `Usage: quickstudy validate [--evals-root <dir>] [--experiments-root <dir>]\n\nDiscover and validate the evals root (frontmatter, scorer default exports)\nand the experiments root (defineExperiment shapes, comparison groups).\nDefaults: evals, experiments.`,
105
108
  run: `Usage: quickstudy run [--eval <id>...] [--experiment <id>...] [options]\n\nExecute the selected eval × experiment pairs. Omit --eval to select every\ndiscovered eval; omit --experiment to select every discovered experiment.\n\nOptions:\n --eval <id>... --experiment <id>...\n --evals-root <dir> --experiments-root <dir> (defaults: evals, experiments)\n --suite <s>... --framework <f>...\n --trials <n> --egress-proxy\n --concurrency <n> --seed <value> (default concurrency: 1)\n --resume <run-id> --retry-unfinished (explicit error/timeout retries)\n --provision-ms <n> --agent-ms <n> --score-ms <n>\n --export-ms <n> --cleanup-ms <n>\n --db <path> --results <dir>`,
106
- report: `Usage: quickstudy report <run-id> [options]\n\nBuild report.json into the results dir and print the text report\n(pass \`--latest\` to pick the most recent run; \`--json\` prints the JSON).\n\nOptions:\n --latest --json --strict\n --strict exits non-zero for incomplete runs or any attempt error\n --db <path> --results <dir>`,
109
+ report: `Usage: quickstudy report <run-id> [options]\n\nBuild report.json into the results dir and print the text report\n(pass \`--latest\` to pick the most recent run; \`--json\` prints the JSON).\n\nOptions:\n --latest --json --strict\n --strict exits non-zero for incomplete runs or any attempt error\n --pricing <file> Estimate missing Codex token costs using runtime pricing JSON\n --db <path> --results <dir>`,
107
110
  diagnose: `Usage: quickstudy diagnose <run-id> [options]\n\nSend each failing (eval × experiment) pair's harness-selected evidence to a\nstructured model call and write diagnosis.json beside report.json\n(pass \`--latest\` to pick the most recent run). Findings are hypotheses with\nevidence links — never verdicts. Requires ANTHROPIC_API_KEY; override the\nanalyst model with QUICKSTUDY_ANALYST_MODEL.\n\nOptions:\n --latest\n --db <path> --results <dir>`,
108
111
  clean: `Usage: quickstudy clean\n\nRemove quickstudy-labeled containers, networks, and sidecars.`,
109
- export: `Usage: quickstudy export <run-id>... [options]\n\nBuild a self-contained static site.\n\nOptions:\n --runs <a,b|all> --out <dir> --db <path> --results <dir>\n --mode <offline|hosted> (default: offline)\n --expected-trials <n> Publish fully sampled pairs; fail if none\n Writes omissions and retains source diagnostics`,
112
+ export: `Usage: quickstudy export <run-id>... [options]\n\nBuild a self-contained static site.\n\nOptions:\n --runs <a,b|all> --out <dir> --db <path> --results <dir>\n --mode <offline|hosted> (default: offline)\n --pricing <file> Recalculate costs using runtime pricing JSON\n --expected-trials <n> Publish fully sampled pairs; fail if none\n Writes omissions and retains source diagnostics`,
110
113
  ui: `Usage: quickstudy ui [options]\n\nExport stored runs to a temporary directory and serve the local UI.\n\nOptions:\n --runs <a,b> --port <n> --watch --db <path> --results <dir>`,
111
114
  };
112
115
 
@@ -330,6 +333,7 @@ async function commandReport(args: string[]): Promise<void> {
330
333
  allowPositionals: true,
331
334
  options: {
332
335
  json: { type: "boolean", default: false },
336
+ pricing: { type: "string" },
333
337
  strict: { type: "boolean", default: false },
334
338
  latest: { type: "boolean", default: false },
335
339
  db: { type: "string", default: "results.db" },
@@ -342,6 +346,7 @@ async function commandReport(args: string[]): Promise<void> {
342
346
 
343
347
  const requestedRunId = positionals[0];
344
348
  if (!requestedRunId && !values.latest) fail(`report: missing <run-id> (or pass --latest)\n\n${USAGE}`);
349
+ const pricing = values.pricing === undefined ? undefined : readPricing(values.pricing);
345
350
 
346
351
  const store = new ResultsStore(values.db);
347
352
  try {
@@ -356,7 +361,7 @@ async function commandReport(args: string[]): Promise<void> {
356
361
  runId = latest.id;
357
362
  }
358
363
 
359
- const report: ReportJson = buildReport({ store, runId: runId as string, resultsDir: values.results });
364
+ const report: ReportJson = buildReport({ store, runId: runId as string, resultsDir: values.results, pricing });
360
365
  const path = writeReportJson(report, values.results);
361
366
  console.log(values.json ? JSON.stringify(report, null, 2) : renderReportText(report));
362
367
  console.error(`report written: ${path}`);
@@ -532,7 +537,7 @@ function collectRunIds(positionals: string[], runsFlag: string | undefined): str
532
537
  }
533
538
 
534
539
  async function commandExport(args: string[]): Promise<void> {
535
- let values: { runs?: string; out: string; db: string; results: string; mode: string; "expected-trials"?: string };
540
+ let values: { runs?: string; out: string; db: string; results: string; mode: string; "expected-trials"?: string; pricing?: string };
536
541
  let positionals: string[];
537
542
  try {
538
543
  ({ values, positionals } = parseArgs({
@@ -541,6 +546,7 @@ async function commandExport(args: string[]): Promise<void> {
541
546
  options: {
542
547
  runs: { type: "string" },
543
548
  out: { type: "string", default: "site" },
549
+ pricing: { type: "string" },
544
550
  mode: { type: "string", default: "offline" },
545
551
  "expected-trials": { type: "string" },
546
552
  db: { type: "string", default: "results.db" },
@@ -551,6 +557,7 @@ async function commandExport(args: string[]): Promise<void> {
551
557
  fail(`export: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
552
558
  }
553
559
 
560
+ const pricing = values.pricing === undefined ? undefined : readPricing(values.pricing);
554
561
  const bundleDir = mustUiBundleDir();
555
562
  const store = new ResultsStore(values.db);
556
563
  try {
@@ -572,6 +579,7 @@ async function commandExport(args: string[]): Promise<void> {
572
579
  resultsDir: values.results,
573
580
  runIds,
574
581
  outDir: values.out,
582
+ pricing,
575
583
  mode: values.mode === "hosted" || values.mode === "offline" ? values.mode : fail("export --mode must be hosted or offline"),
576
584
  ...(values["expected-trials"] ? { expectedTrials: parsePositiveInt(values["expected-trials"], "--expected-trials") } : {}),
577
585
  bundleDir,
@@ -663,6 +671,7 @@ async function commandUi(args: string[]): Promise<void> {
663
671
  }
664
672
 
665
673
  interface ReportFlags {
674
+ pricing?: string;
666
675
  json: boolean;
667
676
  strict: boolean;
668
677
  latest: boolean;
@@ -774,6 +783,7 @@ async function main(): Promise<void> {
774
783
  err instanceof MissingProviderKeyError ||
775
784
  err instanceof InvalidProviderKeyError ||
776
785
  err instanceof ReportError ||
786
+ err instanceof PricingError ||
777
787
  err instanceof ExportError ||
778
788
  err instanceof DiagnoseError ||
779
789
  err instanceof ModelOutputError
package/src/export.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { trialCoverage } from "./completeness.ts";
35
35
  import { hashString } from "./hash.ts";
36
36
  import { buildReport, renderReportText, type ReportJson } from "./report/report.ts";
37
+ import { parsePricing, type Pricing } from "./report/costs.ts";
37
38
  import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord } from "./store/db.ts";
38
39
 
39
40
  /** Loud, CLI-friendly failures (unknown run, missing bundle, ...). */
@@ -45,6 +46,8 @@ export class ExportError extends Error {
45
46
  }
46
47
 
47
48
  export interface ExportOptions {
49
+ /** Recalculate token-cost estimates from saved transcripts using this runtime snapshot. */
50
+ pricing?: Pricing;
48
51
  /** Offline embeds JSON; hosted fetches immutable per-run snapshots lazily. */
49
52
  mode?: "offline" | "hosted";
50
53
  /** Explicit publication gate; omits incompletely sampled pairs with reasons. */
@@ -175,7 +178,9 @@ function resolveReport(
175
178
  runId: string,
176
179
  log: (message: string) => void,
177
180
  generatedAt?: string,
181
+ pricing?: Pricing,
178
182
  ): ReportJson {
183
+ if (pricing !== undefined) return buildReport({ store, runId, resultsDir, generatedAt, pricing });
179
184
  const path = join(resultsDir, runId, "report.json");
180
185
  if (existsSync(path)) {
181
186
  try {
@@ -186,8 +191,9 @@ function resolveReport(
186
191
  const run = store.getRun(runId)!;
187
192
  // Historical reports retain their methodology. New reports are reused
188
193
  // only when the entire source record still matches (including retries).
189
- if (run.manifest.manifestVersion === "v3") return stored;
190
- const current = buildReport({ store, runId, resultsDir, generatedAt: generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)! });
194
+ if (run.manifest.manifestVersion === "v3" && stored.cost_estimate === undefined) return stored;
195
+ const current = buildReport({ store, runId, resultsDir, generatedAt: generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)!,
196
+ ...(stored.cost_estimate ? { pricing: parsePricing(stored.cost_estimate.pricing) } : {}) });
191
197
  return stored.source_fingerprint === current.source_fingerprint ? stored : current;
192
198
  } catch (err) {
193
199
  log(`stored report.json for ${runId} is unreadable (${err instanceof Error ? err.message : String(err)}) — rebuilding`);
@@ -256,6 +262,14 @@ export function inlineDataIntoHtml(html: string, data: InlineData): string {
256
262
  return html.replace(QS_DATA_SLOT, () => `<script id="qs-data">window.__QS_DATA__ = ${json};</script>`);
257
263
  }
258
264
 
265
+ function publicationReport(options: ExportOptions, run: RunRecord, log: (message: string) => void): ReportJson | undefined {
266
+ if (options.expectedTrials === undefined) return undefined;
267
+ const pricing = options.pricing ?? resolveReport(options.store, options.resultsDir, run.id, log, options.generatedAt).cost_estimate?.pricing;
268
+ return buildReport({ store: options.store, runId: run.id, resultsDir: options.resultsDir,
269
+ expectedTrials: options.expectedTrials, pricing,
270
+ generatedAt: options.generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)! });
271
+ }
272
+
259
273
  /** Export one or more runs into a self-contained static site directory. */
260
274
  export async function exportSite(options: ExportOptions): Promise<ExportSummary> {
261
275
  const log = options.log ?? ((message: string) => console.warn(message));
@@ -269,7 +283,7 @@ export async function exportSite(options: ExportOptions): Promise<ExportSummary>
269
283
  );
270
284
  }
271
285
 
272
- const publications = new Map(runs.map((run) => [run.id, options.expectedTrials === undefined ? undefined : buildReport({ store, runId: run.id, resultsDir, expectedTrials: options.expectedTrials, generatedAt: options.generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)! })]));
286
+ const publications = new Map(runs.map((run) => [run.id, publicationReport(options, run, log)]));
273
287
  const dataDir = join(outDir, "data");
274
288
  mkdirSync(dataDir, { recursive: true });
275
289
 
@@ -321,11 +335,11 @@ export async function exportSite(options: ExportOptions): Promise<ExportSummary>
321
335
  };
322
336
  atomicWrite(join(runDir, "run.json"), `${JSON.stringify(dataRun, null, 2)}\n`, "utf8");
323
337
 
324
- const report = publications.get(run.id) ?? resolveReport(store, resultsDir, run.id, log, options.generatedAt);
338
+ const report = publications.get(run.id) ?? resolveReport(store, resultsDir, run.id, log, options.generatedAt, options.pricing);
325
339
  if (publication) {
326
340
  atomicWrite(join(runDir, "omissions.json"), JSON.stringify(publication, null, 2));
327
341
  atomicWrite(join(runDir, "omissions.txt"), renderReportText(report));
328
- atomicWrite(join(runDir, "source-report.json"), JSON.stringify(buildReport({ store, runId: run.id, resultsDir }), null, 2));
342
+ atomicWrite(join(runDir, "source-report.json"), JSON.stringify(buildReport({ store, runId: run.id, resultsDir, pricing: report.cost_estimate?.pricing }), null, 2));
329
343
  log(`${run.id}: complete-sample publication includes ${publication.eligible.length} pair(s), omits ${publication.omitted.length}; see data/runs/${run.id}/omissions.json and omissions.txt`);
330
344
  }
331
345
  atomicWrite(join(runDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { parseCodexCostUsage, type CodexCostUsage } from "../adapters/parse.ts";
5
+ import { hashString } from "../hash.ts";
6
+ import type { AttemptRecord, RunRecord } from "../store/db.ts";
7
+
8
+ const rateSchema = z.object({
9
+ provider: z.literal("openai"),
10
+ model: z.string().min(1),
11
+ effective_from: z.iso.datetime({ offset: true }),
12
+ effective_until: z.iso.datetime({ offset: true }).optional(),
13
+ source: z.url({ protocol: /^https?$/ }),
14
+ input_usd_per_million: z.number().finite().nonnegative(),
15
+ cached_input_usd_per_million: z.number().finite().nonnegative(),
16
+ output_usd_per_million: z.number().finite().nonnegative(),
17
+ }).strict();
18
+ const pricingSchema = z.object({
19
+ version: z.string().min(1), currency: z.literal("USD"), rates: z.array(rateSchema).min(1),
20
+ }).strict();
21
+ export type Pricing = z.infer<typeof pricingSchema>;
22
+ type Rate = Pricing["rates"][number];
23
+
24
+ export class PricingError extends Error {
25
+ constructor(message: string) { super(message); this.name = "PricingError"; }
26
+ }
27
+
28
+ /** Reject ambiguous schedules rather than silently selecting one matching price. */
29
+ export function parsePricing(value: unknown): Pricing {
30
+ const result = pricingSchema.safeParse(value);
31
+ if (!result.success) throw new PricingError(`invalid pricing: ${result.error.message}`);
32
+ const pricing = result.data;
33
+ const ends = new Map<string, number>();
34
+ for (const rate of [...pricing.rates].sort((a, b) => Date.parse(a.effective_from) - Date.parse(b.effective_from))) {
35
+ const start = Date.parse(rate.effective_from);
36
+ const end = rate.effective_until === undefined ? Infinity : Date.parse(rate.effective_until);
37
+ if (end <= start) throw new PricingError(`invalid pricing: ${rate.model} effective_until must follow effective_from`);
38
+ const key = `${rate.provider}:${rate.model}`;
39
+ if ((ends.get(key) ?? -Infinity) > start) throw new PricingError(`invalid pricing: overlapping rates for ${rate.model}`);
40
+ ends.set(key, end);
41
+ }
42
+ return pricing;
43
+ }
44
+
45
+ export function readPricing(path: string): Pricing {
46
+ try { return parsePricing(JSON.parse(readFileSync(path, "utf8"))); }
47
+ catch (err) {
48
+ throw new PricingError(`pricing file ${path}: ${err instanceof Error ? err.message : String(err)}`);
49
+ }
50
+ }
51
+
52
+ export interface AttemptCostEstimate {
53
+ attempt_id: string;
54
+ cost_usd: number | null;
55
+ unpriced_reason?: "unsupported_adapter" | "pricing_unavailable" | "transcript_unreadable" | "usage_unavailable" | "invalid_cost";
56
+ usage?: CodexCostUsage;
57
+ transcript_sha256?: string;
58
+ rate_index?: number;
59
+ }
60
+
61
+ /** Estimates supplement, never replace, telemetry.cost_usd and its reported-attempt count. */
62
+ export interface RunCostEstimate {
63
+ pricing: Pricing;
64
+ cost_usd: number | null;
65
+ cost_attempts: number;
66
+ unpriced_attempts: number;
67
+ /** Only attempts without a provider-reported cost. All values are agent token costs, not invoices. */
68
+ attempts: AttemptCostEstimate[];
69
+ }
70
+
71
+ function readTranscript(resultsDir: string, runId: string, attempt: AttemptRecord): string {
72
+ // Prefer the restored artifacts tree to the original machine's absolute reference.
73
+ const canonical = join(resultsDir, runId, attempt.id, "transcript.jsonl");
74
+ try { return readFileSync(canonical, "utf8"); }
75
+ catch (err) {
76
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT" || !attempt.transcriptRef || attempt.transcriptRef === canonical) throw err;
77
+ return readFileSync(attempt.transcriptRef, "utf8");
78
+ }
79
+ }
80
+
81
+ function estimateAttempt(run: RunRecord, attempt: AttemptRecord, resultsDir: string, pricing: Pricing): AttemptCostEstimate {
82
+ const unavailable = (unpriced_reason: NonNullable<AttemptCostEstimate["unpriced_reason"]>): AttemptCostEstimate =>
83
+ ({ attempt_id: attempt.id, cost_usd: null, unpriced_reason });
84
+ const agent = run.manifest.experiments[attempt.experimentId]?.components.agent;
85
+ if (agent?.adapter !== "codex" || agent.provider !== "openai") return unavailable("unsupported_adapter");
86
+ const at = attempt.startedAt ?? run.startedAt;
87
+ const rateIndex = pricing.rates.findIndex((rate) => rate.provider === agent.provider && rate.model === agent.model &&
88
+ at >= Date.parse(rate.effective_from) && (rate.effective_until === undefined || at < Date.parse(rate.effective_until)));
89
+ const rate = pricing.rates[rateIndex];
90
+ if (rate === undefined) return unavailable("pricing_unavailable");
91
+ let raw: string;
92
+ try { raw = readTranscript(resultsDir, run.id, attempt); }
93
+ catch { return unavailable("transcript_unreadable"); }
94
+ const usage = parseCodexCostUsage(raw);
95
+ if (usage === null) return { ...unavailable("usage_unavailable"), transcript_sha256: hashString(raw) };
96
+ const cost = tokenCost(usage, rate);
97
+ if (!Number.isFinite(cost)) return unavailable("invalid_cost");
98
+ return { attempt_id: attempt.id, cost_usd: cost, usage, transcript_sha256: hashString(raw), rate_index: rateIndex };
99
+ }
100
+
101
+ function tokenCost(usage: CodexCostUsage, rate: Rate): number {
102
+ return ((usage.input_tokens - usage.cached_input_tokens) * rate.input_usd_per_million +
103
+ usage.cached_input_tokens * rate.cached_input_usd_per_million +
104
+ usage.output_tokens * rate.output_usd_per_million) / 1_000_000;
105
+ }
106
+
107
+ /** Uses the report's selected attempts (excluding superseded retries and publication omissions). */
108
+ export function estimateRunCosts(run: RunRecord, attempts: AttemptRecord[], resultsDir: string, input: Pricing): RunCostEstimate {
109
+ const pricing = parsePricing(input);
110
+ const estimates = attempts.filter((a) => a.costUsd === null).map((a) => estimateAttempt(run, a, resultsDir, pricing));
111
+ const costs = estimates.flatMap((a) => a.cost_usd === null ? [] : [a.cost_usd]);
112
+ const total = costs.reduce((sum, cost) => sum + cost, 0);
113
+ if (!Number.isFinite(total)) throw new PricingError("estimated token cost exceeds the supported numeric range");
114
+ return { pricing, cost_usd: costs.length ? total : null, cost_attempts: costs.length,
115
+ unpriced_attempts: estimates.length - costs.length, attempts: estimates };
116
+ }
@@ -18,8 +18,9 @@
18
18
  * - offered-versus-used surface telemetry per experiment;
19
19
  * - egress denials, when the proxied run recorded any.
20
20
  *
21
- * There are deliberately no intervals, no estimates, no analyst layers, and
22
- * no language stronger than the counts themselves (see BANNED_LANGUAGE).
21
+ * There are deliberately no intervals, no statistical estimates, no analyst layers,
22
+ * and no language stronger than the counts themselves (see BANNED_LANGUAGE).
23
+ * Opt-in token-cost estimates are a separate layer, never provider-reported telemetry.
23
24
  */
24
25
 
25
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -30,6 +31,7 @@ import type { ComparisonGroup } from "../experiments/groups.ts";
30
31
  import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord } from "../store/db.ts";
31
32
  import type { SurfaceUsage } from "../surface-usage.ts";
32
33
  import { collectPassAtK, displayPassAtK, type PassAtKEntry } from "./pass-at-k.ts";
34
+ import { estimateRunCosts, type Pricing, type RunCostEstimate } from "./costs.ts";
33
35
 
34
36
  /** Loud, CLI-friendly failures (unknown run, missing report.json, ...). */
35
37
  export class ReportError extends Error {
@@ -217,6 +219,8 @@ export interface ReportJson {
217
219
  retry_history?: Array<{ attempt_id: string; status: string; error: string | null }>;
218
220
  totals: ReportTotals;
219
221
  telemetry: ReportTelemetry;
222
+ /** Optional runtime-priced supplement; absent on historical or unpriced reports. */
223
+ cost_estimate?: RunCostEstimate;
220
224
  judge_telemetry?: { provenance: "semantic-judge"; calls: number; tokens_in: number | null; tokens_out: number | null; cost_usd: number | null };
221
225
  /**
222
226
  * Per-experiment identity cards (model, reasoning, adapter, ...). Optional
@@ -486,6 +490,8 @@ function collectEgressDenials(resultsDir: string, runId: string, attempts: Attem
486
490
 
487
491
  export interface BuildReportOptions {
488
492
  expectedTrials?: number;
493
+ /** Explicit runtime rate snapshot; never changes stored provider-reported costs. */
494
+ pricing?: Pricing;
489
495
  store: ResultsStore;
490
496
  runId: string;
491
497
  /** Artifacts root (the harness `--results` dir) — egress denial logs live here. */
@@ -514,8 +520,10 @@ export function buildReport(options: BuildReportOptions): ReportJson {
514
520
  if (publication && publication.eligible.length === 0) throw new ReportError(`no eligible complete samples for run ${runId}: ${publication.omitted.map((p) => `${p.eval_id} × ${p.experiment_id}: ${p.reasons.join("; ")}`).join(" | ")}`);
515
521
  const attempts = publication ? current.filter((a) => publication.eligible.some((p) => p.eval_id === a.evalId && p.experiment_id === a.experimentId)) : current;
516
522
  const pairs = collectPairs(attempts);
523
+ const costEstimate = options.pricing === undefined ? undefined : estimateRunCosts(run, attempts, options.resultsDir, options.pricing);
517
524
 
518
- const methodology = methodologyNote(run.config.trials);
525
+ const methodology = methodologyNote(run.config.trials) + (costEstimate === undefined ? "" :
526
+ " Optional token-cost estimates supplement reported cost using the supplied pricing snapshot. They cover only the selected attempts, not superseded retries, tool fees, diagnosis or infrastructure, and are not billing records.");
519
527
  const banned = findBannedLanguage(methodology);
520
528
  if (banned.length > 0) {
521
529
  throw new ReportError(`report methodology violates the language guard: ${banned.join(", ")}`);
@@ -534,12 +542,13 @@ export function buildReport(options: BuildReportOptions): ReportJson {
534
542
  manifest_hash: run.manifestHash,
535
543
  },
536
544
  incomplete: publication ? false : run.finishedAt === null || coverage.length === 0 || coverage.some((pair) => !pair.complete),
537
- source_fingerprint: hashString(JSON.stringify({ manifest: run.manifestHash, finishedAt: run.finishedAt, trials: run.config.trials, evals: run.config.evalIds, experiments: run.config.experimentIds, history: history.map(({ transcriptRef, diffRef, ...row }) => ({ ...row, transcript: Boolean(transcriptRef), diff: Boolean(diffRef) })) })),
545
+ source_fingerprint: hashString(JSON.stringify({ manifest: run.manifestHash, finishedAt: run.finishedAt, trials: run.config.trials, evals: run.config.evalIds, experiments: run.config.experimentIds, history: history.map(({ transcriptRef, diffRef, ...row }) => ({ ...row, transcript: Boolean(transcriptRef), diff: Boolean(diffRef) })), costEstimate })),
538
546
  ...(publication ? { publication: { ...publication, source_totals: collectTotals(current), source_incomplete: run.finishedAt === null || coverage.some((p) => !p.complete) } } : {}),
539
547
  coverage,
540
548
  retry_history: history.filter((a) => !current.includes(a)).map((a) => ({ attempt_id: a.id, status: a.status, error: a.error })),
541
549
  totals: collectTotals(attempts),
542
550
  telemetry: collectTelemetry(attempts),
551
+ ...(costEstimate === undefined ? {} : { cost_estimate: costEstimate }),
543
552
  judge_telemetry: collectJudgeTelemetry(attempts),
544
553
  experiments: collectExperiments(run),
545
554
  pairs,
@@ -605,6 +614,16 @@ export function renderReportText(report: ReportJson): string {
605
614
  lines.push(
606
615
  ` cost: ${tel.cost_usd === null ? "unavailable" : `$${tel.cost_usd.toFixed(4)} across ${tel.cost_attempts} attempt(s)`}`,
607
616
  );
617
+ if (report.cost_estimate) {
618
+ const estimate = report.cost_estimate;
619
+ const cost = estimate.cost_usd;
620
+ const displayed = cost === null ? "unavailable" : cost > 0 && cost < 0.0001 ? "<$0.0001" : `$${cost.toFixed(4)}`;
621
+ lines.push(` estimated token cost: ${displayed} (in addition to reported cost)`);
622
+ lines.push(` ${estimate.cost_attempts} estimated, ${estimate.unpriced_attempts} unpriced attempt(s); pricing ${estimate.pricing.version}`);
623
+ for (const attempt of estimate.attempts) {
624
+ if (attempt.unpriced_reason) lines.push(` ${attempt.attempt_id}: ${attempt.unpriced_reason}`);
625
+ }
626
+ }
608
627
  lines.push(
609
628
  ` tokens: ${
610
629
  tel.tokens_in === null && tel.tokens_out === null