@spendgraph/harness 0.1.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.
@@ -0,0 +1,187 @@
1
+ import { Client } from "./core/client.js";
2
+ import { Budget } from "./core/budget.js";
3
+ import { PullCache } from "./core/cache.js";
4
+ import { pullWithCache } from "./core/pull.js";
5
+ import { serializeFields } from "./core/schema/serialize.js";
6
+ import { renderMessages } from "./prompt/render.js";
7
+ import { newRolloutId, runAcrossModels, runOnce, sampleRuns } from "./prompt/run.js";
8
+ export class Harness {
9
+ client;
10
+ cache;
11
+ hasKey;
12
+ onReportError;
13
+ concurrency;
14
+ budget;
15
+ constructor(opts) {
16
+ const { cache, onReportError, onPullError, concurrency, maxCostMicros, ...clientOpts } = opts;
17
+ this.client = new Client(clientOpts);
18
+ // Spread first, then the wiring — the other order let a `cache` object
19
+ // carrying `onRefreshError: undefined` silently discard `onPullError`.
20
+ this.cache = new PullCache({
21
+ ...cache,
22
+ onRefreshError: cache?.onRefreshError ?? onPullError,
23
+ // What a cached prompt answers to, so an invalidation by uuid also
24
+ // catches the slug production actually pulled by, and vice versa.
25
+ identify: (p) => [p.id, p.slug].filter((v) => Boolean(v)),
26
+ });
27
+ this.hasKey = Boolean(opts.apiKey);
28
+ this.onReportError = onReportError;
29
+ this.concurrency = Math.max(1, concurrency ?? 4);
30
+ this.budget = new Budget("maxCostMicros" in opts ? maxCostMicros : undefined);
31
+ }
32
+ /**
33
+ * A prompt, from cache when one is warm.
34
+ *
35
+ * Serves a stale copy immediately and refreshes behind the caller, so an edit
36
+ * takes effect within a TTL without any request ever paying for the fetch.
37
+ *
38
+ * Takes an id or a slug — the server resolves either. Cached under whatever
39
+ * you passed, so pulling the same prompt both ways keeps two entries.
40
+ *
41
+ * A slug is only as stable as the prompt's name: renaming it server-side
42
+ * mints a new one and the old stops resolving, at which point the background
43
+ * refresh 404s and this keeps serving the wording it last saw. Pull by id
44
+ * where a handle has to outlive an edit to the name.
45
+ */
46
+ async pull(promptId) {
47
+ const payload = await pullWithCache(this.cache, promptId, async () => {
48
+ const res = await this.client.request(`/api/v1/prompts/${encodeURIComponent(promptId)}`);
49
+ return res.prompt;
50
+ });
51
+ const serialize = (values = {}) => serializeFields(values, payload.fieldSpec ?? []);
52
+ return {
53
+ id: payload.id,
54
+ name: payload.name,
55
+ slug: payload.slug ?? null,
56
+ fields: payload.fieldSpec ?? [],
57
+ versionId: payload.currentVersionId ?? null,
58
+ models: payload.models ?? [],
59
+ serialize,
60
+ render: (values = {}) => renderMessages(payload.blocks, payload.question, serialize(values)),
61
+ };
62
+ }
63
+ /**
64
+ * Runs the prompt server-side and records the rollout.
65
+ *
66
+ * The batch path. A production request should pull, render and report
67
+ * instead — this adds a hop and makes spendgraph a dependency of the caller's
68
+ * uptime, which is the right trade for evaluation and the wrong one for a
69
+ * user waiting on a response.
70
+ */
71
+ async run(promptId, values = {}, opts = {}) {
72
+ return runOnce(this.client, promptId, values, opts, this.budget);
73
+ }
74
+ /** k repetitions of one case. Always k results, failures included. */
75
+ async sample(promptId, values = {}, opts = {}) {
76
+ return sampleRuns(this.client, promptId, values, { concurrency: this.concurrency, ...opts }, this.budget);
77
+ }
78
+ /** One rollout per model — the playground's comparison, headless. */
79
+ async runAll(promptId, values = {}, opts = {}) {
80
+ const models = opts.models ?? (await this.pull(promptId)).models;
81
+ if (models.length === 0) {
82
+ throw new Error("No models given and the prompt has none saved.");
83
+ }
84
+ return runAcrossModels(this.client, promptId, values, models, { concurrency: this.concurrency, ...opts }, this.budget);
85
+ }
86
+ /** The dataset a prompt is evaluated against, with its split counts. */
87
+ async cases(promptId) {
88
+ return this.client.request(`/api/v1/prompts/${encodeURIComponent(promptId)}/cases`);
89
+ }
90
+ /**
91
+ * Replaces the whole dataset.
92
+ *
93
+ * A dataset is a set: "which cases am I evaluating against" has one answer at
94
+ * a time, and a merge would leave no way to remove a case or to know from the
95
+ * outside what the set currently is.
96
+ */
97
+ async setCases(promptId, cases) {
98
+ return this.client.request(`/api/v1/prompts/${encodeURIComponent(promptId)}/cases`, { method: "PUT", body: JSON.stringify({ cases }) });
99
+ }
100
+ /** Micro-dollars this client has spent on runs it initiated. */
101
+ spent() {
102
+ return this.budget.spent();
103
+ }
104
+ /** What is left of the ceiling, or Infinity when none was set. */
105
+ remaining() {
106
+ return this.budget.remaining();
107
+ }
108
+ /** Every wording this prompt has had, newest first. */
109
+ async versions(promptId, opts = {}) {
110
+ const q = new URLSearchParams();
111
+ if (opts.origin)
112
+ q.set("origin", opts.origin);
113
+ if (opts.limit)
114
+ q.set("limit", String(opts.limit));
115
+ const suffix = q.size ? `?${q}` : "";
116
+ return this.client.request(`/api/v1/prompts/${encodeURIComponent(promptId)}/versions${suffix}`);
117
+ }
118
+ /**
119
+ * Serves this version from now on.
120
+ *
121
+ * Invalidates the cache: a promotion that left a warm entry in place would
122
+ * keep sending the old wording for up to a TTL, which is the one moment a
123
+ * caller is actively watching for the change.
124
+ */
125
+ async promote(promptId, versionId) {
126
+ const res = await this.client.request(`/api/v1/prompts/${encodeURIComponent(promptId)}/versions`, {
127
+ method: "POST",
128
+ body: JSON.stringify({ versionId }),
129
+ });
130
+ this.invalidate(promptId);
131
+ return res;
132
+ }
133
+ /**
134
+ * Drops whatever is cached, so the next pull refetches.
135
+ *
136
+ * Both handles, not just the one passed. `pull` keys the cache on whatever
137
+ * string it was given, so the same prompt can sit under its uuid and its
138
+ * slug at once — and `promote` is only reachable with a uuid while the
139
+ * dashboard teaches pulling by slug. Dropping one key left the other warm
140
+ * for a full TTL, still rendering the wording the promotion replaced, which
141
+ * is the exact case this method exists to prevent.
142
+ */
143
+ invalidate(promptId) {
144
+ if (!promptId) {
145
+ this.cache.clear();
146
+ return;
147
+ }
148
+ // Matched on the payload, not on the key handed in. Reading the entry under
149
+ // `promptId` only finds the sibling when that key was the cached one — and
150
+ // the case this exists for is the opposite: production pulled by slug, so
151
+ // the slug is the only key, while `promote` is reachable only with a uuid.
152
+ for (const key of this.cache.keysWhere((p) => p.id === promptId || p.slug === promptId)) {
153
+ this.cache.delete(key);
154
+ }
155
+ // Always, even with nothing stored under it: a cold isolate has a fetch in
156
+ // flight under this handle and no entry yet, and delete is what bumps the
157
+ // epoch that stops the in-flight result being stored after the promotion.
158
+ this.cache.delete(promptId);
159
+ // Recorded against the name as well, so a fetch still in flight — which
160
+ // carries no payload yet to say which prompt it is for — is judged when it
161
+ // lands rather than guessed at now. This is what replaced dropping every
162
+ // unrelated outstanding fetch on any promote.
163
+ this.cache.invalidate(promptId);
164
+ }
165
+ /**
166
+ * Records a rollout the caller executed.
167
+ *
168
+ * Never throws and never rejects. This sits beside a user-facing request that
169
+ * has already been answered; telemetry that can break the thing it measures
170
+ * is worse than no telemetry. Failures go to `onReportError` and are dropped.
171
+ *
172
+ * Returns the rollout id it used, so a caller that wants to retry the exact
173
+ * same record can pass it back and land on one row.
174
+ */
175
+ async report(promptId, input) {
176
+ const rolloutId = input.rolloutId ?? newRolloutId();
177
+ if (!this.hasKey)
178
+ return rolloutId;
179
+ try {
180
+ await this.client.request(`/api/v1/prompts/${encodeURIComponent(promptId)}/rollouts`, { method: "POST", body: JSON.stringify({ ...input, rolloutId }) });
181
+ }
182
+ catch (err) {
183
+ this.onReportError?.(err);
184
+ }
185
+ return rolloutId;
186
+ }
187
+ }
@@ -0,0 +1,9 @@
1
+ export { Harness, type HarnessOptions } from "./harness.js";
2
+ export { Budget, BudgetExceededError, Client, HarnessError, PullCache, pullWithCache, type CacheHit, type CacheOptions, type ClientOptions, } from "./core/index.js";
3
+ export type { FieldSpec, FieldType } from "./core/schema/types.js";
4
+ export { FieldValidationError, validateFields, type FieldError, } from "./core/schema/validate.js";
5
+ export { serializeFields } from "./core/schema/serialize.js";
6
+ export { applyVariables, compileSystemPrompt, findVariables, renderMessages, type Block, type Message, } from "./prompt/render.js";
7
+ export { mapLimit, newRolloutId } from "./prompt/run.js";
8
+ export { emitPromptTypes, typeOf, valuesTypeFor, type CodegenPrompt, type EmitOptions, } from "./prompt/codegen.js";
9
+ export type { DatasetCase, DatasetSummary, Prompt, PromptPayload, ReportInput, Rollout, RunOptions, PromptVersion, Prompts, RunResult, Split, ValuesFor, } from "./prompt/types.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { Harness } from "./harness.js";
2
+ export { Budget, BudgetExceededError, Client, HarnessError, PullCache, pullWithCache, } from "./core/index.js";
3
+ export { FieldValidationError, validateFields, } from "./core/schema/validate.js";
4
+ export { serializeFields } from "./core/schema/serialize.js";
5
+ export { applyVariables, compileSystemPrompt, findVariables, renderMessages, } from "./prompt/render.js";
6
+ export { mapLimit, newRolloutId } from "./prompt/run.js";
7
+ export { emitPromptTypes, typeOf, valuesTypeFor, } from "./prompt/codegen.js";
@@ -0,0 +1,37 @@
1
+ import type { FieldSpec } from "../core/schema/types.js";
2
+ /** The shape codegen needs from a prompt — a narrow slice of the payload. */
3
+ export interface CodegenPrompt {
4
+ id: string;
5
+ /** The other handle `pull` and `run` accept, so it needs a key too. */
6
+ slug?: string | null;
7
+ name: string;
8
+ fieldSpec: FieldSpec[];
9
+ }
10
+ /**
11
+ * The TypeScript type a declared field accepts.
12
+ *
13
+ * `enum` becomes a union of its options rather than `string`, which is the whole
14
+ * reason a caller feels the difference: a typo in a tone or a format is caught
15
+ * where it is written instead of at the other end of a paid request.
16
+ */
17
+ export declare function typeOf(field: FieldSpec): string;
18
+ /** `{ name: type }`, or `Record<string, never>` when the prompt takes nothing. */
19
+ export declare function valuesTypeFor(fields: FieldSpec[], indent?: string): string;
20
+ export interface EmitOptions {
21
+ /** The package specifier being augmented. Overridable for tests and forks. */
22
+ module?: string;
23
+ }
24
+ /**
25
+ * A `.d.ts` that teaches TypeScript what each stored prompt expects.
26
+ *
27
+ * Declaration merging rather than a generated client: the caller keeps importing
28
+ * `Harness` from the package, and the generated file only sharpens the types it
29
+ * already has. Nothing breaks if the file is deleted or goes stale — calls fall
30
+ * back to `Record<string, unknown>`, and the runtime validation that always runs
31
+ * catches what the compiler then cannot.
32
+ *
33
+ * `export {}` at the end is load-bearing: without it the file is a script rather
34
+ * than a module, and `declare module` would replace the package's types instead
35
+ * of adding to them.
36
+ */
37
+ export declare function emitPromptTypes(prompts: CodegenPrompt[], opts?: EmitOptions): string;
@@ -0,0 +1,97 @@
1
+ /** JSON string literal, so a quote or newline in an option cannot break the file. */
2
+ function lit(value) {
3
+ return JSON.stringify(value);
4
+ }
5
+ /**
6
+ * The TypeScript type a declared field accepts.
7
+ *
8
+ * `enum` becomes a union of its options rather than `string`, which is the whole
9
+ * reason a caller feels the difference: a typo in a tone or a format is caught
10
+ * where it is written instead of at the other end of a paid request.
11
+ */
12
+ export function typeOf(field) {
13
+ switch (field.type) {
14
+ case "number":
15
+ return "number";
16
+ case "boolean":
17
+ return "boolean";
18
+ case "enum": {
19
+ const options = field.options ?? [];
20
+ // An enum with no options declared cannot be narrowed to anything useful,
21
+ // and `never` would make the prompt uncallable.
22
+ return options.length ? options.map(lit).join(" | ") : "string";
23
+ }
24
+ case "list":
25
+ return "string[]";
26
+ case "json":
27
+ return "unknown";
28
+ default:
29
+ return "string";
30
+ }
31
+ }
32
+ /** A field may be omitted when it is optional or carries a default. */
33
+ function isOptional(field) {
34
+ return !field.required || field.default !== undefined;
35
+ }
36
+ /** `{ name: type }`, or `Record<string, never>` when the prompt takes nothing. */
37
+ export function valuesTypeFor(fields, indent = " ") {
38
+ if (fields.length === 0)
39
+ return "Record<string, never>";
40
+ const lines = fields.map((f) => {
41
+ const doc = f.description ? `${indent} /** ${f.description.replace(/\*\//g, "*\\/")} */\n` : "";
42
+ const key = /^[A-Za-z_$][\w$]*$/.test(f.name) ? f.name : lit(f.name);
43
+ return `${doc}${indent} ${key}${isOptional(f) ? "?" : ""}: ${typeOf(f)};`;
44
+ });
45
+ return `{\n${lines.join("\n")}\n${indent}}`;
46
+ }
47
+ /**
48
+ * A `.d.ts` that teaches TypeScript what each stored prompt expects.
49
+ *
50
+ * Declaration merging rather than a generated client: the caller keeps importing
51
+ * `Harness` from the package, and the generated file only sharpens the types it
52
+ * already has. Nothing breaks if the file is deleted or goes stale — calls fall
53
+ * back to `Record<string, unknown>`, and the runtime validation that always runs
54
+ * catches what the compiler then cannot.
55
+ *
56
+ * `export {}` at the end is load-bearing: without it the file is a script rather
57
+ * than a module, and `declare module` would replace the package's types instead
58
+ * of adding to them.
59
+ */
60
+ export function emitPromptTypes(prompts, opts = {}) {
61
+ // Not named `module`: the app's eslint config forbids assigning that
62
+ // identifier, and this package is linted under it.
63
+ const importFrom = opts.module ?? "@spendgraph/harness";
64
+ // Sorted by id so regenerating an unchanged project produces no diff.
65
+ const sorted = [...prompts].sort((a, b) => a.id.localeCompare(b.id));
66
+ const entries = sorted.map((p) => {
67
+ const name = p.name ? `${indentDoc(p.name)}\n` : "";
68
+ const values = valuesTypeFor(p.fieldSpec ?? []);
69
+ // A key per handle. `pull` and `run` accept an id or a slug, and the
70
+ // dashboard's Use-in-code tab hands out the slug — so keying on the id
71
+ // alone meant every snippet the product prints fell through
72
+ // `ValuesFor<K>` to `Record<string, unknown>` and lost the checking this
73
+ // file exists to provide, silently.
74
+ const keys = p.slug && p.slug !== p.id ? [p.id, p.slug] : [p.id];
75
+ return keys.map((k) => `${name} ${lit(k)}: ${values};`).join("\n");
76
+ });
77
+ const body = entries.length
78
+ ? entries.join("\n")
79
+ : " // No prompts found. Run `harness codegen` again after saving one.";
80
+ return `// Generated by \`harness codegen\`. Do not edit.
81
+ //
82
+ // Sharpens the id and values arguments of Harness methods. Deleting this file
83
+ // is safe: calls fall back to a loose \`Record<string, unknown>\`, and the
84
+ // runtime field validation is unchanged either way.
85
+
86
+ declare module ${lit(importFrom)} {
87
+ interface Prompts {
88
+ ${body}
89
+ }
90
+ }
91
+
92
+ export {};
93
+ `;
94
+ }
95
+ function indentDoc(text) {
96
+ return ` /** ${text.replace(/\*\//g, "*\\/")} */`;
97
+ }
@@ -0,0 +1,35 @@
1
+ export type Block = {
2
+ title: string;
3
+ body: string;
4
+ };
5
+ export type Message = {
6
+ role: "system" | "user";
7
+ content: string;
8
+ };
9
+ /**
10
+ * Substitutes `{name}` placeholders. An unknown one is left verbatim — silently
11
+ * emptying it would send a prompt with a hole the caller cannot see.
12
+ *
13
+ * Mirrors `lib/prompt.ts` in the app, deliberately duplicated: this package is
14
+ * published and depends on nothing. The two are kept in step by
15
+ * `rendered` being recorded on every rollout, which makes a divergence visible
16
+ * rather than theoretical.
17
+ */
18
+ export declare function applyVariables(text: string, values: Record<string, string> | undefined): string;
19
+ /** Placeholder names used anywhere in the prompt, in first-seen order. */
20
+ export declare function findVariables(blocks: Block[], question: string): string[];
21
+ /**
22
+ * Blocks in the order they were arranged.
23
+ *
24
+ * An empty body drops the block entirely — a heading over nothing is noise in
25
+ * the prompt. A body with no title is emitted bare rather than under a blank
26
+ * `##`, so an unnamed block still says what it says.
27
+ */
28
+ export declare function compileSystemPrompt(blocks: Block[], values?: Record<string, string>): string;
29
+ /**
30
+ * Every message the request will send, in order.
31
+ *
32
+ * An empty system message is a different request from no system message at all,
33
+ * so a prompt with no blocks sends one message rather than two.
34
+ */
35
+ export declare function renderMessages(blocks: Block[], question: string, values?: Record<string, string>): Message[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Substitutes `{name}` placeholders. An unknown one is left verbatim — silently
3
+ * emptying it would send a prompt with a hole the caller cannot see.
4
+ *
5
+ * Mirrors `lib/prompt.ts` in the app, deliberately duplicated: this package is
6
+ * published and depends on nothing. The two are kept in step by
7
+ * `rendered` being recorded on every rollout, which makes a divergence visible
8
+ * rather than theoretical.
9
+ */
10
+ export function applyVariables(text, values) {
11
+ if (!text || !values)
12
+ return text;
13
+ return text.replace(/\{(\w+)\}/g, (whole, name) => name in values ? values[name] : whole);
14
+ }
15
+ /** Placeholder names used anywhere in the prompt, in first-seen order. */
16
+ export function findVariables(blocks, question) {
17
+ const seen = [];
18
+ const scan = (text) => {
19
+ for (const m of text.matchAll(/\{(\w+)\}/g)) {
20
+ if (!seen.includes(m[1]))
21
+ seen.push(m[1]);
22
+ }
23
+ };
24
+ // titles too — a heading can carry a placeholder as readily as a body
25
+ for (const b of blocks ?? []) {
26
+ scan(b.title ?? "");
27
+ scan(b.body ?? "");
28
+ }
29
+ scan(question);
30
+ return seen;
31
+ }
32
+ /**
33
+ * Blocks in the order they were arranged.
34
+ *
35
+ * An empty body drops the block entirely — a heading over nothing is noise in
36
+ * the prompt. A body with no title is emitted bare rather than under a blank
37
+ * `##`, so an unnamed block still says what it says.
38
+ */
39
+ export function compileSystemPrompt(blocks, values) {
40
+ const out = [];
41
+ for (const b of blocks ?? []) {
42
+ const body = b.body?.trim();
43
+ if (!body)
44
+ continue;
45
+ const title = b.title?.trim();
46
+ const text = applyVariables(body, values);
47
+ out.push(title ? `## ${applyVariables(title, values)}\n${text}` : text);
48
+ }
49
+ return out.join("\n\n");
50
+ }
51
+ /**
52
+ * Every message the request will send, in order.
53
+ *
54
+ * An empty system message is a different request from no system message at all,
55
+ * so a prompt with no blocks sends one message rather than two.
56
+ */
57
+ export function renderMessages(blocks, question, values) {
58
+ const system = compileSystemPrompt(blocks, values);
59
+ const user = applyVariables(question, values);
60
+ return system
61
+ ? [
62
+ { role: "system", content: system },
63
+ { role: "user", content: user },
64
+ ]
65
+ : [{ role: "user", content: user }];
66
+ }
@@ -0,0 +1,38 @@
1
+ import type { Budget } from "../core/budget.js";
2
+ import type { Client } from "../core/client.js";
3
+ import type { RunOptions, RunResult } from "./types.js";
4
+ /** Generated per attempt so a retry of the same call lands on one row. */
5
+ export declare function newRolloutId(): string;
6
+ /**
7
+ * Runs `fn` over `items` with at most `limit` in flight.
8
+ *
9
+ * Bounded on purpose. Firing a whole dataset at once turns most of it into 429s
10
+ * that you still wait out, so the unbounded version finishes later and flakier
11
+ * than the bounded one.
12
+ */
13
+ export declare function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
14
+ export declare function runOnce(client: Client, promptId: string, values: Record<string, unknown>, opts?: RunOptions, budget?: Budget): Promise<RunResult>;
15
+ /**
16
+ * k repetitions of one case, so `pass^k` is computable.
17
+ *
18
+ * Always returns k results. A seed that failed is present with
19
+ * `status: "failed"` rather than missing: returning three results for k = 5
20
+ * would have the reliability metric compute over three and report better
21
+ * consistency than the run actually showed.
22
+ *
23
+ * A budget stop is the exception, and it throws. Money running out says nothing
24
+ * about how reliable the prompt is, so recording those seeds as failures would
25
+ * push a number into pass^k that is about the wallet rather than the model.
26
+ *
27
+ * `seed` is metadata, not a provider parameter. Anthropic exposes none and
28
+ * OpenAI's is best-effort; it records which repetition a row is, which is all
29
+ * pass^k needs.
30
+ */
31
+ export declare function sampleRuns(client: Client, promptId: string, values: Record<string, unknown>, opts?: RunOptions & {
32
+ k?: number;
33
+ concurrency?: number;
34
+ }, budget?: Budget): Promise<RunResult[]>;
35
+ /** One rollout per model — the playground's comparison, headless. */
36
+ export declare function runAcrossModels(client: Client, promptId: string, values: Record<string, unknown>, models: string[], opts?: RunOptions & {
37
+ concurrency?: number;
38
+ }, budget?: Budget): Promise<RunResult[]>;
@@ -0,0 +1,96 @@
1
+ import { BudgetExceededError } from "../core/budget.js";
2
+ /** Generated per attempt so a retry of the same call lands on one row. */
3
+ export function newRolloutId() {
4
+ return `ro_${crypto.randomUUID().replace(/-/g, "")}`;
5
+ }
6
+ /**
7
+ * Runs `fn` over `items` with at most `limit` in flight.
8
+ *
9
+ * Bounded on purpose. Firing a whole dataset at once turns most of it into 429s
10
+ * that you still wait out, so the unbounded version finishes later and flakier
11
+ * than the bounded one.
12
+ */
13
+ export async function mapLimit(items, limit, fn) {
14
+ const out = new Array(items.length);
15
+ let next = 0;
16
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
17
+ while (next < items.length) {
18
+ const i = next++;
19
+ out[i] = await fn(items[i], i);
20
+ }
21
+ });
22
+ await Promise.all(workers);
23
+ return out;
24
+ }
25
+ export async function runOnce(client, promptId, values, opts = {}, budget) {
26
+ budget?.assertAffordable();
27
+ const rolloutId = opts.rolloutId ?? newRolloutId();
28
+ const res = await client.request(`/api/v1/prompts/${encodeURIComponent(promptId)}/run`, {
29
+ method: "POST",
30
+ body: JSON.stringify({
31
+ rolloutId,
32
+ values,
33
+ model: opts.model,
34
+ temperature: opts.temperature,
35
+ maxTokens: opts.maxTokens,
36
+ seed: opts.seed ?? 0,
37
+ caseId: opts.caseId,
38
+ candidateId: opts.candidateId,
39
+ parentId: opts.parentId,
40
+ generation: opts.generation,
41
+ record: opts.record ?? true,
42
+ }),
43
+ });
44
+ // A deduped reply costs nothing new, but its cost is already in the total from
45
+ // the attempt that wrote it, so counting it again would overstate the spend.
46
+ if (!res.deduped)
47
+ budget?.add(res.rollout.costMicros ?? 0);
48
+ return res.rollout;
49
+ }
50
+ /**
51
+ * k repetitions of one case, so `pass^k` is computable.
52
+ *
53
+ * Always returns k results. A seed that failed is present with
54
+ * `status: "failed"` rather than missing: returning three results for k = 5
55
+ * would have the reliability metric compute over three and report better
56
+ * consistency than the run actually showed.
57
+ *
58
+ * A budget stop is the exception, and it throws. Money running out says nothing
59
+ * about how reliable the prompt is, so recording those seeds as failures would
60
+ * push a number into pass^k that is about the wallet rather than the model.
61
+ *
62
+ * `seed` is metadata, not a provider parameter. Anthropic exposes none and
63
+ * OpenAI's is best-effort; it records which repetition a row is, which is all
64
+ * pass^k needs.
65
+ */
66
+ export async function sampleRuns(client, promptId, values, opts = {}, budget) {
67
+ const k = Math.max(1, opts.k ?? 5);
68
+ const seeds = Array.from({ length: k }, (_, i) => i);
69
+ return mapLimit(seeds, opts.concurrency ?? 4, async (seed) => {
70
+ try {
71
+ return await runOnce(client, promptId, values, { ...opts, seed, rolloutId: undefined }, budget);
72
+ }
73
+ catch (err) {
74
+ if (err instanceof BudgetExceededError)
75
+ throw err;
76
+ // A transport failure is still an outcome for this seed. Throwing would
77
+ // discard the k-1 results that did come back.
78
+ return {
79
+ id: null,
80
+ model: opts.model ?? "",
81
+ status: "failed",
82
+ output: "",
83
+ error: err.message,
84
+ costMicros: 0,
85
+ latencyMs: 0,
86
+ inputTokens: 0,
87
+ outputTokens: 0,
88
+ seed,
89
+ };
90
+ }
91
+ });
92
+ }
93
+ /** One rollout per model — the playground's comparison, headless. */
94
+ export async function runAcrossModels(client, promptId, values, models, opts = {}, budget) {
95
+ return mapLimit(models, opts.concurrency ?? 4, (model) => runOnce(client, promptId, values, { ...opts, model, rolloutId: undefined }, budget));
96
+ }