@workos/quickstudy 0.1.0 → 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 +1 -2
- package/package.json +1 -1
- package/src/adapters/parse.ts +54 -0
- package/src/build-info.generated.ts +1 -1
- package/src/cli.ts +14 -4
- package/src/export.ts +19 -5
- package/src/report/costs.ts +116 -0
- package/src/report/report.ts +23 -4
- package/ui/dist/index.html +3 -3
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
|
|
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.
|
|
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",
|
package/src/adapters/parse.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
+
}
|
package/src/report/report.ts
CHANGED
|
@@ -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,
|
|
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
|