openmerit 0.4.1 → 0.4.2

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.
Files changed (70) hide show
  1. package/README.md +239 -58
  2. package/data/catalogue.json +6778 -0
  3. package/dist/catalogue/catalogue.d.ts +41 -0
  4. package/dist/catalogue/catalogue.js +33 -0
  5. package/dist/cli/prompt.d.ts +26 -0
  6. package/dist/cli/prompt.js +114 -0
  7. package/dist/cli/render.d.ts +12 -0
  8. package/dist/cli/render.js +104 -0
  9. package/dist/cli.js +245 -158
  10. package/dist/connect.d.ts +34 -0
  11. package/dist/connect.js +158 -0
  12. package/dist/connectors/langfuse/index.d.ts +37 -110
  13. package/dist/connectors/langfuse/index.js +95 -246
  14. package/dist/daemon/daemon.d.ts +81 -0
  15. package/dist/daemon/daemon.js +408 -0
  16. package/dist/daemon/registry.d.ts +111 -0
  17. package/dist/daemon/registry.js +109 -0
  18. package/dist/eval/proxy.d.ts +54 -0
  19. package/dist/eval/proxy.js +174 -0
  20. package/dist/eval/runner.d.ts +29 -0
  21. package/dist/eval/runner.js +80 -0
  22. package/dist/harness/claude-bridge.d.ts +21 -0
  23. package/dist/harness/claude-bridge.js +65 -0
  24. package/dist/harness/harness.d.ts +60 -0
  25. package/dist/harness/harness.js +221 -0
  26. package/dist/index.d.ts +9 -10
  27. package/dist/index.js +9 -10
  28. package/dist/local.d.ts +12 -0
  29. package/dist/local.js +40 -0
  30. package/dist/mcp.d.ts +1 -0
  31. package/dist/mcp.js +155 -0
  32. package/dist/scout/pool.d.ts +17 -0
  33. package/dist/scout/pool.js +56 -0
  34. package/dist/scout/scan.d.ts +42 -0
  35. package/dist/scout/scan.js +169 -0
  36. package/dist/scout/search.d.ts +25 -98
  37. package/dist/scout/search.js +56 -156
  38. package/dist/scout/workflow.d.ts +221 -0
  39. package/dist/scout/workflow.js +941 -0
  40. package/dist/setup.d.ts +44 -0
  41. package/dist/setup.js +211 -0
  42. package/dist/state/config.d.ts +116 -0
  43. package/dist/state/config.js +60 -0
  44. package/dist/state/db.d.ts +159 -0
  45. package/dist/state/db.js +260 -0
  46. package/dist/state/paths.d.ts +12 -0
  47. package/dist/state/paths.js +23 -0
  48. package/dist/update.d.ts +22 -0
  49. package/dist/update.js +58 -0
  50. package/package.json +12 -17
  51. package/dist/connectors/langfuse/otlp.d.ts +0 -60
  52. package/dist/connectors/langfuse/otlp.js +0 -40
  53. package/dist/pi/extension.d.ts +0 -13
  54. package/dist/pi/extension.js +0 -148
  55. package/dist/pi/harness.d.ts +0 -20
  56. package/dist/pi/harness.js +0 -49
  57. package/dist/pi/instructions.d.ts +0 -2
  58. package/dist/pi/instructions.js +0 -12
  59. package/dist/scout/evaluators/module.d.ts +0 -24
  60. package/dist/scout/evaluators/module.js +0 -19
  61. package/dist/scout/evaluators/pi.d.ts +0 -53
  62. package/dist/scout/evaluators/pi.js +0 -117
  63. package/dist/scout/pipeline.d.ts +0 -111
  64. package/dist/scout/pipeline.js +0 -157
  65. package/dist/scout/report.d.ts +0 -3
  66. package/dist/scout/report.js +0 -42
  67. package/dist/scout/service.d.ts +0 -148
  68. package/dist/scout/service.js +0 -143
  69. package/examples/health-declaration-codes/dataset.jsonl +0 -14
  70. package/examples/sahra-enrollment/evaluator.mts +0 -152
