@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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ClientOptions {
|
|
2
|
+
/** spendgraph API key (sg_…). Without it every call is refused locally. */
|
|
3
|
+
apiKey: string | undefined;
|
|
4
|
+
/** Base URL of your deployed spendgraph app. */
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
/** Injected for tests and for runtimes with a non-global fetch. */
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
/** Injected so tests do not actually wait out a backoff. */
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
/** Total attempts including the first. Default 3. */
|
|
11
|
+
attempts?: number;
|
|
12
|
+
/** Never wait longer than this for one backoff. Default 5000. */
|
|
13
|
+
maxWaitMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The one thing that talks to spendgraph.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately the only module in this package that knows a network exists —
|
|
19
|
+
* everything else is given data. That is what makes the rest of it testable
|
|
20
|
+
* with no server, and what keeps the boundary rule honest: this package speaks
|
|
21
|
+
* HTTP and never touches a database.
|
|
22
|
+
*/
|
|
23
|
+
export declare class Client {
|
|
24
|
+
private readonly apiKey;
|
|
25
|
+
private readonly baseUrl;
|
|
26
|
+
private readonly doFetch;
|
|
27
|
+
private readonly sleep;
|
|
28
|
+
private readonly attempts;
|
|
29
|
+
private readonly maxWaitMs;
|
|
30
|
+
constructor(opts: ClientOptions);
|
|
31
|
+
request<T>(path: string, init?: RequestInit): Promise<T>;
|
|
32
|
+
private toError;
|
|
33
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { HarnessError } from "./errors.js";
|
|
2
|
+
const DEFAULT_ATTEMPTS = 3;
|
|
3
|
+
const DEFAULT_MAX_WAIT_MS = 5_000;
|
|
4
|
+
/**
|
|
5
|
+
* `retry-after` in milliseconds, or null when the server did not say.
|
|
6
|
+
*
|
|
7
|
+
* Sent as whole seconds per the HTTP spec; spendgraph's rate limiter always
|
|
8
|
+
* sets it alongside a 429, and honouring it beats guessing — a backoff shorter
|
|
9
|
+
* than the window just burns another attempt.
|
|
10
|
+
*/
|
|
11
|
+
function retryAfterMs(res) {
|
|
12
|
+
const raw = res.headers.get("retry-after");
|
|
13
|
+
if (!raw)
|
|
14
|
+
return null;
|
|
15
|
+
const seconds = Number(raw);
|
|
16
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The one thing that talks to spendgraph.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately the only module in this package that knows a network exists —
|
|
22
|
+
* everything else is given data. That is what makes the rest of it testable
|
|
23
|
+
* with no server, and what keeps the boundary rule honest: this package speaks
|
|
24
|
+
* HTTP and never touches a database.
|
|
25
|
+
*/
|
|
26
|
+
export class Client {
|
|
27
|
+
apiKey;
|
|
28
|
+
baseUrl;
|
|
29
|
+
doFetch;
|
|
30
|
+
sleep;
|
|
31
|
+
attempts;
|
|
32
|
+
maxWaitMs;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
this.apiKey = opts.apiKey;
|
|
35
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
36
|
+
this.doFetch = opts.fetch ?? globalThis.fetch;
|
|
37
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
38
|
+
this.attempts = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
|
|
39
|
+
this.maxWaitMs = opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
40
|
+
}
|
|
41
|
+
async request(path, init = {}) {
|
|
42
|
+
if (!this.apiKey) {
|
|
43
|
+
// Refused here rather than sent and rejected: a missing key is a
|
|
44
|
+
// configuration mistake, and a 401 from the server describes it worse.
|
|
45
|
+
throw new HarnessError(401, "no_api_key", "No spendgraph API key configured.");
|
|
46
|
+
}
|
|
47
|
+
const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
48
|
+
let last;
|
|
49
|
+
for (let attempt = 1; attempt <= this.attempts; attempt++) {
|
|
50
|
+
let res;
|
|
51
|
+
try {
|
|
52
|
+
res = await this.doFetch(url, {
|
|
53
|
+
...init,
|
|
54
|
+
headers: {
|
|
55
|
+
"x-api-key": this.apiKey,
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
...init.headers,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// A dropped connection is worth another attempt; a bad request is not.
|
|
63
|
+
last = new HarnessError(0, "network_error", err.message, { attempts: attempt });
|
|
64
|
+
if (attempt < this.attempts) {
|
|
65
|
+
await this.sleep(Math.min(this.maxWaitMs, 250 * 2 ** (attempt - 1)));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
throw last;
|
|
69
|
+
}
|
|
70
|
+
if (res.ok)
|
|
71
|
+
return (await res.json());
|
|
72
|
+
last = await this.toError(res, attempt);
|
|
73
|
+
if (!last.retryable || attempt === this.attempts)
|
|
74
|
+
throw last;
|
|
75
|
+
// Honour the server's own number when it sent one; otherwise back off
|
|
76
|
+
// exponentially from 250ms.
|
|
77
|
+
const wait = retryAfterMs(res) ?? 250 * 2 ** (attempt - 1);
|
|
78
|
+
await this.sleep(Math.min(this.maxWaitMs, wait));
|
|
79
|
+
}
|
|
80
|
+
// Unreachable: the loop either returns or throws.
|
|
81
|
+
throw last ?? new HarnessError(0, "unknown", "Request failed.");
|
|
82
|
+
}
|
|
83
|
+
async toError(res, attempt) {
|
|
84
|
+
let code = "http_error";
|
|
85
|
+
let message = `${res.status} ${res.statusText}`.trim();
|
|
86
|
+
try {
|
|
87
|
+
const body = (await res.json());
|
|
88
|
+
if (body?.error?.code)
|
|
89
|
+
code = body.error.code;
|
|
90
|
+
if (body?.error?.message)
|
|
91
|
+
message = body.error.message;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// A non-JSON body (a proxy's HTML error page) is not worth failing over.
|
|
95
|
+
}
|
|
96
|
+
return new HarnessError(res.status, code, message, { attempts: attempt });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One error type for everything the API can refuse, carrying the status.
|
|
3
|
+
*
|
|
4
|
+
* A caller retrying blind is the failure this prevents: 429 is worth waiting
|
|
5
|
+
* out, 401 never will be, and a message alone cannot tell them apart.
|
|
6
|
+
*/
|
|
7
|
+
export declare class HarnessError extends Error {
|
|
8
|
+
readonly status: number;
|
|
9
|
+
/** The API's machine-readable code, when it sent one. */
|
|
10
|
+
readonly code: string;
|
|
11
|
+
/** How many attempts were made, so a log line says "gave up after 4". */
|
|
12
|
+
readonly attempts: number;
|
|
13
|
+
constructor(status: number, code: string, message: string, opts?: {
|
|
14
|
+
attempts?: number;
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Worth trying again: rate limits, server faults, and a connection that never
|
|
18
|
+
* got far enough to have a status. Never a bad request — retrying a 401 or a
|
|
19
|
+
* 422 just spends the same attempt budget on the same answer.
|
|
20
|
+
*/
|
|
21
|
+
get retryable(): boolean;
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One error type for everything the API can refuse, carrying the status.
|
|
3
|
+
*
|
|
4
|
+
* A caller retrying blind is the failure this prevents: 429 is worth waiting
|
|
5
|
+
* out, 401 never will be, and a message alone cannot tell them apart.
|
|
6
|
+
*/
|
|
7
|
+
export class HarnessError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
/** The API's machine-readable code, when it sent one. */
|
|
10
|
+
code;
|
|
11
|
+
/** How many attempts were made, so a log line says "gave up after 4". */
|
|
12
|
+
attempts;
|
|
13
|
+
constructor(status, code, message, opts = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "HarnessError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.attempts = opts.attempts ?? 1;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Worth trying again: rate limits, server faults, and a connection that never
|
|
22
|
+
* got far enough to have a status. Never a bad request — retrying a 401 or a
|
|
23
|
+
* 422 just spends the same attempt budget on the same answer.
|
|
24
|
+
*/
|
|
25
|
+
get retryable() {
|
|
26
|
+
return this.status === 0 || this.status === 429 || this.status >= 500;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { HarnessError } from "./errors.js";
|
|
2
|
+
export { Budget, BudgetExceededError, resetBudgetWarning } from "./budget.js";
|
|
3
|
+
export { Client, type ClientOptions } from "./client.js";
|
|
4
|
+
export { PullCache, type CacheHit, type CacheOptions } from "./cache.js";
|
|
5
|
+
export { pullWithCache } from "./pull.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PullCache } from "./cache.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fetch-by-id with stale-while-revalidate.
|
|
4
|
+
*
|
|
5
|
+
* Generic over the payload because tools and graphs will be pulled the same
|
|
6
|
+
* way: the mechanic is identical, only the type differs.
|
|
7
|
+
*
|
|
8
|
+
* The three paths, and why the middle one exists:
|
|
9
|
+
* fresh → return it, no request
|
|
10
|
+
* stale → return it NOW, refresh behind the caller
|
|
11
|
+
* miss → fetch, and only then answer
|
|
12
|
+
*
|
|
13
|
+
* Serving stale is the difference between a prompt edit taking effect within a
|
|
14
|
+
* TTL and every request unlucky enough to arrive after expiry paying for a round
|
|
15
|
+
* trip. A production request path cannot afford the second.
|
|
16
|
+
*/
|
|
17
|
+
export declare function pullWithCache<T>(cache: PullCache<T>, key: string, fetchOne: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch-by-id with stale-while-revalidate.
|
|
3
|
+
*
|
|
4
|
+
* Generic over the payload because tools and graphs will be pulled the same
|
|
5
|
+
* way: the mechanic is identical, only the type differs.
|
|
6
|
+
*
|
|
7
|
+
* The three paths, and why the middle one exists:
|
|
8
|
+
* fresh → return it, no request
|
|
9
|
+
* stale → return it NOW, refresh behind the caller
|
|
10
|
+
* miss → fetch, and only then answer
|
|
11
|
+
*
|
|
12
|
+
* Serving stale is the difference between a prompt edit taking effect within a
|
|
13
|
+
* TTL and every request unlucky enough to arrive after expiry paying for a round
|
|
14
|
+
* trip. A production request path cannot afford the second.
|
|
15
|
+
*/
|
|
16
|
+
export async function pullWithCache(cache, key, fetchOne) {
|
|
17
|
+
const hit = cache.get(key);
|
|
18
|
+
if (hit && !hit.stale)
|
|
19
|
+
return hit.value;
|
|
20
|
+
if (hit) {
|
|
21
|
+
cache.refreshInBackground(key, fetchOne);
|
|
22
|
+
return hit.value;
|
|
23
|
+
}
|
|
24
|
+
// Guarded like the background refresh, and for the same reason: an
|
|
25
|
+
// invalidation can land while this fetch is in flight, and storing the result
|
|
26
|
+
// afterwards would mark pre-invalidation state fresh for a whole TTL. This is
|
|
27
|
+
// the path a cold isolate takes, so it is the common one, not the rare one.
|
|
28
|
+
const startedAt = cache.beginFetch();
|
|
29
|
+
try {
|
|
30
|
+
const value = await fetchOne();
|
|
31
|
+
cache.settle(key, value, startedAt);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
cache.abandon(key);
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FieldSpec } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validated values as the flat string map the renderer substitutes.
|
|
4
|
+
*
|
|
5
|
+
* Throws rather than rendering something approximate. Headless there is no
|
|
6
|
+
* human reading the reply, so a missing required field would otherwise buy a
|
|
7
|
+
* paid-for response with a hole in it that nothing downstream can detect.
|
|
8
|
+
*
|
|
9
|
+
* An optional field left unset is omitted, so its `{name}` stays visible — the
|
|
10
|
+
* same behaviour the prompt builder has always had.
|
|
11
|
+
*/
|
|
12
|
+
export declare function serializeFields(values: Record<string, unknown>, spec: FieldSpec[]): Record<string, string>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FieldValidationError, isMissing, toBoolean, toNumber, validateFields, } from "./validate.js";
|
|
2
|
+
/** One declared value as the text that replaces its placeholder. */
|
|
3
|
+
function renderOne(field, value) {
|
|
4
|
+
switch (field.type) {
|
|
5
|
+
case "number":
|
|
6
|
+
return String(toNumber(value));
|
|
7
|
+
case "boolean":
|
|
8
|
+
return toBoolean(value) ? (field.trueText ?? "true") : (field.falseText ?? "false");
|
|
9
|
+
case "list":
|
|
10
|
+
return value.map(String).join(field.separator ?? ", ");
|
|
11
|
+
case "json":
|
|
12
|
+
return JSON.stringify(value, null, 2);
|
|
13
|
+
default:
|
|
14
|
+
return String(value);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Validated values as the flat string map the renderer substitutes.
|
|
19
|
+
*
|
|
20
|
+
* Throws rather than rendering something approximate. Headless there is no
|
|
21
|
+
* human reading the reply, so a missing required field would otherwise buy a
|
|
22
|
+
* paid-for response with a hole in it that nothing downstream can detect.
|
|
23
|
+
*
|
|
24
|
+
* An optional field left unset is omitted, so its `{name}` stays visible — the
|
|
25
|
+
* same behaviour the prompt builder has always had.
|
|
26
|
+
*/
|
|
27
|
+
export function serializeFields(values, spec) {
|
|
28
|
+
const errors = validateFields(values, spec);
|
|
29
|
+
if (errors.length)
|
|
30
|
+
throw new FieldValidationError(errors);
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const field of spec) {
|
|
33
|
+
const value = values[field.name];
|
|
34
|
+
if (isMissing(value)) {
|
|
35
|
+
if (field.default !== undefined)
|
|
36
|
+
out[field.name] = field.default;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
out[field.name] = renderOne(field, value);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed prompt fields — what a `{placeholder}` accepts, and how it renders.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately a copy of `lib/fields.ts` rather than an import. This package is
|
|
5
|
+
* published and depends on nothing; reaching into the app for a type would make
|
|
6
|
+
* every installed copy break the next time the app moved a file. The two are
|
|
7
|
+
* kept in step by the wire format, which is the only contract that matters.
|
|
8
|
+
*
|
|
9
|
+
* A field type is two things, and the second is the one that gets forgotten: an
|
|
10
|
+
* input contract, and a rendering rule. Everything ends up interpolated into a
|
|
11
|
+
* string, so `{max_words}` given `50` has to become `"50"` and `{tags}` given
|
|
12
|
+
* `["a","b"]` has to become something the prompt author chose rather than
|
|
13
|
+
* whatever the first implementation happened to do.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not called `Variable`: a tool's arguments and an agent node's
|
|
16
|
+
* inputs are the same problem, and naming this after prompts guarantees it gets
|
|
17
|
+
* written twice more with three different meanings for `required`.
|
|
18
|
+
*/
|
|
19
|
+
export type FieldType = "string" | "text" | "number" | "boolean" | "enum" | "list" | "json";
|
|
20
|
+
export interface FieldSpec {
|
|
21
|
+
/** Matches the `{placeholder}` in the blocks or question. */
|
|
22
|
+
name: string;
|
|
23
|
+
type: FieldType;
|
|
24
|
+
/** A missing required field throws before anything is sent. */
|
|
25
|
+
required: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Substituted verbatim when the field is absent — already-rendered text, not
|
|
28
|
+
* a value to coerce. A `list` default is the joined string, a `json` default
|
|
29
|
+
* is the printed JSON. Authored once by a human rather than typed per run, so
|
|
30
|
+
* asking for it in final form is simpler than parsing it back per type.
|
|
31
|
+
*/
|
|
32
|
+
default?: string;
|
|
33
|
+
/**
|
|
34
|
+
* What the field means. Shown in the UI, and given to the optimizer's
|
|
35
|
+
* reflector — knowing what a field is *for* is the difference between a
|
|
36
|
+
* useful prompt mutation and a guess.
|
|
37
|
+
*/
|
|
38
|
+
description?: string;
|
|
39
|
+
/** `enum` — the permitted values. */
|
|
40
|
+
options?: string[];
|
|
41
|
+
/** `list` — what joins the items. Defaults to ", ". */
|
|
42
|
+
separator?: string;
|
|
43
|
+
/** `number` — inclusive bounds. */
|
|
44
|
+
min?: number;
|
|
45
|
+
max?: number;
|
|
46
|
+
/** `string` | `text` — rejected above this length. */
|
|
47
|
+
maxLength?: number;
|
|
48
|
+
/** `boolean` — words to render instead of "true"/"false". */
|
|
49
|
+
trueText?: string;
|
|
50
|
+
falseText?: string;
|
|
51
|
+
/**
|
|
52
|
+
* This is the field a dataset varies.
|
|
53
|
+
*
|
|
54
|
+
* A prompt declaring `{question}` plus 200 stored values for it *is* a
|
|
55
|
+
* 200-case dataset; this flag is what says which field those values fill.
|
|
56
|
+
*/
|
|
57
|
+
datasetKey?: boolean;
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FieldSpec } from "./types.js";
|
|
2
|
+
export type FieldError = {
|
|
3
|
+
field: string;
|
|
4
|
+
message: string;
|
|
5
|
+
};
|
|
6
|
+
export declare class FieldValidationError extends Error {
|
|
7
|
+
readonly errors: FieldError[];
|
|
8
|
+
constructor(errors: FieldError[]);
|
|
9
|
+
}
|
|
10
|
+
/** Absent means absent. An empty string is a value someone chose to send. */
|
|
11
|
+
export declare function isMissing(value: unknown): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Coercions shared with the renderer.
|
|
14
|
+
*
|
|
15
|
+
* Shared deliberately: if the checker and the renderer disagreed, a value could
|
|
16
|
+
* pass validation and then render as something else — a request you paid for
|
|
17
|
+
* and cannot explain.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toNumber(value: unknown): number;
|
|
20
|
+
export declare function toBoolean(value: unknown): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Everything wrong with these values, rather than the first thing.
|
|
23
|
+
*
|
|
24
|
+
* Values with no matching field are ignored, not reported: a stored prompt keeps
|
|
25
|
+
* values for placeholders that were later edited out, and treating a stale key
|
|
26
|
+
* as an error would fail a prompt that renders perfectly well.
|
|
27
|
+
*/
|
|
28
|
+
export declare function validateFields(values: Record<string, unknown>, spec: FieldSpec[]): FieldError[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export class FieldValidationError extends Error {
|
|
2
|
+
errors;
|
|
3
|
+
constructor(errors) {
|
|
4
|
+
super(errors.map((e) => `${e.field}: ${e.message}`).join("; "));
|
|
5
|
+
this.name = "FieldValidationError";
|
|
6
|
+
this.errors = errors;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Absent means absent. An empty string is a value someone chose to send. */
|
|
10
|
+
export function isMissing(value) {
|
|
11
|
+
return value === undefined || value === null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Coercions shared with the renderer.
|
|
15
|
+
*
|
|
16
|
+
* Shared deliberately: if the checker and the renderer disagreed, a value could
|
|
17
|
+
* pass validation and then render as something else — a request you paid for
|
|
18
|
+
* and cannot explain.
|
|
19
|
+
*/
|
|
20
|
+
export function toNumber(value) {
|
|
21
|
+
return typeof value === "number" ? value : Number(String(value).trim());
|
|
22
|
+
}
|
|
23
|
+
export function toBoolean(value) {
|
|
24
|
+
return typeof value === "boolean" ? value : String(value).toLowerCase() === "true";
|
|
25
|
+
}
|
|
26
|
+
function checkOne(field, value) {
|
|
27
|
+
switch (field.type) {
|
|
28
|
+
case "string":
|
|
29
|
+
case "text": {
|
|
30
|
+
const s = String(value);
|
|
31
|
+
return field.maxLength !== undefined && s.length > field.maxLength
|
|
32
|
+
? `is ${s.length} characters, over the limit of ${field.maxLength}`
|
|
33
|
+
: null;
|
|
34
|
+
}
|
|
35
|
+
case "number": {
|
|
36
|
+
const n = toNumber(value);
|
|
37
|
+
if (!Number.isFinite(n))
|
|
38
|
+
return `expects a number, got ${JSON.stringify(value)}`;
|
|
39
|
+
if (field.min !== undefined && n < field.min)
|
|
40
|
+
return `is ${n}, below the minimum of ${field.min}`;
|
|
41
|
+
if (field.max !== undefined && n > field.max)
|
|
42
|
+
return `is ${n}, above the maximum of ${field.max}`;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
case "boolean": {
|
|
46
|
+
if (typeof value === "boolean")
|
|
47
|
+
return null;
|
|
48
|
+
const s = String(value).toLowerCase();
|
|
49
|
+
return s === "true" || s === "false"
|
|
50
|
+
? null
|
|
51
|
+
: `expects true or false, got ${JSON.stringify(value)}`;
|
|
52
|
+
}
|
|
53
|
+
case "enum": {
|
|
54
|
+
const options = field.options ?? [];
|
|
55
|
+
if (options.length === 0)
|
|
56
|
+
return "is an enum with no options declared";
|
|
57
|
+
return options.includes(String(value))
|
|
58
|
+
? null
|
|
59
|
+
: `must be one of ${options.join(" | ")}, got ${JSON.stringify(value)}`;
|
|
60
|
+
}
|
|
61
|
+
case "list":
|
|
62
|
+
return Array.isArray(value) ? null : `expects an array, got ${typeof value}`;
|
|
63
|
+
case "json":
|
|
64
|
+
try {
|
|
65
|
+
// Catches cycles and BigInt, which otherwise reach the provider as a
|
|
66
|
+
// crash — at which point the request has already been paid for.
|
|
67
|
+
JSON.stringify(value);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return `is not serialisable: ${err.message}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Everything wrong with these values, rather than the first thing.
|
|
77
|
+
*
|
|
78
|
+
* Values with no matching field are ignored, not reported: a stored prompt keeps
|
|
79
|
+
* values for placeholders that were later edited out, and treating a stale key
|
|
80
|
+
* as an error would fail a prompt that renders perfectly well.
|
|
81
|
+
*/
|
|
82
|
+
export function validateFields(values, spec) {
|
|
83
|
+
const errors = [];
|
|
84
|
+
for (const field of spec) {
|
|
85
|
+
const value = values[field.name];
|
|
86
|
+
if (isMissing(value)) {
|
|
87
|
+
if (field.default !== undefined)
|
|
88
|
+
continue;
|
|
89
|
+
if (field.required)
|
|
90
|
+
errors.push({ field: field.name, message: "is required" });
|
|
91
|
+
continue; // optional and unset — the placeholder stays visible
|
|
92
|
+
}
|
|
93
|
+
const problem = checkOne(field, value);
|
|
94
|
+
if (problem)
|
|
95
|
+
errors.push({ field: field.name, message: problem });
|
|
96
|
+
}
|
|
97
|
+
return errors;
|
|
98
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type ClientOptions } from "./core/client.js";
|
|
2
|
+
import { type CacheOptions } from "./core/cache.js";
|
|
3
|
+
import type { DatasetCase, DatasetSummary, Prompt, ReportInput, PromptVersion, RunOptions, RunResult, ValuesFor } from "./prompt/types.js";
|
|
4
|
+
export interface HarnessOptions extends Omit<ClientOptions, "apiKey" | "baseUrl"> {
|
|
5
|
+
/** spendgraph API key (sg_…). Without it, `pull` throws and `report` no-ops. */
|
|
6
|
+
apiKey: string | undefined;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
cache?: CacheOptions;
|
|
9
|
+
/** Max requests in flight for sample() and runAll(). Default 4. */
|
|
10
|
+
concurrency?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Stop spending past this, in micro-dollars. Omit and the client warns once
|
|
13
|
+
* that it is unbounded; pass `null` to say you meant it.
|
|
14
|
+
*/
|
|
15
|
+
maxCostMicros?: number | null;
|
|
16
|
+
/**
|
|
17
|
+
* Called when a `report` is dropped. Reporting never throws, so without this
|
|
18
|
+
* a misconfigured key is invisible until somebody notices the empty table.
|
|
19
|
+
*/
|
|
20
|
+
onReportError?: (err: unknown) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Called when a background prompt refresh fails.
|
|
23
|
+
*
|
|
24
|
+
* The case worth wiring: renaming a prompt mints a new slug, so a service
|
|
25
|
+
* pulling by the old one keeps serving the wording it last saw. That is the
|
|
26
|
+
* safe behaviour, but it should not be a silent one.
|
|
27
|
+
*/
|
|
28
|
+
onPullError?: (err: unknown, handle: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare class Harness {
|
|
31
|
+
private readonly client;
|
|
32
|
+
private readonly cache;
|
|
33
|
+
private readonly hasKey;
|
|
34
|
+
private readonly onReportError?;
|
|
35
|
+
private readonly concurrency;
|
|
36
|
+
private readonly budget;
|
|
37
|
+
constructor(opts: HarnessOptions);
|
|
38
|
+
/**
|
|
39
|
+
* A prompt, from cache when one is warm.
|
|
40
|
+
*
|
|
41
|
+
* Serves a stale copy immediately and refreshes behind the caller, so an edit
|
|
42
|
+
* takes effect within a TTL without any request ever paying for the fetch.
|
|
43
|
+
*
|
|
44
|
+
* Takes an id or a slug — the server resolves either. Cached under whatever
|
|
45
|
+
* you passed, so pulling the same prompt both ways keeps two entries.
|
|
46
|
+
*
|
|
47
|
+
* A slug is only as stable as the prompt's name: renaming it server-side
|
|
48
|
+
* mints a new one and the old stops resolving, at which point the background
|
|
49
|
+
* refresh 404s and this keeps serving the wording it last saw. Pull by id
|
|
50
|
+
* where a handle has to outlive an edit to the name.
|
|
51
|
+
*/
|
|
52
|
+
pull(promptId: string): Promise<Prompt>;
|
|
53
|
+
/**
|
|
54
|
+
* Runs the prompt server-side and records the rollout.
|
|
55
|
+
*
|
|
56
|
+
* The batch path. A production request should pull, render and report
|
|
57
|
+
* instead — this adds a hop and makes spendgraph a dependency of the caller's
|
|
58
|
+
* uptime, which is the right trade for evaluation and the wrong one for a
|
|
59
|
+
* user waiting on a response.
|
|
60
|
+
*/
|
|
61
|
+
run<K extends string>(promptId: K, values?: ValuesFor<K>, opts?: RunOptions): Promise<RunResult>;
|
|
62
|
+
/** k repetitions of one case. Always k results, failures included. */
|
|
63
|
+
sample<K extends string>(promptId: K, values?: ValuesFor<K>, opts?: RunOptions & {
|
|
64
|
+
k?: number;
|
|
65
|
+
concurrency?: number;
|
|
66
|
+
}): Promise<RunResult[]>;
|
|
67
|
+
/** One rollout per model — the playground's comparison, headless. */
|
|
68
|
+
runAll<K extends string>(promptId: K, values?: ValuesFor<K>, opts?: RunOptions & {
|
|
69
|
+
models?: string[];
|
|
70
|
+
concurrency?: number;
|
|
71
|
+
}): Promise<RunResult[]>;
|
|
72
|
+
/** The dataset a prompt is evaluated against, with its split counts. */
|
|
73
|
+
cases(promptId: string): Promise<DatasetSummary>;
|
|
74
|
+
/**
|
|
75
|
+
* Replaces the whole dataset.
|
|
76
|
+
*
|
|
77
|
+
* A dataset is a set: "which cases am I evaluating against" has one answer at
|
|
78
|
+
* a time, and a merge would leave no way to remove a case or to know from the
|
|
79
|
+
* outside what the set currently is.
|
|
80
|
+
*/
|
|
81
|
+
setCases(promptId: string, cases: DatasetCase[]): Promise<Omit<DatasetSummary, "cases">>;
|
|
82
|
+
/** Micro-dollars this client has spent on runs it initiated. */
|
|
83
|
+
spent(): number;
|
|
84
|
+
/** What is left of the ceiling, or Infinity when none was set. */
|
|
85
|
+
remaining(): number;
|
|
86
|
+
/** Every wording this prompt has had, newest first. */
|
|
87
|
+
versions(promptId: string, opts?: {
|
|
88
|
+
origin?: "user" | "assay";
|
|
89
|
+
limit?: number;
|
|
90
|
+
}): Promise<{
|
|
91
|
+
versions: PromptVersion[];
|
|
92
|
+
currentVersionId: string | null;
|
|
93
|
+
}>;
|
|
94
|
+
/**
|
|
95
|
+
* Serves this version from now on.
|
|
96
|
+
*
|
|
97
|
+
* Invalidates the cache: a promotion that left a warm entry in place would
|
|
98
|
+
* keep sending the old wording for up to a TTL, which is the one moment a
|
|
99
|
+
* caller is actively watching for the change.
|
|
100
|
+
*/
|
|
101
|
+
promote(promptId: string, versionId: string): Promise<{
|
|
102
|
+
promoted: string;
|
|
103
|
+
previous?: string | null;
|
|
104
|
+
unchanged?: boolean;
|
|
105
|
+
}>;
|
|
106
|
+
/**
|
|
107
|
+
* Drops whatever is cached, so the next pull refetches.
|
|
108
|
+
*
|
|
109
|
+
* Both handles, not just the one passed. `pull` keys the cache on whatever
|
|
110
|
+
* string it was given, so the same prompt can sit under its uuid and its
|
|
111
|
+
* slug at once — and `promote` is only reachable with a uuid while the
|
|
112
|
+
* dashboard teaches pulling by slug. Dropping one key left the other warm
|
|
113
|
+
* for a full TTL, still rendering the wording the promotion replaced, which
|
|
114
|
+
* is the exact case this method exists to prevent.
|
|
115
|
+
*/
|
|
116
|
+
invalidate(promptId?: string): void;
|
|
117
|
+
/**
|
|
118
|
+
* Records a rollout the caller executed.
|
|
119
|
+
*
|
|
120
|
+
* Never throws and never rejects. This sits beside a user-facing request that
|
|
121
|
+
* has already been answered; telemetry that can break the thing it measures
|
|
122
|
+
* is worse than no telemetry. Failures go to `onReportError` and are dropped.
|
|
123
|
+
*
|
|
124
|
+
* Returns the rollout id it used, so a caller that wants to retry the exact
|
|
125
|
+
* same record can pass it back and land on one row.
|
|
126
|
+
*/
|
|
127
|
+
report(promptId: string, input: ReportInput): Promise<string>;
|
|
128
|
+
}
|