@foldrun/core 2.10.0 → 2.11.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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +4 -1
- package/dist/src/deploy.js +16 -0
- package/dist/src/eval-compare.d.ts +74 -0
- package/dist/src/eval-compare.js +187 -0
- package/dist/src/evals.d.ts +62 -3
- package/dist/src/evals.js +161 -36
- package/dist/src/flow-lint.js +4 -0
- package/dist/src/frontmatter-fields.js +4 -1
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +113 -0
- package/dist/src/gitrepo.js +404 -0
- package/dist/src/grades.d.ts +118 -0
- package/dist/src/grades.js +481 -0
- package/dist/src/judge-verdict.d.ts +1 -0
- package/dist/src/judge-verdict.js +3 -1
- package/dist/src/online-checks.d.ts +4 -0
- package/dist/src/online-checks.js +5 -1
- package/dist/src/openapi.d.ts +4 -0
- package/dist/src/openapi.js +5 -1
- package/dist/src/platform.d.ts +12 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/providers.js +8 -1
- package/dist/src/starter.js +5 -0
- package/dist/src/store.d.ts +8 -0
- package/dist/src/store.js +5 -1
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +112 -0
- package/dist/src/tool-test.js +19 -7
- package/dist/src/translator.d.ts +4 -0
- package/dist/src/translator.js +22 -3
- package/dist/src/trigger-filter.d.ts +28 -0
- package/dist/src/trigger-filter.js +272 -0
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +2 -0
- package/dist/src/triggers.js +13 -2
- package/dist/src/untrusted-fetch.d.ts +16 -0
- package/dist/src/untrusted-fetch.js +60 -2
- package/index.ts +3 -0
- package/package.json +2 -2
- package/src/catalog.ts +5 -1
- package/src/deploy.ts +10 -0
- package/src/eval-compare.ts +243 -0
- package/src/evals.ts +195 -38
- package/src/flow-lint.ts +4 -0
- package/src/frontmatter-fields.ts +4 -1
- package/src/grades.ts +532 -0
- package/src/judge-verdict.ts +4 -2
- package/src/online-checks.ts +5 -1
- package/src/openapi.ts +6 -1
- package/src/platform.ts +16 -4
- package/src/providers.ts +8 -1
- package/src/starter.ts +5 -0
- package/src/store.ts +11 -1
- package/src/tool-test.ts +21 -7
- package/src/translator.ts +24 -3
- package/src/trigger-filter.ts +264 -0
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +14 -2
- package/src/untrusted-fetch.ts +65 -2
package/dist/src/triggers.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare function withTask(steps: FlowStep[], tag: string, body: string):
|
|
|
6
6
|
export declare function endingOf(run: RunRecord): "completed" | "failed" | "blocked";
|
|
7
7
|
/** The flows a finished run should start, by this workspace's own files. */
|
|
8
8
|
export declare function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[];
|
|
9
|
+
/** What a chained flow's `filter:` reads: the run that just settled. */
|
|
10
|
+
export declare function chainFields(finished: RunRecord): Record<string, unknown>;
|
|
9
11
|
/**
|
|
10
12
|
* Start every flow chained on a run that just settled. The finished run's
|
|
11
13
|
* one-line summary and its final result are the task; the run id rides
|
package/dist/src/triggers.js
CHANGED
|
@@ -54,6 +54,17 @@ export function chainedFlows(tenant, workspace, finished) {
|
|
|
54
54
|
f.name !== finished.flow &&
|
|
55
55
|
(f.on === "any" || f.on === endingOf(finished)));
|
|
56
56
|
}
|
|
57
|
+
/** What a chained flow's `filter:` reads: the run that just settled. */
|
|
58
|
+
export function chainFields(finished) {
|
|
59
|
+
return {
|
|
60
|
+
run: finished.id,
|
|
61
|
+
flow: finished.flow,
|
|
62
|
+
status: finished.status,
|
|
63
|
+
ending: endingOf(finished),
|
|
64
|
+
verdict: runVerdict(finished) ?? null,
|
|
65
|
+
summary: finished.summary ?? null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
57
68
|
/**
|
|
58
69
|
* Start every flow chained on a run that just settled. The finished run's
|
|
59
70
|
* one-line summary and its final result are the task; the run id rides
|
|
@@ -73,7 +84,7 @@ export async function fireChainedFlows(tenant, workspace, finished) {
|
|
|
73
84
|
(result ? `\n${result}` : "");
|
|
74
85
|
for (const flow of flows) {
|
|
75
86
|
try {
|
|
76
|
-
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
|
|
87
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run", fields: chainFields(finished) });
|
|
77
88
|
if (!gate.admit) {
|
|
78
89
|
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
79
90
|
continue;
|
|
@@ -138,7 +149,7 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
|
|
|
138
149
|
const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
|
|
139
150
|
// A deploy that writes forty files under one prefix is one event, not
|
|
140
151
|
// forty runs — which is exactly what `debounce:` is for.
|
|
141
|
-
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
|
|
152
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event", fields: { by, paths: hits } });
|
|
142
153
|
if (!gate.admit) {
|
|
143
154
|
console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
144
155
|
continue;
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
2
2
|
* nothing a tenant points the platform at needs more than a couple. */
|
|
3
3
|
export declare const MAX_REDIRECTS = 5;
|
|
4
|
+
/** What one answer may be, read, unless the caller says otherwise. */
|
|
5
|
+
export declare const MAX_BYTES: number;
|
|
6
|
+
export declare class ResponseTooLarge extends Error {
|
|
7
|
+
constructor(url: string, maxBytes: number);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `res`, its body read to at most `maxBytes`. The status and headers stand;
|
|
11
|
+
* the body errors with ResponseTooLarge at the limit — `text()`, `json()`
|
|
12
|
+
* and a reader all reject, never resolve short. A declared length past the
|
|
13
|
+
* limit errors at the first read, before a byte is fetched; an undeclared
|
|
14
|
+
* one is counted as it streams (after decompression, so a small gzip that
|
|
15
|
+
* unpacks huge is caught too). Infinity: no limit.
|
|
16
|
+
*/
|
|
17
|
+
export declare function capBody(res: Response, maxBytes: number, url: string): Response;
|
|
4
18
|
export declare class RedirectRefused extends Error {
|
|
5
19
|
constructor(message: string);
|
|
6
20
|
}
|
|
@@ -8,4 +22,6 @@ export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
|
8
22
|
/** The request carries a credential (in its body or its headers):
|
|
9
23
|
* follow a redirect only within the origin it was sent to. */
|
|
10
24
|
sameOrigin?: boolean;
|
|
25
|
+
/** The most the answer may be, read; MAX_BYTES unless said. */
|
|
26
|
+
maxBytes?: number;
|
|
11
27
|
}): Promise<Response>;
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// the first one was. A hop to another origin keeps only the headers that
|
|
15
15
|
// cannot carry a credential, and a caller whose request IS a credential
|
|
16
16
|
// (a token exchange, a provider key in a header) follows none at all.
|
|
17
|
+
//
|
|
18
|
+
// And every answer is read to a limit (maxBytes, 1 MiB unless the caller
|
|
19
|
+
// says otherwise): a tenant's URL can answer forever, and the worker or web
|
|
20
|
+
// tier holding it all in memory is the platform's problem, not the tenant's.
|
|
21
|
+
// Past the limit the body errors — a caller never gets the first megabyte
|
|
22
|
+
// passed off as the whole answer.
|
|
17
23
|
import { platform } from "./platform.js";
|
|
18
24
|
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
19
25
|
* nothing a tenant points the platform at needs more than a couple. */
|
|
@@ -23,6 +29,57 @@ const REDIRECT = new Set([301, 302, 303, 307, 308]);
|
|
|
23
29
|
* credential. Everything else — Authorization, a cookie, an x-api-key, a
|
|
24
30
|
* webhook's signature — stays with the origin it was meant for. */
|
|
25
31
|
const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
|
|
32
|
+
/** What one answer may be, read, unless the caller says otherwise. */
|
|
33
|
+
export const MAX_BYTES = 1024 * 1024;
|
|
34
|
+
export class ResponseTooLarge extends Error {
|
|
35
|
+
constructor(url, maxBytes) {
|
|
36
|
+
super(`${url} answered more than ${sizeLabel(maxBytes)} — not read`);
|
|
37
|
+
this.name = "ResponseTooLarge";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const sizeLabel = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
|
|
41
|
+
/** Statuses whose Response may carry no body (the constructor throws on one). */
|
|
42
|
+
const NULL_BODY = new Set([101, 204, 205, 304]);
|
|
43
|
+
/**
|
|
44
|
+
* `res`, its body read to at most `maxBytes`. The status and headers stand;
|
|
45
|
+
* the body errors with ResponseTooLarge at the limit — `text()`, `json()`
|
|
46
|
+
* and a reader all reject, never resolve short. A declared length past the
|
|
47
|
+
* limit errors at the first read, before a byte is fetched; an undeclared
|
|
48
|
+
* one is counted as it streams (after decompression, so a small gzip that
|
|
49
|
+
* unpacks huge is caught too). Infinity: no limit.
|
|
50
|
+
*/
|
|
51
|
+
export function capBody(res, maxBytes, url) {
|
|
52
|
+
if (!Number.isFinite(maxBytes) || !res.body || NULL_BODY.has(res.status))
|
|
53
|
+
return res;
|
|
54
|
+
const declared = Number(res.headers.get("content-length") ?? NaN);
|
|
55
|
+
const tooLong = Number.isFinite(declared) && declared > maxBytes;
|
|
56
|
+
const reader = res.body.getReader();
|
|
57
|
+
let seen = 0;
|
|
58
|
+
const body = new ReadableStream({
|
|
59
|
+
async pull(controller) {
|
|
60
|
+
if (tooLong) {
|
|
61
|
+
reader.cancel().catch(() => { });
|
|
62
|
+
return controller.error(new ResponseTooLarge(url, maxBytes));
|
|
63
|
+
}
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done)
|
|
66
|
+
return controller.close();
|
|
67
|
+
seen += value.byteLength;
|
|
68
|
+
if (seen > maxBytes) {
|
|
69
|
+
reader.cancel().catch(() => { });
|
|
70
|
+
return controller.error(new ResponseTooLarge(url, maxBytes));
|
|
71
|
+
}
|
|
72
|
+
controller.enqueue(value);
|
|
73
|
+
},
|
|
74
|
+
cancel(reason) {
|
|
75
|
+
return reader.cancel(reason);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
79
|
+
// Where the answer came from, as the original said.
|
|
80
|
+
Object.defineProperty(out, "url", { value: res.url });
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
26
83
|
export class RedirectRefused extends Error {
|
|
27
84
|
constructor(message) {
|
|
28
85
|
super(message);
|
|
@@ -30,13 +87,14 @@ export class RedirectRefused extends Error {
|
|
|
30
87
|
}
|
|
31
88
|
}
|
|
32
89
|
export async function fetchUntrusted(url, init = {}, opts = {}) {
|
|
90
|
+
const maxBytes = opts.maxBytes ?? MAX_BYTES;
|
|
33
91
|
let current = url;
|
|
34
92
|
let req = { ...init };
|
|
35
93
|
for (let hop = 0;; hop++) {
|
|
36
|
-
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
|
|
94
|
+
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
|
|
37
95
|
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
38
96
|
if (!location)
|
|
39
|
-
return res;
|
|
97
|
+
return capBody(res, maxBytes, current);
|
|
40
98
|
// The body of a redirect is never read; free the socket.
|
|
41
99
|
await res.body?.cancel().catch(() => { });
|
|
42
100
|
if (hop >= MAX_REDIRECTS)
|
package/index.ts
CHANGED
|
@@ -18,7 +18,10 @@ export * from "./src/starter.ts";
|
|
|
18
18
|
export * from "./src/runner.ts";
|
|
19
19
|
export * from "./src/okf.ts";
|
|
20
20
|
export * from "./src/evals.ts";
|
|
21
|
+
export * from "./src/grades.ts";
|
|
22
|
+
export * from "./src/eval-compare.ts";
|
|
21
23
|
export * from "./src/flow-lint.ts";
|
|
24
|
+
export * from "./src/trigger-filter.ts";
|
|
22
25
|
export * from "./src/catalog.ts";
|
|
23
26
|
export * from "./src/model-probe.ts";
|
|
24
27
|
export * from "./src/providers.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
|
57
57
|
"gray-matter": "^4.0.3",
|
|
58
|
-
"zod": "4.
|
|
58
|
+
"zod": "4.6.7"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^26",
|
package/src/catalog.ts
CHANGED
|
@@ -121,6 +121,10 @@ export function readCatalog(baseUrl: string): Catalog | null {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** The most a gateway's model list may be, read: OpenRouter's is about
|
|
125
|
+
* 800 KB and grows, near fetchUntrusted's 1 MiB default. */
|
|
126
|
+
export const CATALOG_MAX_BYTES = 8 * 1024 * 1024;
|
|
127
|
+
|
|
124
128
|
/**
|
|
125
129
|
* The cache, refreshed over the network when it is older than the TTL.
|
|
126
130
|
* A failed fetch keeps the stale copy — offline is a reason to be out of
|
|
@@ -134,7 +138,7 @@ export async function loadCatalog(baseUrl: string): Promise<Catalog | null> {
|
|
|
134
138
|
try {
|
|
135
139
|
const res = await fetchUntrusted(`${trimSlashes(baseUrl)}/v1/models`, {
|
|
136
140
|
signal: AbortSignal.timeout(10_000),
|
|
137
|
-
});
|
|
141
|
+
}, { maxBytes: CATALOG_MAX_BYTES });
|
|
138
142
|
if (!res.ok) throw new Error(`${res.status}`);
|
|
139
143
|
const models = parseModels(await res.json());
|
|
140
144
|
if (!models.length) throw new Error("empty model list");
|
package/src/deploy.ts
CHANGED
|
@@ -636,6 +636,16 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
636
636
|
}
|
|
637
637
|
}
|
|
638
638
|
|
|
639
|
+
// An eval whose settings cannot be read: a `sample:` past 100, a grader
|
|
640
|
+
// (`trigger: runs`) with a task nothing receives or a file:/run: check
|
|
641
|
+
// that would act on the platform's host. Refused where they are written.
|
|
642
|
+
for (const f of files) {
|
|
643
|
+
if (!/^evals\/[^/]+\.md$/.test(f.path)) continue;
|
|
644
|
+
let info;
|
|
645
|
+
try { info = parseEval(f.path, f.content); } catch { continue; }
|
|
646
|
+
for (const p of info.problems) at(f.path, p);
|
|
647
|
+
}
|
|
648
|
+
|
|
639
649
|
// OKF conformance, which needs the files as a tree. Staged in a temp
|
|
640
650
|
// directory and thrown away — nothing touches the live workspace until every
|
|
641
651
|
// check above has passed.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Model comparisons: one eval's cases, run under two to four model settings,
|
|
2
|
+
// side by side (feature 2, 24 Oct 2026).
|
|
3
|
+
//
|
|
4
|
+
// "Could this step run on the fast model?" was a guess: switch it, watch a
|
|
5
|
+
// week of desks, switch it back. A comparison answers it from the cases you
|
|
6
|
+
// already have — each configuration runs every case, `repeat` times, and the
|
|
7
|
+
// result says per configuration how many passed, what it cost (the runs and
|
|
8
|
+
// their judges) and how long a run took, and per case where they differ.
|
|
9
|
+
//
|
|
10
|
+
// The rules, settled in a Codex debate (24 Oct 2026):
|
|
11
|
+
// · The model is forced on the steps (every step of a flow eval, or only
|
|
12
|
+
// `agent`'s), so it wins over the agent's own `model:` and the flow's.
|
|
13
|
+
// · Every run is a test run, whatever the eval or the flow says about
|
|
14
|
+
// `live:` — a comparison is never the reason something was sent.
|
|
15
|
+
// · The judge is the eval's own, the same for every configuration: what
|
|
16
|
+
// changes is the subject, never the grader.
|
|
17
|
+
// · Bounded twice: at most MAX_TRIALS case runs, and `maxUsd` — once the
|
|
18
|
+
// runs and judges have spent it, the rest are recorded as not run.
|
|
19
|
+
// · A run's time is from a worker picking it up to its end; queueing is
|
|
20
|
+
// left out, so a busy account does not read as a slow model.
|
|
21
|
+
// · The repeats interleave (each configuration once, then again), so a
|
|
22
|
+
// provider that slows down mid-way slows every configuration alike.
|
|
23
|
+
// · Graders and saved inputs are not comparable: they have no cases to run.
|
|
24
|
+
// · Nothing here writes the eval's latest result or its history.
|
|
25
|
+
|
|
26
|
+
import { runEval, type EvalInfo, type EvalResult } from "./evals.ts";
|
|
27
|
+
import { resolveEffort, type Effort } from "./store.ts";
|
|
28
|
+
import { loadFlow } from "./runner.ts";
|
|
29
|
+
|
|
30
|
+
export const MAX_CONFIGS = 4;
|
|
31
|
+
export const MAX_REPEAT = 5;
|
|
32
|
+
export const MAX_TRIALS = 60;
|
|
33
|
+
export const DEFAULT_MAX_USD = 5;
|
|
34
|
+
export const MAX_MAX_USD = 100;
|
|
35
|
+
|
|
36
|
+
export interface CompareConfig {
|
|
37
|
+
/** A tier (fast, default, max) or a model id. */
|
|
38
|
+
model: string;
|
|
39
|
+
effort?: Effort | null;
|
|
40
|
+
/** Flow evals: only this agent's steps take the model. */
|
|
41
|
+
agent?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ComparePlan {
|
|
45
|
+
configs: CompareConfig[];
|
|
46
|
+
repeat: number;
|
|
47
|
+
maxUsd: number;
|
|
48
|
+
/** cases × configs × repeat. */
|
|
49
|
+
trials: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CompareCaseRow {
|
|
53
|
+
case: string;
|
|
54
|
+
/** Per configuration, in order: passed of trials. */
|
|
55
|
+
passed: number[];
|
|
56
|
+
trials: number[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CompareConfigResult extends CompareConfig {
|
|
60
|
+
label: string;
|
|
61
|
+
trials: number;
|
|
62
|
+
passed: number;
|
|
63
|
+
/** Trials that were not run (the cap) or could not run (an error before any check). */
|
|
64
|
+
notRun: number;
|
|
65
|
+
/** passed / decided trials; null when none ran. */
|
|
66
|
+
rate: number | null;
|
|
67
|
+
/** The runs and their judges, USD. */
|
|
68
|
+
costUsd: number;
|
|
69
|
+
judgeCostUsd: number;
|
|
70
|
+
/** Per case run that finished, the mean, USD and seconds. */
|
|
71
|
+
avgCostUsd: number | null;
|
|
72
|
+
avgSecs: number | null;
|
|
73
|
+
/** Every run this configuration started, for opening one. */
|
|
74
|
+
runIds: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface EvalComparison {
|
|
78
|
+
eval: string;
|
|
79
|
+
target: { flow: string | null; agent: string | null };
|
|
80
|
+
judge: { model: string; effort: Effort | null };
|
|
81
|
+
startedAt: string;
|
|
82
|
+
finishedAt: string;
|
|
83
|
+
repeat: number;
|
|
84
|
+
maxUsd: number;
|
|
85
|
+
spentUsd: number;
|
|
86
|
+
/** Why it stopped early, when it did. */
|
|
87
|
+
stopped: string | null;
|
|
88
|
+
configs: CompareConfigResult[];
|
|
89
|
+
cases: CompareCaseRow[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function refuse(message: string, status: number): Error {
|
|
93
|
+
return Object.assign(new Error(message), { status });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MODEL = /^[A-Za-z0-9][\w.:/-]{0,99}$/;
|
|
97
|
+
|
|
98
|
+
export const configLabel = (c: CompareConfig) =>
|
|
99
|
+
`${c.model}${c.effort ? ` · ${c.effort}` : ""}${c.agent ? ` · ${c.agent} only` : ""}`;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read and bound a request. Throws with a status: 400 for a shape, 413 when
|
|
103
|
+
* it is too big to run. The eval must have cases and not be a grader.
|
|
104
|
+
*/
|
|
105
|
+
export function planComparison(info: EvalInfo, body: unknown): ComparePlan {
|
|
106
|
+
if (info.trigger === "runs") throw refuse(`${info.name} grades real runs — it has no cases to compare models on`, 400);
|
|
107
|
+
if (info.inputs) throw refuse(`${info.name} is a file of saved inputs, not an eval`, 400);
|
|
108
|
+
if (info.cases.length === 0) throw refuse(`${info.name} has no cases`, 400);
|
|
109
|
+
// A comparison promises test runs only; a run: check is a command on the
|
|
110
|
+
// platform's host, run again for every case of every setting and repeat.
|
|
111
|
+
const runs = info.cases.find((c) => c.expect.some((a) => a.type === "run"));
|
|
112
|
+
if (runs) throw refuse(`${info.name} has a run: check (case "${runs.name}") — a comparison repeats every check many times and run: executes a command, so it cannot be compared; use contains, matches or judge`, 400);
|
|
113
|
+
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
|
|
114
|
+
if (!Array.isArray(b.configs) || b.configs.length < 2) throw refuse("expected { configs: [{ model, effort?, agent? }, …] } — two to four settings to compare", 400);
|
|
115
|
+
if (b.configs.length > MAX_CONFIGS) throw refuse(`at most ${MAX_CONFIGS} settings in one comparison`, 400);
|
|
116
|
+
const configs: CompareConfig[] = b.configs.map((raw, i) => {
|
|
117
|
+
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
|
118
|
+
if (typeof r.model !== "string" || !MODEL.test(r.model.trim())) throw refuse(`configs[${i}].model is a tier (fast, default, max) or a model id`, 400);
|
|
119
|
+
let effort: Effort | null = null;
|
|
120
|
+
if (r.effort !== undefined && r.effort !== null && r.effort !== "") {
|
|
121
|
+
effort = resolveEffort(r.effort);
|
|
122
|
+
if (!effort) throw refuse(`configs[${i}].effort is low, medium, high, xhigh or max`, 400);
|
|
123
|
+
}
|
|
124
|
+
let agent: string | null = null;
|
|
125
|
+
if (r.agent !== undefined && r.agent !== null && r.agent !== "") {
|
|
126
|
+
if (!info.flow) throw refuse(`configs[${i}].agent picks one agent's steps of a flow eval — ${info.name} tests agent ${info.agent} already`, 400);
|
|
127
|
+
if (typeof r.agent !== "string" || !/^[a-z0-9][a-z0-9-]{0,60}$/.test(r.agent)) throw refuse(`configs[${i}].agent is an agent's name`, 400);
|
|
128
|
+
agent = r.agent;
|
|
129
|
+
}
|
|
130
|
+
return { model: r.model.trim(), effort, agent };
|
|
131
|
+
});
|
|
132
|
+
const keys = configs.map(configLabel);
|
|
133
|
+
if (new Set(keys).size !== keys.length) throw refuse("two settings are the same — each one should differ", 400);
|
|
134
|
+
const repeat = b.repeat === undefined ? 1 : Number(b.repeat);
|
|
135
|
+
if (!Number.isInteger(repeat) || repeat < 1 || repeat > MAX_REPEAT) throw refuse(`repeat is a whole number from 1 to ${MAX_REPEAT}`, 400);
|
|
136
|
+
const maxUsd = b.maxUsd === undefined ? DEFAULT_MAX_USD : Number(b.maxUsd);
|
|
137
|
+
if (!Number.isFinite(maxUsd) || maxUsd <= 0 || maxUsd > MAX_MAX_USD) throw refuse(`maxUsd is a spend limit in USD, above 0 and at most ${MAX_MAX_USD}`, 400);
|
|
138
|
+
const trials = info.cases.length * configs.length * repeat;
|
|
139
|
+
if (trials > MAX_TRIALS) {
|
|
140
|
+
throw refuse(`${info.cases.length} cases × ${configs.length} settings × ${repeat} = ${trials} runs — at most ${MAX_TRIALS}; fewer settings or repeats`, 413);
|
|
141
|
+
}
|
|
142
|
+
return { configs, repeat, maxUsd, trials };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run the comparison. `onProgress` hears each finished pass of one
|
|
147
|
+
* configuration (done of total case runs). `run` is the eval runner —
|
|
148
|
+
* injectable, so a test needs no model.
|
|
149
|
+
*/
|
|
150
|
+
/** A flow eval's `agent` must have steps in the flow, or the comparison
|
|
151
|
+
* would run the original models under the requested labels. */
|
|
152
|
+
function assertAgentsInFlow(tenant: string, workspace: string, info: EvalInfo, plan: ComparePlan) {
|
|
153
|
+
const named = plan.configs.map((c) => c.agent).filter((a): a is string => !!a);
|
|
154
|
+
if (!info.flow || !named.length) return;
|
|
155
|
+
const flow = loadFlow(tenant, workspace, info.flow);
|
|
156
|
+
const agents = new Set((flow?.steps ?? []).map((s) => s.agent));
|
|
157
|
+
const missing = named.find((a) => !agents.has(a));
|
|
158
|
+
if (missing) throw refuse(`flow ${info.flow} has no step by ${missing} — pick an agent it runs`, 400);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function compareEval(
|
|
162
|
+
tenant: string,
|
|
163
|
+
workspace: string,
|
|
164
|
+
info: EvalInfo,
|
|
165
|
+
plan: ComparePlan,
|
|
166
|
+
opts: { onProgress?: (done: number, total: number) => void | Promise<void>; run?: typeof runEval } = {},
|
|
167
|
+
): Promise<EvalComparison> {
|
|
168
|
+
if (!opts.run) assertAgentsInFlow(tenant, workspace, info, plan);
|
|
169
|
+
const run = opts.run ?? runEval;
|
|
170
|
+
const startedAt = new Date().toISOString();
|
|
171
|
+
const results: EvalResult[][] = plan.configs.map(() => []);
|
|
172
|
+
let spent = 0;
|
|
173
|
+
let stopped: string | null = null;
|
|
174
|
+
let done = 0;
|
|
175
|
+
for (let r = 0; r < plan.repeat; r++) {
|
|
176
|
+
for (let i = 0; i < plan.configs.length; i++) {
|
|
177
|
+
const cfg = plan.configs[i];
|
|
178
|
+
const result = await run(tenant, workspace, info, {
|
|
179
|
+
model: cfg.model,
|
|
180
|
+
effort: cfg.effort ?? null,
|
|
181
|
+
agent: cfg.agent ?? null,
|
|
182
|
+
forceTest: true,
|
|
183
|
+
beforeCase: () => {
|
|
184
|
+
if (spent >= plan.maxUsd) {
|
|
185
|
+
stopped ??= `the spend limit of $${plan.maxUsd.toFixed(2)} was reached`;
|
|
186
|
+
return stopped;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
},
|
|
190
|
+
// Counted as each case ends, so the limit holds inside a setting too.
|
|
191
|
+
afterCase: (c) => {
|
|
192
|
+
spent += c.costUsd + (c.judgeCostUsd ?? 0);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
results[i].push(result);
|
|
196
|
+
done += info.cases.length;
|
|
197
|
+
await opts.onProgress?.(done, plan.trials);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const round = (n: number, d = 4) => Math.round(n * 10 ** d) / 10 ** d;
|
|
202
|
+
const configs: CompareConfigResult[] = plan.configs.map((cfg, i) => {
|
|
203
|
+
const cases = results[i].flatMap((r) => r.cases);
|
|
204
|
+
const ran = cases.filter((c) => c.runId);
|
|
205
|
+
const notRun = cases.filter((c) => !c.passed && c.error && !c.assertions.length).length;
|
|
206
|
+
const passed = cases.filter((c) => c.passed).length;
|
|
207
|
+
const decided = cases.length - notRun;
|
|
208
|
+
const cost = cases.reduce((n, c) => n + c.costUsd, 0);
|
|
209
|
+
const judge = cases.reduce((n, c) => n + (c.judgeCostUsd ?? 0), 0);
|
|
210
|
+
const timed = ran.filter((c) => typeof c.secs === "number");
|
|
211
|
+
return {
|
|
212
|
+
...cfg,
|
|
213
|
+
label: configLabel(cfg),
|
|
214
|
+
trials: cases.length,
|
|
215
|
+
passed,
|
|
216
|
+
notRun,
|
|
217
|
+
rate: decided ? round(passed / decided, 3) : null,
|
|
218
|
+
costUsd: round(cost + judge),
|
|
219
|
+
judgeCostUsd: round(judge),
|
|
220
|
+
avgCostUsd: ran.length ? round((cost + judge) / ran.length) : null,
|
|
221
|
+
avgSecs: timed.length ? Math.round((timed.reduce((n, c) => n + (c.secs ?? 0), 0) / timed.length) * 10) / 10 : null,
|
|
222
|
+
runIds: ran.map((c) => c.runId!),
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
const cases: CompareCaseRow[] = info.cases.map((c) => ({
|
|
226
|
+
case: c.name,
|
|
227
|
+
passed: results.map((rs) => rs.flatMap((r) => r.cases).filter((x) => x.name === c.name && x.passed).length),
|
|
228
|
+
trials: results.map((rs) => rs.flatMap((r) => r.cases).filter((x) => x.name === c.name).length),
|
|
229
|
+
}));
|
|
230
|
+
return {
|
|
231
|
+
eval: info.name,
|
|
232
|
+
target: { flow: info.flow, agent: info.agent },
|
|
233
|
+
judge: { model: info.model, effort: info.effort },
|
|
234
|
+
startedAt,
|
|
235
|
+
finishedAt: new Date().toISOString(),
|
|
236
|
+
repeat: plan.repeat,
|
|
237
|
+
maxUsd: plan.maxUsd,
|
|
238
|
+
spentUsd: round(spent),
|
|
239
|
+
stopped,
|
|
240
|
+
configs,
|
|
241
|
+
cases,
|
|
242
|
+
};
|
|
243
|
+
}
|