@@ -1,157 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { compareFinals, meanCost, scoutSearch } from "./search.js";
3
- const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
4
- /**
5
- * Scout end to end: dataset → binary search over ECI-ranked candidates → finals on every item,
6
- * each probe and final recorded as a Langfuse experiment → the winner from Langfuse's results.
7
- */
8
- export async function runScout(options) {
9
- const log = options.log ?? (() => { });
10
- const runId = `scout-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 4)}`;
11
- const passScore = options.passScore ?? "correct";
12
- const role = options.role ?? "candidate";
13
- const lf = options.langfuse;
14
- let items;
15
- let datasetName;
16
- let datasetId = null;
17
- if (options.dataset.langfuseDataset) {
18
- if (!lf)
19
- throw new Error("A Langfuse dataset needs a Langfuse connection (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASE_URL)");
20
- datasetName = options.dataset.langfuseDataset;
21
- items = (await lf.getDatasetItems(datasetName)).map(item => ({ id: item.id, input: item.input, expectedOutput: item.expectedOutput, metadata: item.metadata }));
22
- datasetId = (await lf.getDataset(datasetName)).id;
23
- }
24
- else if (options.dataset.local) {
25
- datasetName = options.dataset.local.name;
26
- items = options.dataset.local.items;
27
- if (lf) {
28
- const pushed = await lf.ensureDataset({
29
- name: datasetName, ...(options.dataset.local.description ? { description: options.dataset.local.description } : {}),
30
- metadata: { source: "openmerit-scout" },
31
- items: items.map(item => ({ id: item.id, input: item.input, expectedOutput: item.expectedOutput, ...(item.metadata ? { metadata: item.metadata } : {}) })),
32
- });
33
- datasetId = pushed.datasetId;
34
- }
35
- }
36
- else
37
- throw new Error("Scout needs a labelled dataset: a Langfuse dataset name or local items");
38
- log(`Dataset ${datasetName}: ${items.length} items${datasetId ? ` (Langfuse ${datasetId})` : " (local only)"}`);
39
- log(`${options.candidates.length} ECI-ranked candidates · run ${runId}`);
40
- const urls = new Map();
41
- const project = lf ? await lf.check() : null;
42
- const onTrial = async (trial) => {
43
- if (!lf || !datasetId)
44
- return;
45
- const stage = trial.stage === "final" ? "final" : "screen";
46
- const recordings = [];
47
- for (let repeat = 1; repeat <= trial.runs; repeat++) {
48
- const suffix = trial.runs > 1 ? `-r${repeat}` : "";
49
- const experimentId = `${runId}-${stage}${suffix}-${slug(trial.candidate.model)}`;
50
- const recorded = await lf.recordExperimentItems({
51
- datasetId, experimentId, experimentName: `scout ${stage}${trial.runs > 1 ? ` run ${repeat}/${trial.runs}` : ""} ${trial.candidate.model} ${runId}`, traceName: "openmerit-scout-eval",
52
- metadata: { [`model_${role}`]: trial.candidate.model, scout_run: runId, stage, ...(trial.runs > 1 ? { repeat, repeats: trial.runs } : {}), eci: trial.candidate.eci, eci_source: trial.candidate.eciSource, task: options.task.slice(0, 200) },
53
- items: trial.outcomes.filter(outcome => (outcome.repeat ?? 1) === repeat).map(outcome => {
54
- const item = items.find(entry => entry.id === outcome.itemId);
55
- return {
56
- itemId: outcome.itemId, model: trial.candidate.model, input: item.input, output: outcome.output ?? null,
57
- startMs: outcome.startMs ?? Date.now(), endMs: outcome.endMs ?? Date.now(),
58
- ...(outcome.usage ? { usage: outcome.usage } : {}), ...(typeof outcome.costUsd === "number" ? { costUsd: outcome.costUsd } : {}),
59
- metadata: { stage: trial.stage, scout_run: runId, eci: trial.candidate.eci, ...(trial.runs > 1 ? { repeat } : {}) },
60
- scores: [{ name: passScore, value: outcome.pass, dataType: "BOOLEAN", ...(outcome.comment ? { comment: outcome.comment } : {}) }],
61
- };
62
- }),
63
- });
64
- recordings.push({ experimentId, url: recorded.url });
65
- }
66
- urls.set(trial, recordings);
67
- };
68
- const search = await scoutSearch({
69
- dataset: items, candidates: options.candidates, evaluate: options.evaluate, onTrial, log,
70
- ...(options.sampleSize ? { sampleSize: options.sampleSize } : {}), ...(options.finalists ? { finalists: options.finalists } : {}),
71
- ...(options.challengers !== undefined ? { challengers: options.challengers } : {}),
72
- ...(options.repeats !== undefined ? { repeats: options.repeats } : {}),
73
- ...(options.concurrency ? { concurrency: options.concurrency } : {}), ...(options.pickIndex ? { pickIndex: options.pickIndex } : {}),
74
- ...(options.signal ? { signal: options.signal } : {}),
75
- });
76
- // The decision: read every final back from Langfuse, so the recommendation rests on the recorded experiment.
77
- const rule = { score: passScore, equals: true };
78
- const finals = [];
79
- for (const trial of search.finals) {
80
- const recorded = urls.get(trial) ?? [];
81
- const readbacks = [];
82
- if (lf && recorded.length) {
83
- log(`Reading ${trial.candidate.model} back from Langfuse (${recorded.length} experiments)…`);
84
- for (const { experimentId } of recorded) {
85
- readbacks.push(await lf.waitForExperiment({ experimentId, pass: rule, includeCost: true, since: new Date(Date.now() - 86_400_000) }, { expectItems: items.length, timeoutMs: options.langfuseTimeoutMs ?? 300_000 }));
86
- }
87
- }
88
- const verified = readbacks.length > 0 && readbacks.length === recorded.length;
89
- const results = verified
90
- ? readbacks.flatMap(readback => readback.items.map(item => ({ itemId: item.itemId, pass: item.passed })))
91
- : trial.outcomes.map(outcome => ({ itemId: outcome.itemId, pass: outcome.pass }));
92
- const passesByItem = {};
93
- for (const item of items)
94
- passesByItem[item.id] = 0;
95
- for (const result of results)
96
- if (result.pass)
97
- passesByItem[result.itemId] = (passesByItem[result.itemId] ?? 0) + 1;
98
- const langfuseCost = verified && readbacks.every(readback => readback.costUsd != null) ? readbacks.reduce((sum, readback) => sum + readback.costUsd, 0) : null;
99
- finals.push({
100
- model: trial.candidate.model, eci: trial.candidate.eci, eciSource: trial.candidate.eciSource, listPrice: trial.candidate.price,
101
- passes: results.filter(result => result.pass).length, total: results.length, runs: trial.runs, passesByItem,
102
- costPerItemUsd: langfuseCost !== null && results.length ? langfuseCost / results.length : meanCost(trial.outcomes),
103
- localPasses: trial.passes, verifiedInLangfuse: verified, missingScores: readbacks.flatMap(readback => readback.missingScores),
104
- experimentIds: recorded.map(item => item.experimentId), experimentId: recorded[0]?.experimentId ?? null, experimentUrl: recorded[0]?.url ?? null,
105
- failedItems: Object.entries(passesByItem).filter(([, passes]) => passes < trial.runs).map(([id]) => id),
106
- });
107
- }
108
- const asTrial = (verdict) => {
109
- const trial = search.finals.find(item => item.candidate.model === verdict.model);
110
- return { ...trial, passes: verdict.passes, outcomes: trial.outcomes.map(outcome => ({ ...outcome, costUsd: verdict.costPerItemUsd })) };
111
- };
112
- finals.sort((a, b) => compareFinals(asTrial(a), asTrial(b)));
113
- // A stopped run has no recommendation: its finals are incomplete and listed only as evidence.
114
- const [recommendation = null, runnerUp = null] = search.status === "selected" ? finals : [];
115
- const all = [...search.trials, ...search.finals].flatMap(trial => trial.outcomes);
116
- const limitations = [
117
- search.limitation,
118
- "ECI is Epoch's published capability index. Models without a published score use ScoutBM's estimated or calibrated ECI from the bundled CSV, which carries 2–4 points of error; models with neither are not candidates (see `unranked`).",
119
- ...(lf ? [] : ["Langfuse is not connected: results were not recorded or verified in Langfuse."]),
120
- ...(search.shortfall ? [search.shortfall] : []),
121
- ...(search.status === "stopped" && finals.length ? [`The run stopped, so the ${finals.length} finals shown are partial and no model is recommended.`] : []),
122
- ...(search.unavailable.length ? [`${search.unavailable.length} candidates could not run (provider rejected the model or the balance did not cover it) and are untested: ${search.unavailable.map(item => item.model).join(", ")}.`] : []),
123
- ...(finals.some(item => item.localPasses !== item.passes) ? ["Langfuse and local pass counts differ for at least one finalist; Langfuse's count was used."] : []),
124
- ];
125
- return {
126
- scout: "OpenMerit Scout", runId, task: options.task, createdAt: new Date().toISOString(),
127
- dataset: { name: datasetName, source: options.dataset.langfuseDataset ? "langfuse" : "local", size: items.length, datasetId, url: project && datasetId ? `${lf.baseUrl}/project/${project.projectId}/datasets/${datasetId}` : null },
128
- candidates: options.candidates.length,
129
- search: {
130
- status: search.status, ...(search.error ? { error: search.error } : {}), criterion: search.criterion, sampleIds: search.sampleIds, selected: search.selected, unavailable: search.unavailable,
131
- finalRule: search.finalRule, limitation: search.limitation, ...(search.shortfall ? { shortfall: search.shortfall } : {}),
132
- trials: search.trials.map(trial => ({
133
- stage: trial.stage, model: trial.candidate.model, eci: trial.candidate.eci, eciSource: trial.candidate.eciSource, passes: trial.passes, total: trial.outcomes.length,
134
- accepted: trial.accepted, costUsd: meanCost(trial.outcomes) === null ? null : meanCost(trial.outcomes) * trial.outcomes.length, experimentUrl: urls.get(trial)?.[0]?.url ?? null,
135
- })),
136
- },
137
- finals, recommendation, runnerUp,
138
- why: explain(search, recommendation, runnerUp),
139
- spend: { measuredUsd: all.reduce((sum, item) => sum + (item.costUsd ?? 0), 0), unknownCostItems: all.filter(item => typeof item.costUsd !== "number").length, evaluations: all.length },
140
- langfuse: lf && project ? { baseUrl: lf.baseUrl, project: project.projectName, experiments: [...urls.values()].reduce((sum, list) => sum + list.length, 0) } : null,
141
- limitations,
142
- };
143
- }
144
- function explain(search, best, next) {
145
- if (search.status === "stopped")
146
- return `The search stopped without a verdict: ${search.error}`;
147
- if (search.status === "no_passing_model")
148
- return `No candidate passed all ${search.sampleIds.length} sampled items, so Scout recommends none. Improve the prompt or harness, or check the labels.`;
149
- if (!best)
150
- return "No finalist was evaluated.";
151
- const cost = (value) => (value === null ? "unknown cost" : `$${value.toFixed(5)}/item`);
152
- const head = `${best.model} passed ${best.passes}/${best.total} item runs (${best.runs} per item) in Langfuse at ${cost(best.costPerItemUsd)} (ECI ${best.eci.toFixed(1)}).`;
153
- if (!next)
154
- return head;
155
- const tie = next.passes === best.passes;
156
- return `${head} ${tie ? "Tied on passes with" : "Ahead of"} ${next.model} (${next.passes}/${next.total}, ${cost(next.costPerItemUsd)})${tie ? `; ${best.costPerItemUsd !== null && next.costPerItemUsd !== null && best.costPerItemUsd !== next.costPerItemUsd ? "lower measured cost" : "lower ECI"} breaks the tie` : ""}.`;
157
- }
@@ -1,3 +0,0 @@
1
- import type { ScoutReport } from "./pipeline.js";
2
- /** The decision record as Markdown: no keys, no raw item contents. */
3
- export declare function reportMarkdown(report: ScoutReport): string;
@@ -1,42 +0,0 @@
1
- const usd = (value, digits = 5) => (value === null ? "unknown" : `$${value.toFixed(digits)}`);
2
- const link = (label, url) => (url ? `[${label}](${url})` : label);
3
- const eciCell = (eci, source) => `${eci.toFixed(1)}${source === "published" ? "" : ` (${source.slice(0, 4)}.)`}`;
4
- /** The decision record as Markdown: no keys, no raw item contents. */
5
- export function reportMarkdown(report) {
6
- const best = report.recommendation;
7
- const lines = [
8
- `# Scout recommendation: ${best ? `\`${best.model}\`` : "none"}`,
9
- "",
10
- `- Task: ${report.task.replace(/\s+/g, " ").slice(0, 500)}`,
11
- `- Run: \`${report.runId}\`, ${report.createdAt}`,
12
- `- Dataset: ${link(`\`${report.dataset.name}\``, report.dataset.url)} (${report.dataset.source}, ${report.dataset.size} labelled items)`,
13
- `- Candidates: ${report.candidates} runnable models with an Epoch Capabilities Index (published, or ScoutBM's estimate: "est."/"cali.")`,
14
- `- Langfuse: ${report.langfuse ? `${report.langfuse.project} at ${report.langfuse.baseUrl}, ${report.langfuse.experiments} experiments recorded` : "not connected"}`,
15
- `- Spend: ${usd(report.spend.measuredUsd, 4)} measured over ${report.spend.evaluations} evaluations${report.spend.unknownCostItems ? ` (${report.spend.unknownCostItems} without a cost)` : ""}${report.spend.openRouterCreditsUsd !== undefined ? `; OpenRouter account credit used during the run: ${usd(report.spend.openRouterCreditsUsd, 4)}` : ""}`,
16
- "",
17
- `**Why:** ${report.why}`,
18
- "",
19
- `## Finals (every item ${report.finals[0]?.runs ?? 1} times, one Langfuse experiment per run, read back from Langfuse)`,
20
- "",
21
- "| Rank | Model | Passes | Cost per item run | ECI | List price $/M in+out | Verified in Langfuse | Failed items |",
22
- "|---|---|---|---|---|---|---|---|",
23
- ...report.finals.map((item, index) => `| ${index + 1} | ${link(`\`${item.model}\``, item.experimentUrl)} | ${item.passes}/${item.total} | ${usd(item.costPerItemUsd)} | ${eciCell(item.eci, item.eciSource)} | ${item.listPrice === null ? "unknown" : item.listPrice.toFixed(2)} | ${item.verifiedInLangfuse ? "yes" : "no"}${item.missingScores.length ? ` (${item.missingScores.length} unscored)` : ""} | ${item.failedItems.map(id => `${id} (${item.passesByItem[id] ?? 0}/${item.runs})`).join(", ") || "none"} |`),
24
- "",
25
- `## Binary search (${report.search.criterion}; samples ${report.search.sampleIds.join(", ")})`,
26
- "",
27
- `Status: **${report.search.status}**${report.search.error ? ` — ${report.search.error}` : ""}. Search pick: ${report.search.selected ? `\`${report.search.selected.model}\`` : "none"}.`,
28
- "",
29
- "| # | Stage | Model | ECI | Passes | Verdict | Cost |",
30
- "|---|---|---|---|---|---|---|",
31
- ...report.search.trials.map((trial, index) => `| ${index + 1} | ${trial.stage} | ${link(`\`${trial.model}\``, trial.experimentUrl)} | ${eciCell(trial.eci, trial.eciSource)} | ${trial.passes}/${trial.total} | ${trial.accepted ? "pass" : "fail"} | ${usd(trial.costUsd, 4)} |`),
32
- ...(report.search.unavailable.length ? ["", "Skipped (could not run, untested):", "", ...report.search.unavailable.map(item => `- \`${item.model}\` (${item.stage}): ${item.reason.slice(0, 200)}`)] : []),
33
- "",
34
- `Rule: ${report.search.finalRule}`,
35
- "",
36
- "## Limitations",
37
- "",
38
- ...report.limitations.map(item => `- ${item}`),
39
- "",
40
- ];
41
- return lines.join("\n");
42
- }
@@ -1,148 +0,0 @@
1
- import { type LangfuseConnector } from "../connectors/langfuse/index.js";
2
- import { type CandidateFilter, type RunnableModel } from "./candidates.js";
3
- import { type ModelCaller, type PiTaskSpec } from "./evaluators/pi.js";
4
- import { type ScoutReport } from "./pipeline.js";
5
- import { type EvalItem } from "./search.js";
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.js").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.js").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.js").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
- }>;
@@ -1,143 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
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":[]}}