openmerit 0.3.10 → 0.4.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 +74 -22
- package/data/eci-estimates.csv +179 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +180 -0
- package/dist/connectors/langfuse/client.d.ts +42 -0
- package/dist/connectors/langfuse/client.js +141 -0
- package/dist/connectors/langfuse/index.d.ts +163 -0
- package/dist/connectors/langfuse/index.js +378 -0
- package/dist/connectors/langfuse/otlp.d.ts +60 -0
- package/dist/connectors/langfuse/otlp.js +40 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/pi/extension.d.ts +13 -0
- package/dist/pi/extension.js +148 -0
- package/dist/pi/harness.d.ts +20 -0
- package/dist/pi/harness.js +49 -0
- package/dist/pi/instructions.d.ts +2 -0
- package/dist/pi/instructions.js +12 -0
- package/dist/scout/candidates.d.ts +62 -0
- package/dist/scout/candidates.js +90 -0
- package/dist/scout/eci.d.ts +42 -0
- package/dist/scout/eci.js +84 -0
- package/dist/scout/evaluators/module.d.ts +24 -0
- package/dist/scout/evaluators/module.js +19 -0
- package/dist/scout/evaluators/pi.d.ts +53 -0
- package/dist/scout/evaluators/pi.js +117 -0
- package/dist/scout/pipeline.d.ts +111 -0
- package/dist/scout/pipeline.js +157 -0
- package/dist/scout/report.d.ts +3 -0
- package/dist/scout/report.js +42 -0
- package/dist/scout/search.d.ts +110 -0
- package/dist/scout/search.js +205 -0
- package/dist/scout/service.d.ts +148 -0
- package/dist/scout/service.js +143 -0
- package/examples/health-declaration-codes/dataset.jsonl +14 -0
- package/examples/sahra-enrollment/evaluator.ts +151 -0
- package/package.json +34 -78
- package/CHANGELOG.md +0 -250
- package/dist/pi/scout-extension.mjs +0 -287
- package/dist/pi/src/index.d.ts +0 -6
- package/dist/pi/src/index.js +0 -56
- package/dist/terminal/scout-update.json +0 -1
- package/dist/terminal/src/browser.d.mts +0 -9
- package/dist/terminal/src/browser.mjs +0 -28
- package/dist/terminal/src/cli.mjs +0 -28
- package/dist/terminal/src/connections.js +0 -151
- package/dist/terminal/src/install.d.mts +0 -2
- package/dist/terminal/src/install.mjs +0 -524
- package/dist/terminal/src/scout-claude.js +0 -25
- package/dist/terminal/src/scout-credentials.js +0 -41
- package/dist/terminal/src/scout-instructions.js +0 -213
- package/dist/terminal/src/scout-pi.js +0 -84
- package/dist/terminal/src/scout.js +0 -229
- package/dist/terminal/src/telemetry.d.mts +0 -30
- package/dist/terminal/src/telemetry.mjs +0 -138
- package/dist/terminal/src/updates.js +0 -397
- package/docs/agent-evaluations.md +0 -15
- package/docs/commands.md +0 -9
- package/docs/getting-started.md +0 -37
- package/docs/index.md +0 -25
- package/docs/inference-terminal.md +0 -25
- package/docs/pi-extension.md +0 -58
- package/docs/roadmap.md +0 -16
- package/docs/scout-close-loop.md +0 -359
- package/docs/scout-data-policy.md +0 -87
- package/docs/scout.md +0 -190
- package/docs/security.md +0 -107
- package/docs/testing.md +0 -114
- package/docs/troubleshooting.md +0 -50
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by an evaluator when this one candidate cannot run, such as a provider rejecting the model
|
|
4
|
+
* (400/404) or the balance not covering its request (402). The candidate is skipped and listed as
|
|
5
|
+
* untested; the search goes on without it. Any other throw stops the search.
|
|
6
|
+
*/
|
|
7
|
+
export class CandidateUnavailable extends Error {
|
|
8
|
+
name = "CandidateUnavailable";
|
|
9
|
+
}
|
|
10
|
+
/** By name, so an evaluator module can throw its own `CandidateUnavailable` without importing OpenMerit. */
|
|
11
|
+
const unavailableError = (error) => error instanceof Error && error.name === "CandidateUnavailable";
|
|
12
|
+
export const SAMPLE_SIZE = 5;
|
|
13
|
+
export const FINALISTS = 5;
|
|
14
|
+
export async function scoutSearch(input) {
|
|
15
|
+
const sampleSize = input.sampleSize ?? SAMPLE_SIZE;
|
|
16
|
+
const finalistCount = input.finalists ?? FINALISTS;
|
|
17
|
+
if (input.dataset.length < sampleSize)
|
|
18
|
+
throw new Error(`Scout needs an eval dataset with at least ${sampleSize} labelled items; it has ${input.dataset.length}`);
|
|
19
|
+
if (new Set(input.dataset.map(item => item.id)).size !== input.dataset.length)
|
|
20
|
+
throw new Error("Dataset item IDs must be unique");
|
|
21
|
+
if (!input.candidates.length)
|
|
22
|
+
throw new Error("No ECI-ranked candidate models are runnable with the connected providers");
|
|
23
|
+
if (new Set(input.candidates.map(item => item.model)).size !== input.candidates.length)
|
|
24
|
+
throw new Error("Candidate models must be unique");
|
|
25
|
+
const ordered = [...input.candidates].sort((a, b) => a.eci - b.eci || a.model.localeCompare(b.model));
|
|
26
|
+
const pool = [...input.dataset];
|
|
27
|
+
const samples = [];
|
|
28
|
+
const pick = input.pickIndex ?? randomInt;
|
|
29
|
+
for (let count = 0; count < sampleSize; count++) {
|
|
30
|
+
const index = pick(pool.length);
|
|
31
|
+
if (!Number.isInteger(index) || index < 0 || index >= pool.length)
|
|
32
|
+
throw new Error("Invalid random sample index");
|
|
33
|
+
samples.push(pool.splice(index, 1)[0]);
|
|
34
|
+
}
|
|
35
|
+
const repeats = input.repeats ?? 3;
|
|
36
|
+
if (!Number.isInteger(repeats) || repeats < 1)
|
|
37
|
+
throw new Error("repeats must be a positive integer");
|
|
38
|
+
const log = input.log ?? (() => { });
|
|
39
|
+
const trials = [];
|
|
40
|
+
const finals = [];
|
|
41
|
+
const unavailable = [];
|
|
42
|
+
let selected = null;
|
|
43
|
+
const result = (status, error) => ({
|
|
44
|
+
status, ...(error ? { error } : {}),
|
|
45
|
+
criterion: `${sampleSize}/${sampleSize} sampled items pass`, sampleIds: samples.map(item => item.id), ordered, selected, trials, finals, unavailable,
|
|
46
|
+
...(status === "selected" && finals.length < finalistCount ? { shortfall: `Only ${finals.length} finalists: no further model passed the screen within the neighbour rule (once a weaker neighbour fails, only stronger models are tried)` } : {}),
|
|
47
|
+
finalRule: `Finalists are the search pick and the nearest models by ECI that also pass the same ${sampleSize} samples (${finalistCount} in all; once a weaker neighbour fails, only stronger ones are tried), plus up to ${input.challengers ?? 3} untested challengers with a higher ECI and no higher list price than the leading finalist that pass the samples. Each runs every dataset item ${repeats} times; the winner has the most passes over all runs in Langfuse, then the lower measured cost per run, then the lower ECI.`,
|
|
48
|
+
limitation: `Binary search assumes task quality rises with ECI; models it skips are untested. Screens run each sample once, so a model can be screened out by one unlucky run. ${repeats} runs per final item narrow chance differences but do not remove them, and the result is only as representative as the eval dataset.`,
|
|
49
|
+
});
|
|
50
|
+
const run = async (stage, candidate, items) => {
|
|
51
|
+
const runs = stage === "final" ? repeats : 1;
|
|
52
|
+
// Repeat-major order, so an interrupted final has whole repeats rather than one item run many times.
|
|
53
|
+
const tasks = Array.from({ length: runs }, (_, r) => items.map(item => ({ item, repeat: r + 1 }))).flat();
|
|
54
|
+
const outcomes = new Array(tasks.length);
|
|
55
|
+
let next = 0;
|
|
56
|
+
const worker = async () => {
|
|
57
|
+
while (next < tasks.length) {
|
|
58
|
+
const index = next++;
|
|
59
|
+
const { item, repeat } = tasks[index];
|
|
60
|
+
const startMs = Date.now();
|
|
61
|
+
const outcome = await input.evaluate(candidate, item, { stage, ...(input.signal ? { signal: input.signal } : {}) });
|
|
62
|
+
if (typeof outcome?.pass !== "boolean")
|
|
63
|
+
throw new Error("Evaluator must return { pass: boolean } or throw on infrastructure failure");
|
|
64
|
+
outcomes[index] = { startMs, endMs: Date.now(), latencyMs: Date.now() - startMs, ...outcome, itemId: item.id, ...(runs > 1 ? { repeat } : {}) };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
await Promise.all(Array.from({ length: Math.min(input.concurrency ?? 5, tasks.length) }, worker));
|
|
68
|
+
const passes = outcomes.filter(item => item.pass).length;
|
|
69
|
+
const trial = { stage, candidate, outcomes, passes, runs, accepted: stage === "final" || passes === tasks.length };
|
|
70
|
+
log(`${stage.padEnd(9)} ${candidate.model} (ECI ${candidate.eci.toFixed(1)}): ${passes}/${tasks.length}${runs > 1 ? ` over ${runs} runs of ${items.length} items` : ""}${stage === "final" ? "" : trial.accepted ? " pass" : " fail"}`);
|
|
71
|
+
await input.onTrial?.(trial);
|
|
72
|
+
return trial;
|
|
73
|
+
};
|
|
74
|
+
const failure = (stage, candidate, error) => result("stopped", `${stage} ${candidate.model}: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
const skip = (stage, candidate, error) => {
|
|
76
|
+
unavailable.push({ model: candidate.model, stage, reason: error.message });
|
|
77
|
+
log(`${"skipped".padEnd(9)} ${candidate.model}: ${error.message.slice(0, 200)}`);
|
|
78
|
+
};
|
|
79
|
+
let lower = 0, upper = ordered.length - 1;
|
|
80
|
+
while (lower <= upper) {
|
|
81
|
+
const middle = Math.floor((lower + upper) / 2);
|
|
82
|
+
const candidate = ordered[middle];
|
|
83
|
+
try {
|
|
84
|
+
const trial = await run("search", candidate, samples);
|
|
85
|
+
trials.push(trial);
|
|
86
|
+
if (trial.accepted) {
|
|
87
|
+
selected = candidate;
|
|
88
|
+
upper = middle - 1;
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
lower = middle + 1;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!unavailableError(error))
|
|
95
|
+
return failure("search", candidate, error);
|
|
96
|
+
// Untested, not failed: drop it from the ranking and search the same range without it.
|
|
97
|
+
skip("search", candidate, error);
|
|
98
|
+
ordered.splice(middle, 1);
|
|
99
|
+
upper -= 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!selected)
|
|
103
|
+
return result("no_passing_model");
|
|
104
|
+
const screened = new Map(trials.map(trial => [trial.candidate.model, trial.accepted]));
|
|
105
|
+
const finalists = [selected];
|
|
106
|
+
const pickAt = ordered.indexOf(selected);
|
|
107
|
+
const skipped = new Set();
|
|
108
|
+
let lookBelow = true;
|
|
109
|
+
for (const candidate of outward(ordered, selected)) {
|
|
110
|
+
if (finalists.length === finalistCount)
|
|
111
|
+
break;
|
|
112
|
+
const below = ordered.indexOf(candidate) < pickAt;
|
|
113
|
+
if (below && !lookBelow)
|
|
114
|
+
continue;
|
|
115
|
+
if (skipped.has(candidate.model))
|
|
116
|
+
continue;
|
|
117
|
+
let accepted = screened.get(candidate.model);
|
|
118
|
+
if (accepted === undefined) {
|
|
119
|
+
try {
|
|
120
|
+
const trial = await run("neighbour", candidate, samples);
|
|
121
|
+
trials.push(trial);
|
|
122
|
+
accepted = trial.accepted;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!unavailableError(error))
|
|
126
|
+
return failure("screen", candidate, error);
|
|
127
|
+
skip("neighbour", candidate, error);
|
|
128
|
+
skipped.add(candidate.model);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (accepted)
|
|
133
|
+
finalists.push(candidate);
|
|
134
|
+
else if (below)
|
|
135
|
+
lookBelow = false;
|
|
136
|
+
}
|
|
137
|
+
for (const candidate of finalists) {
|
|
138
|
+
try {
|
|
139
|
+
finals.push(await run("final", candidate, input.dataset));
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!unavailableError(error))
|
|
143
|
+
return failure("final", candidate, error);
|
|
144
|
+
skip("final", candidate, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finals.sort(compareFinals);
|
|
148
|
+
// Challengers: a stronger model at the same or a lower list price than the leader could be the
|
|
149
|
+
// better buy, but the neighbour rule never reaches it. Screen the cheapest few on the same samples.
|
|
150
|
+
const leader = finals[0]?.candidate;
|
|
151
|
+
const tried = new Set([...trials.map(trial => trial.candidate.model), ...unavailable.map(item => item.model)]);
|
|
152
|
+
const challengers = leader?.price == null ? [] : ordered
|
|
153
|
+
.filter(item => item.eci > leader.eci && item.price !== null && item.price <= leader.price && !tried.has(item.model))
|
|
154
|
+
.sort((a, b) => a.price - b.price || b.eci - a.eci)
|
|
155
|
+
.slice(0, input.challengers ?? 3);
|
|
156
|
+
for (const candidate of challengers) {
|
|
157
|
+
try {
|
|
158
|
+
const trial = await run("challenger", candidate, samples);
|
|
159
|
+
trials.push(trial);
|
|
160
|
+
if (trial.accepted)
|
|
161
|
+
finals.push(await run("final", candidate, input.dataset));
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (!unavailableError(error))
|
|
165
|
+
return failure("challenger", candidate, error);
|
|
166
|
+
skip("challenger", candidate, error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finals.sort(compareFinals);
|
|
170
|
+
return result("selected");
|
|
171
|
+
}
|
|
172
|
+
/** Models other than the pick, nearest by rank first; at equal distance the weaker one comes first. */
|
|
173
|
+
export function outward(ordered, pick) {
|
|
174
|
+
const index = ordered.findIndex(item => item.model === pick.model);
|
|
175
|
+
if (index < 0)
|
|
176
|
+
throw new Error("The pick is not in the ranking");
|
|
177
|
+
const out = [];
|
|
178
|
+
for (let distance = 1; out.length < ordered.length - 1; distance++) {
|
|
179
|
+
if (ordered[index - distance])
|
|
180
|
+
out.push(ordered[index - distance]);
|
|
181
|
+
if (ordered[index + distance])
|
|
182
|
+
out.push(ordered[index + distance]);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** Mean measured cost per item, or null when any item's cost is unknown. */
|
|
187
|
+
export function meanCost(outcomes) {
|
|
188
|
+
if (!outcomes.length || outcomes.some(item => typeof item.costUsd !== "number"))
|
|
189
|
+
return null;
|
|
190
|
+
return outcomes.reduce((sum, item) => sum + item.costUsd, 0) / outcomes.length;
|
|
191
|
+
}
|
|
192
|
+
/** Most passes; then lower measured cost per item (list price only when a cost is unknown); then lower ECI. */
|
|
193
|
+
export function compareFinals(a, b) {
|
|
194
|
+
if (b.passes !== a.passes)
|
|
195
|
+
return b.passes - a.passes;
|
|
196
|
+
const ca = meanCost(a.outcomes), cb = meanCost(b.outcomes);
|
|
197
|
+
if (ca !== null && cb !== null && ca !== cb)
|
|
198
|
+
return ca - cb;
|
|
199
|
+
if (ca === null || cb === null) {
|
|
200
|
+
const pa = a.candidate.price ?? Infinity, pb = b.candidate.price ?? Infinity;
|
|
201
|
+
if (pa !== pb)
|
|
202
|
+
return pa - pb;
|
|
203
|
+
}
|
|
204
|
+
return a.candidate.eci - b.candidate.eci;
|
|
205
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { type LangfuseConnector } from "../connectors/langfuse/index.ts";
|
|
2
|
+
import { type CandidateFilter, type RunnableModel } from "./candidates.ts";
|
|
3
|
+
import { type ModelCaller, type PiTaskSpec } from "./evaluators/pi.ts";
|
|
4
|
+
import { type ScoutReport } from "./pipeline.ts";
|
|
5
|
+
import { type EvalItem } from "./search.ts";
|
|
6
|
+
/** Everything Scout's tools need from the host: Pi's models and credentials, and where to write. */
|
|
7
|
+
export interface ScoutHost {
|
|
8
|
+
caller: ModelCaller;
|
|
9
|
+
models: () => Promise<RunnableModel[]>;
|
|
10
|
+
cwd: string;
|
|
11
|
+
env: Record<string, string | undefined>;
|
|
12
|
+
log: (line: string) => void;
|
|
13
|
+
/** The model Scout itself runs on, used as the default judge. */
|
|
14
|
+
scoutModel?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function langfuseFromEnv(env: Record<string, string | undefined>): LangfuseConnector | null;
|
|
17
|
+
export declare function connections(host: ScoutHost): Promise<{
|
|
18
|
+
providers: Record<string, number>;
|
|
19
|
+
scoutModel: string | null;
|
|
20
|
+
langfuse: {
|
|
21
|
+
connected: boolean;
|
|
22
|
+
project?: string;
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
datasets?: string[];
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
export declare function candidates(host: ScoutHost, filter: CandidateFilter): Promise<{
|
|
29
|
+
eci: {
|
|
30
|
+
source: string;
|
|
31
|
+
url: string;
|
|
32
|
+
attribution: string;
|
|
33
|
+
fetchedAt: string;
|
|
34
|
+
rows: number;
|
|
35
|
+
estimates: {
|
|
36
|
+
file: string;
|
|
37
|
+
rows: number;
|
|
38
|
+
provenance: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
counts: {
|
|
42
|
+
published: number;
|
|
43
|
+
estimated: number;
|
|
44
|
+
calibrated: number;
|
|
45
|
+
unranked: number;
|
|
46
|
+
};
|
|
47
|
+
candidates: import("./candidates.ts").Candidate[];
|
|
48
|
+
unranked: string[];
|
|
49
|
+
dropped: {
|
|
50
|
+
model: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}[];
|
|
53
|
+
}>;
|
|
54
|
+
/** JSONL or a JSON array of `{ id?, input, expectedOutput, metadata? }`. */
|
|
55
|
+
export declare function readDatasetFile(path: string): Promise<EvalItem[]>;
|
|
56
|
+
export declare function previewDataset(host: ScoutHost, source: {
|
|
57
|
+
langfuseDataset?: string;
|
|
58
|
+
path?: string;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
size: number;
|
|
61
|
+
enoughForScout: boolean;
|
|
62
|
+
items: {
|
|
63
|
+
id: string;
|
|
64
|
+
input: string;
|
|
65
|
+
expectedOutput: string;
|
|
66
|
+
}[];
|
|
67
|
+
}>;
|
|
68
|
+
export declare function saveDataset(host: ScoutHost, name: string, items: {
|
|
69
|
+
id?: string;
|
|
70
|
+
input: unknown;
|
|
71
|
+
expectedOutput: unknown;
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
73
|
+
}[]): Promise<{
|
|
74
|
+
path: string;
|
|
75
|
+
items: number;
|
|
76
|
+
}>;
|
|
77
|
+
export interface RunParams {
|
|
78
|
+
task: string;
|
|
79
|
+
langfuseDataset?: string;
|
|
80
|
+
datasetPath?: string;
|
|
81
|
+
/** Langfuse dataset name for a local file; defaults to the file name. */
|
|
82
|
+
datasetName?: string;
|
|
83
|
+
evaluator: ({
|
|
84
|
+
type: "pi";
|
|
85
|
+
} & PiTaskSpec) | {
|
|
86
|
+
type: "module";
|
|
87
|
+
path: string;
|
|
88
|
+
};
|
|
89
|
+
filter?: CandidateFilter;
|
|
90
|
+
sampleSize?: number;
|
|
91
|
+
finalists?: number;
|
|
92
|
+
repeats?: number;
|
|
93
|
+
judgeModel?: string;
|
|
94
|
+
/** Without Langfuse the run is local only, which the report says. */
|
|
95
|
+
requireLangfuse?: boolean;
|
|
96
|
+
}
|
|
97
|
+
/** A rough count of evaluations, so a host can ask before spending. */
|
|
98
|
+
export declare function estimate(candidateCount: number, datasetSize: number, sampleSize?: number, finalists?: number, repeats?: number, challengers?: number): {
|
|
99
|
+
candidates: number;
|
|
100
|
+
probes: number;
|
|
101
|
+
evaluations: number;
|
|
102
|
+
note: string;
|
|
103
|
+
};
|
|
104
|
+
export declare function plan(host: ScoutHost, params: RunParams): Promise<{
|
|
105
|
+
plugin: import("./evaluators/module.ts").EvaluatorPlugin | null;
|
|
106
|
+
filter: CandidateFilter;
|
|
107
|
+
ranked: {
|
|
108
|
+
eci: {
|
|
109
|
+
source: string;
|
|
110
|
+
url: string;
|
|
111
|
+
attribution: string;
|
|
112
|
+
fetchedAt: string;
|
|
113
|
+
rows: number;
|
|
114
|
+
estimates: {
|
|
115
|
+
file: string;
|
|
116
|
+
rows: number;
|
|
117
|
+
provenance: string;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
counts: {
|
|
121
|
+
published: number;
|
|
122
|
+
estimated: number;
|
|
123
|
+
calibrated: number;
|
|
124
|
+
unranked: number;
|
|
125
|
+
};
|
|
126
|
+
candidates: import("./candidates.ts").Candidate[];
|
|
127
|
+
unranked: string[];
|
|
128
|
+
dropped: {
|
|
129
|
+
model: string;
|
|
130
|
+
reason: string;
|
|
131
|
+
}[];
|
|
132
|
+
};
|
|
133
|
+
datasetSize: number;
|
|
134
|
+
estimate: {
|
|
135
|
+
candidates: number;
|
|
136
|
+
probes: number;
|
|
137
|
+
evaluations: number;
|
|
138
|
+
note: string;
|
|
139
|
+
};
|
|
140
|
+
}>;
|
|
141
|
+
export declare function run(host: ScoutHost, params: RunParams, signal?: AbortSignal): Promise<{
|
|
142
|
+
report: ScoutReport;
|
|
143
|
+
markdown: string;
|
|
144
|
+
files: {
|
|
145
|
+
json: string;
|
|
146
|
+
markdown: string;
|
|
147
|
+
};
|
|
148
|
+
}>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { connectionFromEnv, createLangfuseConnector } from "../connectors/langfuse/index.js";
|
|
5
|
+
import { openRouterToolSupport, rankCandidates } from "./candidates.js";
|
|
6
|
+
import { loadEci, loadEciEstimates } from "./eci.js";
|
|
7
|
+
import { loadEvaluatorPlugin } from "./evaluators/module.js";
|
|
8
|
+
import { piEvaluator } from "./evaluators/pi.js";
|
|
9
|
+
import { runScout } from "./pipeline.js";
|
|
10
|
+
import { reportMarkdown } from "./report.js";
|
|
11
|
+
import { FINALISTS, SAMPLE_SIZE } from "./search.js";
|
|
12
|
+
let eciCache = null;
|
|
13
|
+
let toolCache = null;
|
|
14
|
+
let estimateCache = null;
|
|
15
|
+
const eci = () => (eciCache ??= loadEci().catch(error => { eciCache = null; throw error; }));
|
|
16
|
+
const tools = () => (toolCache ??= openRouterToolSupport().catch(error => { toolCache = null; throw error; }));
|
|
17
|
+
export function langfuseFromEnv(env) {
|
|
18
|
+
if (!env.LANGFUSE_PUBLIC_KEY || !env.LANGFUSE_SECRET_KEY)
|
|
19
|
+
return null;
|
|
20
|
+
return createLangfuseConnector(connectionFromEnv(env), { environment: "openmerit" });
|
|
21
|
+
}
|
|
22
|
+
export async function connections(host) {
|
|
23
|
+
const models = await host.models();
|
|
24
|
+
const providers = {};
|
|
25
|
+
for (const model of models)
|
|
26
|
+
providers[model.provider] = (providers[model.provider] ?? 0) + 1;
|
|
27
|
+
const lf = langfuseFromEnv(host.env);
|
|
28
|
+
let langfuse = { connected: false };
|
|
29
|
+
if (lf) {
|
|
30
|
+
try {
|
|
31
|
+
const project = await lf.check();
|
|
32
|
+
langfuse = { connected: true, project: project.projectName, baseUrl: project.baseUrl, datasets: (await lf.listDatasets()).map(item => item.name) };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
langfuse = { connected: false, error: error instanceof Error ? error.message : String(error) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { providers, scoutModel: host.scoutModel ?? null, langfuse };
|
|
39
|
+
}
|
|
40
|
+
export async function candidates(host, filter) {
|
|
41
|
+
const [source, support, bundled] = await Promise.all([eci(), filter.tools ? tools() : Promise.resolve(undefined), estimateCache ??= loadEciEstimates()]);
|
|
42
|
+
const ranked = rankCandidates(await host.models(), source.rows, { ...filter, ...(support ? { toolSupport: support } : {}) }, bundled.estimates);
|
|
43
|
+
const bySource = (name) => ranked.candidates.filter(item => item.eciSource === name).length;
|
|
44
|
+
return {
|
|
45
|
+
...ranked,
|
|
46
|
+
eci: { source: source.source, url: source.url, attribution: source.attribution, fetchedAt: source.fetchedAt, rows: source.rows.length, estimates: { file: "data/eci-estimates.csv", rows: bundled.estimates.length, provenance: bundled.provenance } },
|
|
47
|
+
counts: { published: bySource("published"), estimated: bySource("estimated"), calibrated: bySource("calibrated"), unranked: ranked.unranked.length },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A stable ID from the item's input, so re-saving a file keeps its Langfuse item IDs. */
|
|
51
|
+
const itemId = (input) => `item-${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 12)}`;
|
|
52
|
+
/** JSONL or a JSON array of `{ id?, input, expectedOutput, metadata? }`. */
|
|
53
|
+
export async function readDatasetFile(path) {
|
|
54
|
+
const text = await readFile(path, "utf8");
|
|
55
|
+
const rows = text.trim().startsWith("[") ? JSON.parse(text) : text.split("\n").filter(line => line.trim()).map(line => JSON.parse(line));
|
|
56
|
+
return rows.map((row, index) => {
|
|
57
|
+
const item = row;
|
|
58
|
+
const expectedOutput = item.expectedOutput ?? item.expected_output;
|
|
59
|
+
if (item.input === undefined || expectedOutput === undefined)
|
|
60
|
+
throw new Error(`${path} item ${index + 1} needs input and expectedOutput`);
|
|
61
|
+
return {
|
|
62
|
+
id: typeof item.id === "string" && item.id ? item.id : itemId(item.input), input: item.input, expectedOutput,
|
|
63
|
+
...(item.metadata && typeof item.metadata === "object" ? { metadata: item.metadata } : {}),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function previewDataset(host, source) {
|
|
68
|
+
let items;
|
|
69
|
+
if (source.langfuseDataset) {
|
|
70
|
+
const lf = langfuseFromEnv(host.env);
|
|
71
|
+
if (!lf)
|
|
72
|
+
throw new Error("Langfuse is not connected: set LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL");
|
|
73
|
+
items = await lf.getDatasetItems(source.langfuseDataset);
|
|
74
|
+
}
|
|
75
|
+
else if (source.path)
|
|
76
|
+
items = await readDatasetFile(resolve(host.cwd, source.path));
|
|
77
|
+
else
|
|
78
|
+
throw new Error("Give a Langfuse dataset name or a dataset file path");
|
|
79
|
+
const clip = (value) => { const text = JSON.stringify(value); return text.length > 600 ? `${text.slice(0, 600)}…` : text; };
|
|
80
|
+
return { size: items.length, enoughForScout: items.length >= SAMPLE_SIZE, items: items.slice(0, 3).map(item => ({ id: item.id, input: clip(item.input), expectedOutput: clip(item.expectedOutput) })) };
|
|
81
|
+
}
|
|
82
|
+
export async function saveDataset(host, name, items) {
|
|
83
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name))
|
|
84
|
+
throw new Error("Dataset names are lowercase letters, digits and hyphens");
|
|
85
|
+
const dir = join(host.cwd, ".openmerit", "datasets");
|
|
86
|
+
await mkdir(dir, { recursive: true });
|
|
87
|
+
const path = join(dir, `${name}.jsonl`);
|
|
88
|
+
const rows = items.map(item => ({ id: item.id ?? itemId(item.input), input: item.input, expectedOutput: item.expectedOutput, metadata: { ...item.metadata, labelledBy: "openmerit-scout-draft" } }));
|
|
89
|
+
await writeFile(path, `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
|
|
90
|
+
return { path, items: rows.length };
|
|
91
|
+
}
|
|
92
|
+
/** A rough count of evaluations, so a host can ask before spending. */
|
|
93
|
+
export function estimate(candidateCount, datasetSize, sampleSize = SAMPLE_SIZE, finalists = FINALISTS, repeats = 3, challengers = 3) {
|
|
94
|
+
const probes = Math.ceil(Math.log2(candidateCount + 1));
|
|
95
|
+
const screens = Math.min(finalists - 1 + 2 + challengers, Math.max(0, candidateCount - probes));
|
|
96
|
+
return { candidates: candidateCount, probes, evaluations: (probes + screens) * sampleSize + Math.min(finalists + challengers, candidateCount) * datasetSize * repeats, note: "upper estimate: challengers run only when cheaper, stronger untested models exist" };
|
|
97
|
+
}
|
|
98
|
+
export async function plan(host, params) {
|
|
99
|
+
const plugin = params.evaluator.type === "module" ? await loadEvaluatorPlugin(params.evaluator.path, host.cwd) : null;
|
|
100
|
+
const filter = { ...params.filter, ...(plugin?.providers && !params.filter?.providers ? { providers: plugin.providers } : {}), ...(plugin?.tools ? { tools: true } : {}) };
|
|
101
|
+
const ranked = await candidates(host, filter);
|
|
102
|
+
const size = params.langfuseDataset || params.datasetPath ? (await previewDataset(host, { ...(params.langfuseDataset ? { langfuseDataset: params.langfuseDataset } : {}), ...(params.datasetPath ? { path: params.datasetPath } : {}) })).size : 0;
|
|
103
|
+
return { plugin, filter, ranked, datasetSize: size, estimate: estimate(ranked.candidates.length, size, params.sampleSize, params.finalists, params.repeats) };
|
|
104
|
+
}
|
|
105
|
+
export async function run(host, params, signal) {
|
|
106
|
+
const { plugin, ranked } = await plan(host, params);
|
|
107
|
+
const lf = langfuseFromEnv(host.env);
|
|
108
|
+
if (!lf && params.requireLangfuse !== false)
|
|
109
|
+
throw new Error("Scout picks the winner from Langfuse experiments: set LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL (self-hosted works), or pass requireLangfuse: false for a local-only run");
|
|
110
|
+
const evaluate = plugin
|
|
111
|
+
? await plugin.create({ env: host.env, log: host.log })
|
|
112
|
+
: piEvaluator(host.caller, params.evaluator, { ...(params.judgeModel ?? host.scoutModel ? { judgeModel: (params.judgeModel ?? host.scoutModel) } : {}) });
|
|
113
|
+
const dataset = params.langfuseDataset
|
|
114
|
+
? { langfuseDataset: params.langfuseDataset }
|
|
115
|
+
: { local: { name: params.datasetName ?? params.datasetPath.split("/").at(-1).replace(/\.(jsonl|json)$/, ""), description: `Labelled items for: ${params.task.slice(0, 200)}`, items: await readDatasetFile(resolve(host.cwd, params.datasetPath)) } };
|
|
116
|
+
const credits = async () => {
|
|
117
|
+
if (!host.env.OPENROUTER_API_KEY)
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
const response = await fetch("https://openrouter.ai/api/v1/credits", { headers: { authorization: `Bearer ${host.env.OPENROUTER_API_KEY}` }, signal: AbortSignal.timeout(15_000) });
|
|
121
|
+
return response.ok ? (await response.json()).data?.total_usage ?? null : null;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const before = await credits();
|
|
128
|
+
const report = await runScout({
|
|
129
|
+
task: params.task, dataset, candidates: ranked.candidates, evaluate, langfuse: lf, log: host.log,
|
|
130
|
+
...(plugin?.passScore ? { passScore: plugin.passScore } : {}), ...(plugin?.role ? { role: plugin.role } : {}), ...(plugin?.concurrency ? { concurrency: plugin.concurrency } : {}),
|
|
131
|
+
...(params.sampleSize ? { sampleSize: params.sampleSize } : {}), ...(params.finalists ? { finalists: params.finalists } : {}), ...(params.repeats ? { repeats: params.repeats } : {}), ...(signal ? { signal } : {}),
|
|
132
|
+
});
|
|
133
|
+
const after = before === null ? null : await credits();
|
|
134
|
+
if (before !== null && after !== null)
|
|
135
|
+
report.spend.openRouterCreditsUsd = after - before;
|
|
136
|
+
const dir = join(host.cwd, ".openmerit", "runs", report.runId);
|
|
137
|
+
await mkdir(dir, { recursive: true });
|
|
138
|
+
const markdown = reportMarkdown(report);
|
|
139
|
+
const files = { json: join(dir, "report.json"), markdown: join(dir, "report.md") };
|
|
140
|
+
await writeFile(files.json, `${JSON.stringify({ ...report, unranked: ranked.unranked, dropped: ranked.dropped }, null, 2)}\n`);
|
|
141
|
+
await writeFile(files.markdown, markdown);
|
|
142
|
+
return { report, markdown, files };
|
|
143
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{"id":"HD-01","input":"I have high blood pressure and take amlodipine every day.","expectedOutput":{"conditions":["I10"]}}
|
|
2
|
+
{"id":"HD-02","input":"Type 2 diabetes for six years, on metformin. Also high cholesterol, on atorvastatin.","expectedOutput":{"conditions":["E11","E78"]}}
|
|
3
|
+
{"id":"HD-03","input":"No medical conditions.","expectedOutput":{"conditions":[]}}
|
|
4
|
+
{"id":"HD-04","input":"Asthma; I use a salbutamol inhaler a few times a month.","expectedOutput":{"conditions":["J45"]}}
|
|
5
|
+
{"id":"HD-05","input":"Underactive thyroid, treated with levothyroxine.","expectedOutput":{"conditions":["E03"]}}
|
|
6
|
+
{"id":"HD-06","input":"I broke my arm in 2019 and it healed completely. Nothing else.","expectedOutput":{"conditions":[]}}
|
|
7
|
+
{"id":"HD-07","input":"Migraines about twice a month; I take sumatriptan when they start.","expectedOutput":{"conditions":["G43"]}}
|
|
8
|
+
{"id":"HD-08","input":"Atrial fibrillation, on apixaban. I also have hypertension.","expectedOutput":{"conditions":["I48","I10"]}}
|
|
9
|
+
{"id":"HD-09","input":"Psoriasis on both elbows; I use a steroid cream.","expectedOutput":{"conditions":["L40"]}}
|
|
10
|
+
{"id":"HD-10","input":"COPD diagnosed last year. Ex-smoker. I use a tiotropium inhaler daily.","expectedOutput":{"conditions":["J44"]}}
|
|
11
|
+
{"id":"HD-11","input":"Epilepsy. Seizure-free for three years on levetiracetam, which I still take.","expectedOutput":{"conditions":["G40"]}}
|
|
12
|
+
{"id":"HD-12","input":"Acid reflux (GERD); I take omeprazole daily.","expectedOutput":{"conditions":["K21"]}}
|
|
13
|
+
{"id":"HD-13","input":"أعاني من ارتفاع ضغط الدم ومرض السكري من النوع الثاني وأتناول الأدوية بانتظام.","expectedOutput":{"conditions":["I10","E11"]}}
|
|
14
|
+
{"id":"HD-14","input":"I had gestational diabetes in 2021; it resolved after my baby was born and my sugar tests have been normal since. No other conditions.","expectedOutput":{"conditions":[]}}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Scout evaluator for the Sahra Health playground's enrollment agent (fictional data only).
|
|
2
|
+
// It uses only what a customer exposes: the playground Worker's HTTP API and Sahra's own Langfuse
|
|
3
|
+
// dataset `sahra-enrollments` (item input `{ application }`, expected output `{ decision,
|
|
4
|
+
// declaredConditions, notDeclared? }`). Each item runs in a fresh, isolated ledger.
|
|
5
|
+
//
|
|
6
|
+
// PLAYGROUND_URL (default http://localhost:8787), PLAYGROUND_TOKEN, LANGFUSE_* for measured cost.
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
// Copy this file into your project to adapt it; it has no runtime import from OpenMerit.
|
|
10
|
+
// In your project, import the type from the package instead: `import type { EvaluatorPlugin } from "openmerit"`.
|
|
11
|
+
import type { EvaluatorPlugin } from "../../src/scout/evaluators/module.ts";
|
|
12
|
+
|
|
13
|
+
/** Scout skips a candidate that throws this (matched by name) as untested instead of stopping. */
|
|
14
|
+
class CandidateUnavailable extends Error {
|
|
15
|
+
override readonly name = "CandidateUnavailable";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Expected { decision: "enrolled" | "declined"; declaredConditions: string[]; notDeclared?: string[] }
|
|
19
|
+
interface State {
|
|
20
|
+
enrollment: { decision: "enrolled" | "declined" | "pending" } | null;
|
|
21
|
+
member: { declaredConditions: string[] } | null;
|
|
22
|
+
workflow: { status?: string; error?: { message?: string } } | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The agent ended without recording a decision. Under `wrangler dev` the workflow reports only the NonRetryableError. */
|
|
26
|
+
const NO_DECISION = /ended without a decision|threw an? NonRetryableError/;
|
|
27
|
+
|
|
28
|
+
interface Observation { type: string; level?: string; statusMessage?: string | null; endTime?: string | null; totalCost?: number | null; metadata?: { generations?: string | number } }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Why a run ended without a decision, from the playground's own trace: a provider error on a model
|
|
32
|
+
* call (`402: …`, `404: …`) is not a quality verdict. Returns null when every model call succeeded,
|
|
33
|
+
* which makes it the agent's failure.
|
|
34
|
+
*/
|
|
35
|
+
export function providerError(observations: readonly Observation[]): { status: number; message: string } | null {
|
|
36
|
+
for (const row of observations) {
|
|
37
|
+
if (row.type !== "GENERATION" || row.level !== "ERROR") continue;
|
|
38
|
+
const message = row.statusMessage ?? "";
|
|
39
|
+
const status = Number(/^(\d{3})\b/.exec(message)?.[1] ?? /\b(4\d\d|5\d\d)\b/.exec(message)?.[1] ?? 0);
|
|
40
|
+
return { status, message: message.slice(0, 240) };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The playground scenario runner's checks: the decision, then declared condition prefixes. */
|
|
46
|
+
export function enrollmentPass(expected: Expected, state: State): { pass: boolean; comment: string } {
|
|
47
|
+
const decision = state.enrollment?.decision ?? "none";
|
|
48
|
+
if (decision !== expected.decision) return { pass: false, comment: `decision ${decision}, expected ${expected.decision}` };
|
|
49
|
+
if (expected.decision !== "enrolled") return { pass: true, comment: `decision ${decision}` };
|
|
50
|
+
const declared = state.member?.declaredConditions ?? [];
|
|
51
|
+
const missing = expected.declaredConditions.filter(code => !declared.some(item => item.startsWith(code)));
|
|
52
|
+
const wrong = (expected.notDeclared ?? []).filter(code => declared.some(item => item.startsWith(code)));
|
|
53
|
+
return {
|
|
54
|
+
pass: !missing.length && !wrong.length,
|
|
55
|
+
comment: `declared ${declared.join(", ") || "none"}${missing.length ? `; missing ${missing.join(", ")}` : ""}${wrong.length ? `; should not declare ${wrong.join(", ")}` : ""}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const plugin: EvaluatorPlugin = {
|
|
60
|
+
name: "sahra-enrollment",
|
|
61
|
+
providers: ["openrouter"],
|
|
62
|
+
tools: true,
|
|
63
|
+
passScore: "enrollment-correct",
|
|
64
|
+
role: "enrollment",
|
|
65
|
+
concurrency: 6,
|
|
66
|
+
create({ env, log }) {
|
|
67
|
+
const url = (env.PLAYGROUND_URL ?? "http://localhost:8787").replace(/\/+$/, "");
|
|
68
|
+
const token = env.PLAYGROUND_TOKEN;
|
|
69
|
+
if (!token) throw new Error("Set PLAYGROUND_TOKEN to the playground Worker's token");
|
|
70
|
+
const langfuse = env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_SECRET_KEY
|
|
71
|
+
? { base: (env.LANGFUSE_BASE_URL ?? "https://us.cloud.langfuse.com").replace(/\/+$/, ""), auth: `Basic ${btoa(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`)}` }
|
|
72
|
+
: null;
|
|
73
|
+
|
|
74
|
+
/** The playground's own trace once its agent root has ended and every generation is ingested; null on timeout. */
|
|
75
|
+
async function trace(traceId: string, priced: boolean): Promise<Observation[] | null> {
|
|
76
|
+
if (!langfuse) return null;
|
|
77
|
+
const deadline = Date.now() + 180_000;
|
|
78
|
+
await delay(8_000);
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
const response = await fetch(`${langfuse.base}/api/public/v2/observations?traceId=${traceId}&fields=core,basic,usage,metadata&limit=100`, { headers: { authorization: langfuse.auth } });
|
|
81
|
+
if (response.ok) {
|
|
82
|
+
const { data = [] } = await response.json() as { data?: Observation[] };
|
|
83
|
+
const root = data.find(row => row.type === "AGENT");
|
|
84
|
+
const generations = data.filter(row => row.type === "GENERATION");
|
|
85
|
+
const expected = Number(root?.metadata?.generations ?? NaN);
|
|
86
|
+
if (root?.endTime && (generations.length === expected || root.level === "ERROR")
|
|
87
|
+
&& (!priced || generations.every(row => typeof row.totalCost === "number"))) return data;
|
|
88
|
+
}
|
|
89
|
+
await delay(response.status === 429 ? 30_000 : 8_000);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const traceCost = async (traceId: string) => {
|
|
94
|
+
const data = await trace(traceId, true);
|
|
95
|
+
return data ? data.filter(row => row.type === "GENERATION").reduce((sum, row) => sum + (row.totalCost ?? 0), 0) : null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return async (candidate, item, context) => {
|
|
99
|
+
if (candidate.provider !== "openrouter") throw new Error("The enrollment agent runs OpenRouter models only");
|
|
100
|
+
const input = item.input as { application?: { applicationId?: string } };
|
|
101
|
+
const application = input.application;
|
|
102
|
+
if (!application?.applicationId) throw new Error(`Dataset item ${item.id} has no application`);
|
|
103
|
+
const expected = item.expectedOutput as Expected;
|
|
104
|
+
// Outlast EnrollmentFlow's own 10-minute limit and one retry, so the workflow decides the outcome.
|
|
105
|
+
const signal = AbortSignal.any([AbortSignal.timeout(25 * 60_000), ...(context.signal ? [context.signal] : [])]);
|
|
106
|
+
const call = async <T>(method: string, path: string, body?: unknown): Promise<T> => {
|
|
107
|
+
const response = await fetch(`${url}${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body === undefined ? null : JSON.stringify(body), signal });
|
|
108
|
+
if (!response.ok) throw new Error(`Playground ${method} ${path.split("?")[0]} HTTP ${response.status}`);
|
|
109
|
+
return await response.json() as T;
|
|
110
|
+
};
|
|
111
|
+
const traceIdOf = (runId: string) => createHash("sha256").update(`trace:${application.applicationId}--${runId}/enrollment`).digest("hex").slice(0, 32);
|
|
112
|
+
for (let attempt = 1; ; attempt++) {
|
|
113
|
+
const runId = `scout-${randomUUID()}`;
|
|
114
|
+
log(` ${context.stage} ${candidate.id} ${item.id} run=${runId}`);
|
|
115
|
+
await call("POST", "/admin/seed", { runId });
|
|
116
|
+
await call("POST", "/enrollments", { runId, application, models: { enrollment: { provider: "openrouter", id: candidate.id } } });
|
|
117
|
+
const path = `/enrollments/${encodeURIComponent(application.applicationId)}?runId=${encodeURIComponent(runId)}`;
|
|
118
|
+
let verdict: { pass: boolean; comment: string; output: unknown } | null = null;
|
|
119
|
+
while (!verdict) {
|
|
120
|
+
const state = await call<State>("GET", path);
|
|
121
|
+
const decision = state.enrollment?.decision;
|
|
122
|
+
if (["errored", "terminated"].includes(state.workflow?.status ?? "")) {
|
|
123
|
+
if (!NO_DECISION.test(state.workflow?.error?.message ?? "")) {
|
|
124
|
+
throw new Error(`Enrollment workflow ${state.workflow?.status} (run ${runId}): ${state.workflow?.error?.message ?? "no message"}; no quality verdict`);
|
|
125
|
+
}
|
|
126
|
+
// Only the trace can say whether a model call failed (provider error) or the agent gave up.
|
|
127
|
+
const observations = await trace(traceIdOf(runId), false);
|
|
128
|
+
if (!observations) throw new Error(`Cannot tell a provider error from an agent failure for run ${runId}: its Langfuse trace did not arrive`);
|
|
129
|
+
const failed = providerError(observations);
|
|
130
|
+
if (failed && [400, 402, 404, 413, 422].includes(failed.status)) throw new CandidateUnavailable(`${failed.status} from the provider (run ${runId}): ${failed.message}`);
|
|
131
|
+
if (failed && (failed.status === 429 || failed.status >= 500 || failed.status === 0) && attempt < 2) { log(` retrying ${candidate.id} ${item.id} after ${failed.status || "an unclassified provider error"}`); break; }
|
|
132
|
+
if (failed) throw new Error(`Provider error ${failed.status} for ${candidate.model} (run ${runId}): ${failed.message}`);
|
|
133
|
+
verdict = { pass: false, comment: "the agent ended without recording a decision", output: { decision: null } };
|
|
134
|
+
} else if (decision && decision !== "pending") {
|
|
135
|
+
verdict = { ...enrollmentPass(expected, state), output: { decision, declaredConditions: state.member?.declaredConditions ?? [] } };
|
|
136
|
+
} else if (state.workflow?.status === "complete") {
|
|
137
|
+
throw new Error(`Enrollment workflow completed without a decision (run ${runId})`);
|
|
138
|
+
} else await delay(1500, undefined, { signal });
|
|
139
|
+
}
|
|
140
|
+
if (!verdict) continue;
|
|
141
|
+
// The playground's trace ID for this agent run (see its tracing.ts): sha256("trace:<app>--<runId>/enrollment").
|
|
142
|
+
const traceId = traceIdOf(runId);
|
|
143
|
+
// Cost decides ties between finalists, so wait for it there; screens skip the ingestion wait.
|
|
144
|
+
const costUsd = context.stage === "final" ? await traceCost(traceId) : null;
|
|
145
|
+
return { ...verdict, output: { ...verdict.output as object, playgroundRunId: runId, playgroundTraceId: traceId }, costUsd };
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export default plugin;
|