@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.
- package/README.md +162 -0
- package/bin/harness.mjs +76 -0
- package/dist/core/budget.d.ts +46 -0
- package/dist/core/budget.js +72 -0
- package/dist/core/cache.d.ts +119 -0
- package/dist/core/cache.js +206 -0
- package/dist/core/client.d.ts +33 -0
- package/dist/core/client.js +98 -0
- package/dist/core/errors.d.ts +22 -0
- package/dist/core/errors.js +28 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/pull.d.ts +17 -0
- package/dist/core/pull.js +38 -0
- package/dist/core/schema/serialize.d.ts +12 -0
- package/dist/core/schema/serialize.js +42 -0
- package/dist/core/schema/types.d.ts +58 -0
- package/dist/core/schema/types.js +1 -0
- package/dist/core/schema/validate.d.ts +28 -0
- package/dist/core/schema/validate.js +98 -0
- package/dist/harness.d.ts +128 -0
- package/dist/harness.js +187 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/prompt/codegen.d.ts +37 -0
- package/dist/prompt/codegen.js +97 -0
- package/dist/prompt/render.d.ts +35 -0
- package/dist/prompt/render.js +66 -0
- package/dist/prompt/run.d.ts +38 -0
- package/dist/prompt/run.js +96 -0
- package/dist/prompt/types.d.ts +156 -0
- package/dist/prompt/types.js +1 -0
- package/package.json +53 -0
package/dist/harness.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|