openmerit 0.3.11 → 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.
Files changed (69) hide show
  1. package/README.md +74 -22
  2. package/data/eci-estimates.csv +179 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +180 -0
  5. package/dist/connectors/langfuse/client.d.ts +42 -0
  6. package/dist/connectors/langfuse/client.js +141 -0
  7. package/dist/connectors/langfuse/index.d.ts +163 -0
  8. package/dist/connectors/langfuse/index.js +378 -0
  9. package/dist/connectors/langfuse/otlp.d.ts +60 -0
  10. package/dist/connectors/langfuse/otlp.js +40 -0
  11. package/dist/index.d.ts +11 -0
  12. package/dist/index.js +11 -0
  13. package/dist/pi/extension.d.ts +13 -0
  14. package/dist/pi/extension.js +148 -0
  15. package/dist/pi/harness.d.ts +20 -0
  16. package/dist/pi/harness.js +49 -0
  17. package/dist/pi/instructions.d.ts +2 -0
  18. package/dist/pi/instructions.js +12 -0
  19. package/dist/scout/candidates.d.ts +62 -0
  20. package/dist/scout/candidates.js +90 -0
  21. package/dist/scout/eci.d.ts +42 -0
  22. package/dist/scout/eci.js +84 -0
  23. package/dist/scout/evaluators/module.d.ts +24 -0
  24. package/dist/scout/evaluators/module.js +19 -0
  25. package/dist/scout/evaluators/pi.d.ts +53 -0
  26. package/dist/scout/evaluators/pi.js +117 -0
  27. package/dist/scout/pipeline.d.ts +111 -0
  28. package/dist/scout/pipeline.js +157 -0
  29. package/dist/scout/report.d.ts +3 -0
  30. package/dist/scout/report.js +42 -0
  31. package/dist/scout/search.d.ts +110 -0
  32. package/dist/scout/search.js +205 -0
  33. package/dist/scout/service.d.ts +148 -0
  34. package/dist/scout/service.js +143 -0
  35. package/examples/health-declaration-codes/dataset.jsonl +14 -0
  36. package/examples/sahra-enrollment/evaluator.ts +151 -0
  37. package/package.json +34 -78
  38. package/CHANGELOG.md +0 -255
  39. package/dist/pi/scout-extension.mjs +0 -287
  40. package/dist/pi/src/index.d.ts +0 -6
  41. package/dist/pi/src/index.js +0 -56
  42. package/dist/terminal/scout-update.json +0 -1
  43. package/dist/terminal/src/browser.d.mts +0 -9
  44. package/dist/terminal/src/browser.mjs +0 -28
  45. package/dist/terminal/src/cli.mjs +0 -28
  46. package/dist/terminal/src/connections.js +0 -151
  47. package/dist/terminal/src/install.d.mts +0 -2
  48. package/dist/terminal/src/install.mjs +0 -524
  49. package/dist/terminal/src/scout-claude.js +0 -25
  50. package/dist/terminal/src/scout-credentials.js +0 -41
  51. package/dist/terminal/src/scout-instructions.js +0 -213
  52. package/dist/terminal/src/scout-pi.js +0 -84
  53. package/dist/terminal/src/scout.js +0 -229
  54. package/dist/terminal/src/telemetry.d.mts +0 -30
  55. package/dist/terminal/src/telemetry.mjs +0 -138
  56. package/dist/terminal/src/updates.js +0 -397
  57. package/docs/agent-evaluations.md +0 -15
  58. package/docs/commands.md +0 -9
  59. package/docs/getting-started.md +0 -37
  60. package/docs/index.md +0 -25
  61. package/docs/inference-terminal.md +0 -25
  62. package/docs/pi-extension.md +0 -58
  63. package/docs/roadmap.md +0 -16
  64. package/docs/scout-close-loop.md +0 -371
  65. package/docs/scout-data-policy.md +0 -87
  66. package/docs/scout.md +0 -237
  67. package/docs/security.md +0 -107
  68. package/docs/testing.md +0 -114
  69. package/docs/troubleshooting.md +0 -50
@@ -0,0 +1,148 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type } from "typebox";
3
+ import { candidates, connections, plan, previewDataset, run, saveDataset } from "../scout/service.js";
4
+ import { SCOUT_INSTRUCTIONS } from "./instructions.js";
5
+ const json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: undefined });
6
+ const filterSchema = Type.Object({
7
+ providers: Type.Optional(Type.Array(Type.String(), { description: "Connected Pi providers to draw candidates from, e.g. [\"openrouter\"] or [\"anthropic\"]" })),
8
+ tools: Type.Optional(Type.Boolean({ description: "The production call uses tool calls" })),
9
+ maxPrice: Type.Optional(Type.Number({ description: "Max list price, USD per million input+output tokens" })),
10
+ include: Type.Optional(Type.Array(Type.String(), { description: "Keep only models whose provider/id contains one of these" })),
11
+ exclude: Type.Optional(Type.Array(Type.String(), { description: "Drop models whose provider/id contains one of these" })),
12
+ });
13
+ const runSchema = Type.Object({
14
+ task: Type.String({ description: "One paragraph: what the model does in production, its inputs and outputs, and what counts as correct" }),
15
+ langfuseDataset: Type.Optional(Type.String({ description: "Existing Langfuse dataset of labelled items" })),
16
+ datasetPath: Type.Optional(Type.String({ description: "Local JSONL of {id?, input, expectedOutput}, pushed to Langfuse before the run" })),
17
+ evaluator: Type.Object({
18
+ type: StringEnum(["pi", "module"], { description: "pi: one model call per item through Pi; module: the project's own harness evaluator" }),
19
+ systemPrompt: Type.Optional(Type.String({ description: "pi: the production system prompt the candidate receives" })),
20
+ inputTemplate: Type.Optional(Type.String({ description: "pi: user message template; {{input}} is the item input" })),
21
+ grader: Type.Optional(Type.Object({
22
+ type: StringEnum(["exact", "contains", "json", "judge"], { description: "json: every expected field must match, strings case-insensitively, arrays as sets (order ignored, same length); exact/contains: normalized text; judge: Scout's model applies the rubric" }),
23
+ field: Type.Optional(Type.String()),
24
+ rubric: Type.Optional(Type.String({ description: "judge: what a correct output must do" })),
25
+ })),
26
+ path: Type.Optional(Type.String({ description: "module: path to an evaluator module, relative to the project" })),
27
+ }),
28
+ filter: Type.Optional(filterSchema),
29
+ sampleSize: Type.Optional(Type.Integer({ minimum: 2, maximum: 20 })),
30
+ finalists: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
31
+ repeats: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, description: "Runs of every item per finalist (default 3)" })),
32
+ });
33
+ function host(ctx, options) {
34
+ const registry = ctx.modelRegistry;
35
+ const caller = { getModel: (provider, id) => registry.find(provider, id), complete: (model, context, extra) => registry.complete(model, context, extra) };
36
+ const scoutModel = options.scoutModel ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
37
+ return {
38
+ caller, cwd: ctx.cwd, env: process.env, log: options.log ?? (() => { }),
39
+ models: async () => registry.getAvailable().map(model => ({ provider: model.provider, id: model.id, name: model.name, cost: model.cost })),
40
+ ...(scoutModel ? { scoutModel } : {}),
41
+ };
42
+ }
43
+ function toRunParams(params) {
44
+ const { evaluator } = params;
45
+ let spec;
46
+ if (evaluator.type === "module") {
47
+ if (!evaluator.path)
48
+ throw new Error("evaluator.path is required for a module evaluator");
49
+ spec = { type: "module", path: evaluator.path };
50
+ }
51
+ else {
52
+ if (!evaluator.systemPrompt || !evaluator.grader)
53
+ throw new Error("A pi evaluator needs systemPrompt and grader");
54
+ const { type, field, rubric } = evaluator.grader;
55
+ const grader = type === "judge" ? { type: "judge", rubric: rubric ?? "The output must agree with the expected output." }
56
+ : type === "exact" ? { type: "exact", ...(field ? { field } : {}) }
57
+ : type === "contains" ? { type: "contains", ...(field ? { field } : {}) } : { type: "json" };
58
+ spec = { type: "pi", systemPrompt: evaluator.systemPrompt, grader, ...(evaluator.inputTemplate ? { inputTemplate: evaluator.inputTemplate } : {}) };
59
+ }
60
+ if (!params.langfuseDataset && !params.datasetPath)
61
+ throw new Error("Give langfuseDataset or datasetPath");
62
+ return {
63
+ task: params.task, evaluator: spec,
64
+ ...(params.langfuseDataset ? { langfuseDataset: params.langfuseDataset } : {}), ...(params.datasetPath ? { datasetPath: params.datasetPath } : {}),
65
+ ...(params.filter ? { filter: Object.fromEntries(Object.entries(params.filter).filter(([, value]) => value !== undefined)) } : {}),
66
+ ...(params.sampleSize ? { sampleSize: params.sampleSize } : {}), ...(params.finalists ? { finalists: params.finalists } : {}), ...(params.repeats ? { repeats: params.repeats } : {}),
67
+ };
68
+ }
69
+ /** OpenMerit Scout inside Pi: model-recommendation tools, a `/scout` command and Scout's instructions. */
70
+ export function createScoutExtension(options = {}) {
71
+ const allowSpend = options.allowSpend ?? "ask";
72
+ return (pi) => {
73
+ pi.registerTool({
74
+ name: "scout_connections",
75
+ label: "Scout connections",
76
+ description: "Lists the model providers OpenMerit can run (API keys and subscriptions connected in Pi) and the Langfuse project and datasets.",
77
+ promptSnippet: "See which model providers and Langfuse datasets Scout can use",
78
+ parameters: Type.Object({}),
79
+ async execute(_id, _params, _signal, _update, ctx) { return json(await connections(host(ctx, options))); },
80
+ });
81
+ pi.registerTool({
82
+ name: "scout_candidates",
83
+ label: "Scout candidates",
84
+ description: "Ranks the runnable models by the Epoch Capabilities Index (ECI), weakest first, after the filter: Epoch's published score, or ScoutBM's estimated/calibrated ECI bundled with the package. Shows how many candidates the binary search will cover.",
85
+ promptSnippet: "Rank runnable candidate models by public capability (ECI)",
86
+ parameters: filterSchema,
87
+ async execute(_id, params, _signal, _update, ctx) {
88
+ const filter = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined));
89
+ const ranked = await candidates(host(ctx, options), filter);
90
+ const short = (list) => list.map(item => `${item.model} ECI ${item.eci.toFixed(1)}${item.eciSource === "published" ? "" : ` (${item.eciSource} ±${item.eciError?.toFixed(1)})`} $${item.price?.toFixed(2) ?? "?"}/M`);
91
+ return json({ count: ranked.candidates.length, counts: ranked.counts, weakest: short(ranked.candidates.slice(0, 8)), strongest: short(ranked.candidates.slice(-8)), unranked: ranked.unranked.length, dropped: ranked.dropped.length, eci: ranked.eci });
92
+ },
93
+ });
94
+ pi.registerTool({
95
+ name: "scout_dataset",
96
+ label: "Scout dataset",
97
+ description: "Previews a labelled eval dataset: a Langfuse dataset by name or a local JSONL of {id?, input, expectedOutput}. Scout needs at least 5 labelled items.",
98
+ promptSnippet: "Preview a labelled eval dataset (Langfuse or local JSONL)",
99
+ parameters: Type.Object({ langfuseDataset: Type.Optional(Type.String()), path: Type.Optional(Type.String()) }),
100
+ async execute(_id, params, _signal, _update, ctx) {
101
+ return json(await previewDataset(host(ctx, options), { ...(params.langfuseDataset ? { langfuseDataset: params.langfuseDataset } : {}), ...(params.path ? { path: params.path } : {}) }));
102
+ },
103
+ });
104
+ pi.registerTool({
105
+ name: "scout_save_dataset",
106
+ label: "Scout save dataset",
107
+ description: "Saves labelled items Scout drafted from the codebase (fixtures, tests, examples) to .openmerit/datasets/<name>.jsonl. Each item is marked as a Scout draft; label only what the code or fixtures state.",
108
+ promptSnippet: "Save drafted labelled items as a local eval dataset",
109
+ parameters: Type.Object({
110
+ name: Type.String(),
111
+ items: Type.Array(Type.Object({ id: Type.Optional(Type.String()), input: Type.Unknown(), expectedOutput: Type.Unknown() }), { minItems: 1 }),
112
+ }),
113
+ async execute(_id, params, _signal, _update, ctx) {
114
+ return json(await saveDataset(host(ctx, options), params.name, params.items.map(item => ({ ...(item.id ? { id: item.id } : {}), input: item.input, expectedOutput: item.expectedOutput }))));
115
+ },
116
+ });
117
+ pi.registerTool({
118
+ name: "scout_run",
119
+ label: "Scout run",
120
+ description: "Runs Scout Model Binary Search: ECI-ranked candidates, 5 sampled items per probe (pass = 5/5), 5 finalists on every item 3 times, each recorded as a Langfuse experiment; the winner is read back from Langfuse. Spends provider credit; without approval it returns the plan only.",
121
+ promptSnippet: "Binary-search the candidates on labelled items and pick the winner from Langfuse",
122
+ executionMode: "sequential",
123
+ parameters: runSchema,
124
+ async execute(_id, params, signal, onUpdate, ctx) {
125
+ const scoutHost = host(ctx, options);
126
+ const runParams = toRunParams(params);
127
+ const planned = await plan(scoutHost, runParams);
128
+ const summary = { candidates: planned.ranked.candidates.length, datasetSize: planned.datasetSize, estimatedEvaluations: planned.estimate.evaluations, filter: planned.filter, evaluator: planned.plugin?.name ?? "pi" };
129
+ let approved = allowSpend === true;
130
+ if (allowSpend === "ask" && ctx.hasUI)
131
+ approved = await ctx.ui.confirm("Run Scout?", `About ${summary.estimatedEvaluations} evaluations over ${summary.candidates} candidates on ${summary.datasetSize} items. This spends provider credit.`);
132
+ if (!approved)
133
+ return json({ status: "plan_only", ...summary, note: "Not run: spending was not approved. Rerun headless with --execute, or approve in the dialog." });
134
+ const progress = (line) => { scoutHost.log(line); onUpdate?.({ content: [{ type: "text", text: line }], details: undefined }); };
135
+ const result = await run({ ...scoutHost, log: progress }, runParams, signal);
136
+ return { content: [{ type: "text", text: `${result.markdown}\nReport files: ${result.files.markdown}, ${result.files.json}` }], details: undefined };
137
+ },
138
+ });
139
+ pi.registerCommand("scout", {
140
+ description: "Ask OpenMerit Scout which model to use for a task",
141
+ handler: async (args) => { pi.sendUserMessage(`Use OpenMerit Scout to recommend a model for this task: ${args || "the model calls in this codebase"}`); },
142
+ });
143
+ pi.on("before_agent_start", (event) => {
144
+ event.systemPromptOptions.sections.openmerit_scout = SCOUT_INSTRUCTIONS;
145
+ });
146
+ };
147
+ }
148
+ export default createScoutExtension();
@@ -0,0 +1,20 @@
1
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import type { RunnableModel } from "../scout/candidates.ts";
3
+ import type { ModelCaller } from "../scout/evaluators/pi.ts";
4
+ /**
5
+ * OpenMerit's changes to a stock Pi process, applied before Pi reads its environment:
6
+ * - its own agent directory (~/.openmerit/agent, or $OPENMERIT_HOME/agent), so OpenMerit's
7
+ * logins and settings never touch a separate Pi installation; PI_CODING_AGENT_DIR still wins;
8
+ * - a Claude Code subscription token (CLAUDE_CODE_OAUTH_TOKEN) works as the Anthropic
9
+ * subscription credential when no Anthropic key or token is set;
10
+ * - Pi's own update check is off, because this package pins Pi.
11
+ */
12
+ export declare function configureHarness(env?: Record<string, string | undefined>): {
13
+ agentDir: string;
14
+ };
15
+ export declare function openRuntime(): Promise<{
16
+ runtime: ModelRuntime;
17
+ caller: ModelCaller;
18
+ models: () => Promise<RunnableModel[]>;
19
+ }>;
20
+ export declare function pickScoutModel(runtime: ModelRuntime, requested?: string): Promise<import("@earendil-works/pi-ai").Model<import("@earendil-works/pi-ai").Api>>;
@@ -0,0 +1,49 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
4
+ /**
5
+ * OpenMerit's changes to a stock Pi process, applied before Pi reads its environment:
6
+ * - its own agent directory (~/.openmerit/agent, or $OPENMERIT_HOME/agent), so OpenMerit's
7
+ * logins and settings never touch a separate Pi installation; PI_CODING_AGENT_DIR still wins;
8
+ * - a Claude Code subscription token (CLAUDE_CODE_OAUTH_TOKEN) works as the Anthropic
9
+ * subscription credential when no Anthropic key or token is set;
10
+ * - Pi's own update check is off, because this package pins Pi.
11
+ */
12
+ export function configureHarness(env = process.env) {
13
+ env.PI_CODING_AGENT_DIR ??= join(env.OPENMERIT_HOME ?? join(homedir(), ".openmerit"), "agent");
14
+ if (env.CLAUDE_CODE_OAUTH_TOKEN && !env.ANTHROPIC_API_KEY && !env.ANTHROPIC_OAUTH_TOKEN && !env.ANTHROPIC_AUTH_TOKEN) {
15
+ env.ANTHROPIC_OAUTH_TOKEN = env.CLAUDE_CODE_OAUTH_TOKEN;
16
+ }
17
+ env.PI_SKIP_VERSION_CHECK ??= "1";
18
+ return { agentDir: env.PI_CODING_AGENT_DIR };
19
+ }
20
+ /** Scout runs on the strongest connected general model unless the user names one. */
21
+ const SCOUT_MODEL_PREFERENCE = [
22
+ "anthropic/claude-opus-5-5", "anthropic/claude-sonnet-5-5", "openai/gpt-6.1-sol", "openai/gpt-6-sol",
23
+ "openrouter/anthropic/claude-opus-5.5", "openrouter/anthropic/claude-sonnet-5.5", "openrouter/openai/gpt-6.1-sol",
24
+ ];
25
+ export async function openRuntime() {
26
+ const runtime = await ModelRuntime.create({ allowModelNetwork: true, modelRefreshTimeoutMs: 20_000 });
27
+ const caller = { getModel: (provider, id) => runtime.getModel(provider, id), complete: (model, context, extra) => runtime.complete(model, context, extra) };
28
+ const models = async () => (await runtime.getAvailable()).map(model => ({ provider: model.provider, id: model.id, name: model.name, cost: model.cost }));
29
+ return { runtime, caller, models };
30
+ }
31
+ export async function pickScoutModel(runtime, requested) {
32
+ const available = await runtime.getAvailable();
33
+ const ref = (model) => `${model.provider}/${model.id}`;
34
+ if (requested) {
35
+ const model = available.find(item => ref(item) === requested);
36
+ if (!model)
37
+ throw new Error(`Scout model ${requested} is not available with the connected providers. Run \`openmerit status\`.`);
38
+ return model;
39
+ }
40
+ for (const wanted of SCOUT_MODEL_PREFERENCE) {
41
+ const model = available.find(item => ref(item) === wanted);
42
+ if (model)
43
+ return model;
44
+ }
45
+ const model = available[0];
46
+ if (!model)
47
+ throw new Error("No model provider is connected. Set an API key (for example OPENROUTER_API_KEY or ANTHROPIC_API_KEY) or run `openmerit login` for a subscription.");
48
+ return model;
49
+ }
@@ -0,0 +1,2 @@
1
+ /** Scout's workflow, added to Pi's system prompt as the `openmerit_scout` section. */
2
+ export declare const SCOUT_INSTRUCTIONS = "You are OpenMerit Scout. You recommend the model a user should run for a task, from measured results on the user's own labelled examples, not from memory.\n\nWorkflow when asked which model to use:\n1. Understand the task. If a codebase is present, find where the model is called (grep for model IDs, SDK calls, \"systemPrompt\", \"model:\"), the production prompt, whether it calls tools, its output format, and existing evals: fixtures, tests with expected outputs, Langfuse datasets (scout_connections lists them), or .openmerit/datasets/*.jsonl.\n2. Find labelled data. Prefer an existing Langfuse dataset or fixture set with expected outputs. Preview it with scout_dataset; Scout needs at least 5 items. If none exists, draft at least 6 items only from what the code, fixtures or the user state, save them with scout_save_dataset, and say they are drafts the user should check. Never invent labels you cannot justify.\n3. Choose the evaluator. If the project ships an evaluator module that runs its real harness (for example .openmerit/evaluator.ts or examples/*/evaluator.ts), use evaluator type \"module\". Otherwise use type \"pi\" with the production system prompt and the strictest grader that fits: \"json\" for structured outputs, \"exact\"/\"contains\" for short answers, \"judge\" with a precise rubric only for free text.\n4. Check candidates with scout_candidates. Filter only for real constraints: providers the user can run, tools when the call uses tools, a price ceiling the user gave, data-residency or vendor exclusions the user named. Do not narrow the pool from your own preferences.\n5. Run scout_run with the task, dataset and evaluator. It binary-searches the ECI-ranked candidates (pass = every sampled item), screens neighbours, runs 5 finalists (plus cheaper, stronger challengers) on every item 3 times, records each as a Langfuse experiment, and picks the winner from Langfuse: most passes, then lower measured cost per item, then lower ECI.\n6. Answer with the recommended model ID, its passes and cost per item, the runner-up, the Langfuse experiment links, the report path, and the limitations the report lists (sample size, untested skipped models, drafted labels, residency or policy gaps). If scout_run returns plan_only, report the plan and the command to run it.\n\nRules: measured results on the user's data outrank public benchmarks. Provider, auth or billing errors are not quality failures; report them. Never print API keys or tokens.";
@@ -0,0 +1,12 @@
1
+ /** Scout's workflow, added to Pi's system prompt as the `openmerit_scout` section. */
2
+ export const SCOUT_INSTRUCTIONS = `You are OpenMerit Scout. You recommend the model a user should run for a task, from measured results on the user's own labelled examples, not from memory.
3
+
4
+ Workflow when asked which model to use:
5
+ 1. Understand the task. If a codebase is present, find where the model is called (grep for model IDs, SDK calls, "systemPrompt", "model:"), the production prompt, whether it calls tools, its output format, and existing evals: fixtures, tests with expected outputs, Langfuse datasets (scout_connections lists them), or .openmerit/datasets/*.jsonl.
6
+ 2. Find labelled data. Prefer an existing Langfuse dataset or fixture set with expected outputs. Preview it with scout_dataset; Scout needs at least 5 items. If none exists, draft at least 6 items only from what the code, fixtures or the user state, save them with scout_save_dataset, and say they are drafts the user should check. Never invent labels you cannot justify.
7
+ 3. Choose the evaluator. If the project ships an evaluator module that runs its real harness (for example .openmerit/evaluator.ts or examples/*/evaluator.ts), use evaluator type "module". Otherwise use type "pi" with the production system prompt and the strictest grader that fits: "json" for structured outputs, "exact"/"contains" for short answers, "judge" with a precise rubric only for free text.
8
+ 4. Check candidates with scout_candidates. Filter only for real constraints: providers the user can run, tools when the call uses tools, a price ceiling the user gave, data-residency or vendor exclusions the user named. Do not narrow the pool from your own preferences.
9
+ 5. Run scout_run with the task, dataset and evaluator. It binary-searches the ECI-ranked candidates (pass = every sampled item), screens neighbours, runs 5 finalists (plus cheaper, stronger challengers) on every item 3 times, records each as a Langfuse experiment, and picks the winner from Langfuse: most passes, then lower measured cost per item, then lower ECI.
10
+ 6. Answer with the recommended model ID, its passes and cost per item, the runner-up, the Langfuse experiment links, the report path, and the limitations the report lists (sample size, untested skipped models, drafted labels, residency or policy gaps). If scout_run returns plan_only, report the plan and the command to run it.
11
+
12
+ Rules: measured results on the user's data outrank public benchmarks. Provider, auth or billing errors are not quality failures; report them. Never print API keys or tokens.`;
@@ -0,0 +1,62 @@
1
+ import type { EciEstimate, EciRow } from "./eci.ts";
2
+ /** A model the user's connections can run, placed on the ECI scale. */
3
+ export interface Candidate {
4
+ /** `provider/id` as Pi names it, such as `openrouter/deepseek/deepseek-v4-pro` or `anthropic/claude-sonnet-5-5`. */
5
+ model: string;
6
+ provider: string;
7
+ id: string;
8
+ name: string;
9
+ /** Epoch Capabilities Index: published, or ScoutBM's estimate from the bundled CSV. */
10
+ eci: number;
11
+ /** The Epoch row, or the catalogue model the estimate belongs to. */
12
+ eciModel: string;
13
+ eciSource: "published" | "estimated" | "calibrated";
14
+ /** Standard error of an estimated or calibrated ECI. */
15
+ eciError?: number;
16
+ /** List price in USD per million input plus output tokens; null when unknown. Measured cost decides ties, not this. */
17
+ price: number | null;
18
+ }
19
+ /** The parts of a Pi model this module reads. */
20
+ export interface RunnableModel {
21
+ provider: string;
22
+ id: string;
23
+ name: string;
24
+ cost?: {
25
+ input: number;
26
+ output: number;
27
+ };
28
+ }
29
+ export interface CandidateFilter {
30
+ /** Connected providers to draw candidates from, in preference order when two serve the same model. */
31
+ providers?: string[];
32
+ /** The task calls tools: drop models known not to support tool calls. */
33
+ tools?: boolean;
34
+ /** `provider/id` → supports tool calls. Models missing from the map are kept. */
35
+ toolSupport?: ReadonlyMap<string, boolean>;
36
+ /** Drop models whose list price (input + output per million tokens) is above this. */
37
+ maxPrice?: number;
38
+ /** Case-insensitive substrings; a model whose `provider/id` contains any is dropped. */
39
+ exclude?: string[];
40
+ /** Keep only models whose `provider/id` contains one of these. */
41
+ include?: string[];
42
+ }
43
+ /** Exact names only, ignoring case and punctuation: "GPT-6.1 Sol" = "gpt-6.1-sol", never "GPT-6.1". */
44
+ export declare const nameKey: (name: string) => string;
45
+ /**
46
+ * Every runnable model with an ECI, ascending. A published Epoch row wins; otherwise ScoutBM's
47
+ * estimated or calibrated ECI from the bundled CSV, matched by OpenRouter ID or exact name. Each
48
+ * Epoch row or estimate is used by at most one model: the first connected provider, then the
49
+ * cheapest listing. Models with neither are reported as unranked, never guessed.
50
+ */
51
+ export declare function rankCandidates(models: readonly RunnableModel[], rows: readonly EciRow[], filter?: CandidateFilter, estimates?: readonly EciEstimate[]): {
52
+ candidates: Candidate[];
53
+ unranked: string[];
54
+ dropped: {
55
+ model: string;
56
+ reason: string;
57
+ }[];
58
+ };
59
+ /** Ascending ECI; equal scores fall back to model ID purely for repeatability. */
60
+ export declare function rank(candidates: readonly Candidate[]): Candidate[];
61
+ /** OpenRouter's public model list says which models accept tool calls. No key is needed. */
62
+ export declare function openRouterToolSupport(request?: typeof fetch): Promise<Map<string, boolean>>;
@@ -0,0 +1,90 @@
1
+ /** Exact names only, ignoring case and punctuation: "GPT-6.1 Sol" = "gpt-6.1-sol", never "GPT-6.1". */
2
+ export const nameKey = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
3
+ /**
4
+ * Aliases (`~…`, `-latest`), batch-only and free-tier variants are not separate models,
5
+ * and free tiers are rate limited and may train on prompts.
6
+ */
7
+ const ALIAS = /(^~|-latest$|:batch$|:free$|:extended$)/;
8
+ /**
9
+ * Every runnable model with an ECI, ascending. A published Epoch row wins; otherwise ScoutBM's
10
+ * estimated or calibrated ECI from the bundled CSV, matched by OpenRouter ID or exact name. Each
11
+ * Epoch row or estimate is used by at most one model: the first connected provider, then the
12
+ * cheapest listing. Models with neither are reported as unranked, never guessed.
13
+ */
14
+ export function rankCandidates(models, rows, filter = {}, estimates = []) {
15
+ const byName = new Map();
16
+ for (const row of rows)
17
+ for (const name of row.names)
18
+ byName.set(nameKey(name), (byName.get(nameKey(name)) ?? new Set()).add(row));
19
+ const providers = filter.providers ?? [...new Set(models.map(model => model.provider))];
20
+ const lower = (list) => list?.map(item => item.toLowerCase());
21
+ const include = lower(filter.include), exclude = lower(filter.exclude);
22
+ const unranked = [];
23
+ const dropped = [];
24
+ const best = new Map();
25
+ const estimateById = new Map(estimates.flatMap(item => [[item.catalogueModel.toLowerCase(), item], ...(item.openRouterId ? [[item.openRouterId.toLowerCase(), item]] : [])]));
26
+ const estimateByName = new Map();
27
+ for (const item of estimates)
28
+ for (const name of [item.name, item.catalogueModel.split("/").at(-1)])
29
+ estimateByName.set(nameKey(name), (estimateByName.get(nameKey(name)) ?? new Set()).add(item));
30
+ for (const model of models) {
31
+ const full = `${model.provider}/${model.id}`;
32
+ if (!providers.includes(model.provider) || ALIAS.test(model.id))
33
+ continue;
34
+ const id = full.toLowerCase();
35
+ if (include && !include.some(item => id.includes(item)))
36
+ continue;
37
+ if (exclude?.some(item => id.includes(item))) {
38
+ dropped.push({ model: full, reason: "excluded" });
39
+ continue;
40
+ }
41
+ if (filter.tools && filter.toolSupport?.get(full) === false) {
42
+ dropped.push({ model: full, reason: "no tool calls" });
43
+ continue;
44
+ }
45
+ const price = model.cost && Number.isFinite(model.cost.input + model.cost.output) ? model.cost.input + model.cost.output : null;
46
+ if (filter.maxPrice !== undefined && price !== null && price > filter.maxPrice) {
47
+ dropped.push({ model: full, reason: `price ${price} > ${filter.maxPrice}` });
48
+ continue;
49
+ }
50
+ const names = [model.name.replace(/^[^:]+:\s*/, ""), model.name, model.id.split("/").at(-1)];
51
+ const matches = new Set(names.flatMap(name => [...byName.get(nameKey(name)) ?? []]));
52
+ let row;
53
+ let candidate;
54
+ if (matches.size === 1) {
55
+ const published = [...matches][0];
56
+ row = published;
57
+ candidate = { model: full, provider: model.provider, id: model.id, name: model.name, eci: published.eci, eciModel: published.model, eciSource: "published", price };
58
+ }
59
+ else {
60
+ const byId = model.provider === "openrouter" ? estimateById.get(model.id.toLowerCase()) : undefined;
61
+ const byNames = new Set(names.flatMap(name => [...estimateByName.get(nameKey(name)) ?? []]));
62
+ const estimate = byId ?? (byNames.size === 1 ? [...byNames][0] : undefined);
63
+ if (!estimate) {
64
+ unranked.push(full);
65
+ continue;
66
+ }
67
+ row = estimate;
68
+ candidate = { model: full, provider: model.provider, id: model.id, name: model.name, eci: estimate.eci, eciModel: estimate.catalogueModel, eciSource: estimate.source, eciError: estimate.standardError, price };
69
+ }
70
+ const current = best.get(row);
71
+ const better = !current
72
+ || providers.indexOf(candidate.provider) < providers.indexOf(current.provider)
73
+ || (candidate.provider === current.provider && (candidate.price ?? Infinity) < (current.price ?? Infinity));
74
+ if (better)
75
+ best.set(row, candidate);
76
+ }
77
+ return { candidates: rank([...best.values()]), unranked: unranked.sort(), dropped };
78
+ }
79
+ /** Ascending ECI; equal scores fall back to model ID purely for repeatability. */
80
+ export function rank(candidates) {
81
+ return [...candidates].sort((a, b) => a.eci - b.eci || a.model.localeCompare(b.model));
82
+ }
83
+ /** OpenRouter's public model list says which models accept tool calls. No key is needed. */
84
+ export async function openRouterToolSupport(request = fetch) {
85
+ const response = await request("https://openrouter.ai/api/v1/models", { signal: AbortSignal.timeout(30_000) });
86
+ if (!response.ok)
87
+ throw new Error(`OpenRouter models HTTP ${response.status}`);
88
+ const body = await response.json();
89
+ return new Map((body.data ?? []).map(model => [`openrouter/${model.id}`, (model.supported_parameters ?? []).includes("tools")]));
90
+ }
@@ -0,0 +1,42 @@
1
+ export declare const ECI_SCORES_URL = "https://epoch.ai/data/eci_scores.csv";
2
+ export declare const ECI_ATTRIBUTION = "Epoch AI, 'Epoch Capabilities Index'. Published online at epoch.ai. CC BY 4.0.";
3
+ export interface EciRow {
4
+ /** Epoch's model key. */
5
+ model: string;
6
+ /** Every name Epoch lists for the model: key, display name and model versions. */
7
+ names: string[];
8
+ eci: number;
9
+ ciLow: number | null;
10
+ ciHigh: number | null;
11
+ date: string | null;
12
+ }
13
+ /** RFC 4180 fields: commas, quotes and newlines inside quoted values. */
14
+ export declare function parseCSV(text: string): Record<string, string>[];
15
+ export declare function parseEciScores(text: string): EciRow[];
16
+ export interface EciSource {
17
+ source: string;
18
+ url: string;
19
+ attribution: string;
20
+ fetchedAt: string;
21
+ rows: EciRow[];
22
+ }
23
+ export declare function loadEci(request?: typeof fetch): Promise<EciSource>;
24
+ /** ScoutBM's estimated or calibrated ECI for a model Epoch does not score, from the bundled CSV. */
25
+ export interface EciEstimate {
26
+ catalogueModel: string;
27
+ name: string;
28
+ openRouterId: string | null;
29
+ eci: number;
30
+ source: "estimated" | "calibrated";
31
+ standardError: number;
32
+ benchmarks: string[];
33
+ }
34
+ export declare function parseEciEstimates(text: string): {
35
+ provenance: string;
36
+ estimates: EciEstimate[];
37
+ };
38
+ /** The CSV shipped in the package (data/eci-estimates.csv): no network, no Cloudflare. */
39
+ export declare function loadEciEstimates(): Promise<{
40
+ provenance: string;
41
+ estimates: EciEstimate[];
42
+ }>;
@@ -0,0 +1,84 @@
1
+ // Epoch Capabilities Index (ECI): the public, openly licensed capability ranking Scout's binary
2
+ // search orders candidates by. Ported from the ScoutBM prototype (published scores only).
3
+ export const ECI_SCORES_URL = "https://epoch.ai/data/eci_scores.csv";
4
+ export const ECI_ATTRIBUTION = "Epoch AI, 'Epoch Capabilities Index'. Published online at epoch.ai. CC BY 4.0.";
5
+ /** RFC 4180 fields: commas, quotes and newlines inside quoted values. */
6
+ export function parseCSV(text) {
7
+ const rows = [];
8
+ let row = [], field = "", quoted = false;
9
+ for (let index = 0; index < text.length; index++) {
10
+ const char = text[index];
11
+ if (quoted) {
12
+ if (char === '"' && text[index + 1] === '"') {
13
+ field += '"';
14
+ index++;
15
+ }
16
+ else if (char === '"')
17
+ quoted = false;
18
+ else
19
+ field += char;
20
+ }
21
+ else if (char === '"')
22
+ quoted = true;
23
+ else if (char === ",") {
24
+ row.push(field);
25
+ field = "";
26
+ }
27
+ else if (char === "\n" || char === "\r") {
28
+ if (char === "\r" && text[index + 1] === "\n")
29
+ index++;
30
+ row.push(field);
31
+ rows.push(row);
32
+ row = [];
33
+ field = "";
34
+ }
35
+ else
36
+ field += char;
37
+ }
38
+ if (field || row.length) {
39
+ row.push(field);
40
+ rows.push(row);
41
+ }
42
+ const [header = [], ...body] = rows.filter(item => item.some(Boolean));
43
+ return body.map(cells => Object.fromEntries(header.map((name, index) => [name, (cells[index] ?? "").trim()])));
44
+ }
45
+ const num = (value) => (value && Number.isFinite(Number(value)) ? Number(value) : null);
46
+ export function parseEciScores(text) {
47
+ const rows = parseCSV(text);
48
+ const missing = ["Model", "Display name", "eci", "model_versions"].filter(name => rows[0] && !(name in rows[0]));
49
+ if (!rows.length || missing.length)
50
+ throw new Error(`Epoch eci_scores.csv is empty or missing columns: ${missing.join(", ")}`);
51
+ const scored = rows.filter(row => row.Model && num(row.eci) !== null).map(row => ({
52
+ model: row.Model, eci: num(row.eci), ciLow: num(row.eci_ci_low), ciHigh: num(row.eci_ci_high), date: row.date || null,
53
+ names: [...new Set([row.Model, row["Display name"], ...row.model_versions.split(",").map(item => item.trim())].filter(Boolean))],
54
+ }));
55
+ if (new Set(scored.map(item => item.model)).size !== scored.length)
56
+ throw new Error("Duplicate Epoch ECI model rows; retry the download");
57
+ return scored;
58
+ }
59
+ export async function loadEci(request = fetch) {
60
+ const response = await request(ECI_SCORES_URL, { signal: AbortSignal.timeout(30_000) });
61
+ if (!response.ok)
62
+ throw new Error(`Epoch ECI HTTP ${response.status}; no fallback ranking is used`);
63
+ return { source: "Epoch Capabilities Index", url: ECI_SCORES_URL, attribution: ECI_ATTRIBUTION, fetchedAt: new Date().toISOString(), rows: parseEciScores(await response.text()) };
64
+ }
65
+ export function parseEciEstimates(text) {
66
+ const lines = text.split("\n");
67
+ const provenance = lines.filter(line => line.startsWith("#")).map(line => line.slice(1).trim()).join(" ");
68
+ const rows = parseCSV(lines.filter(line => !line.startsWith("#")).join("\n"));
69
+ const estimates = rows.flatMap(row => {
70
+ const eci = num(row.eci), standardError = num(row.standard_error);
71
+ if (!row.catalogue_model || eci === null || (row.eci_source !== "estimated" && row.eci_source !== "calibrated"))
72
+ return [];
73
+ return [{
74
+ catalogueModel: row.catalogue_model, name: row.name ?? row.catalogue_model, openRouterId: row.openrouter_id || null, eci,
75
+ source: row.eci_source, standardError: standardError ?? 0, benchmarks: (row.benchmarks ?? "").split(";").map(item => item.trim()).filter(Boolean),
76
+ }];
77
+ });
78
+ return { provenance, estimates };
79
+ }
80
+ /** The CSV shipped in the package (data/eci-estimates.csv): no network, no Cloudflare. */
81
+ export async function loadEciEstimates() {
82
+ const { readFile } = await import("node:fs/promises");
83
+ return parseEciEstimates(await readFile(new URL("../../data/eci-estimates.csv", import.meta.url), "utf8"));
84
+ }
@@ -0,0 +1,24 @@
1
+ import type { Evaluate } from "../search.ts";
2
+ /**
3
+ * An evaluator that runs a candidate through the user's own harness, such as a deployed agent's
4
+ * HTTP API, and checks the result against the labelled item. A module's default export is one of these.
5
+ */
6
+ export interface EvaluatorPlugin {
7
+ name: string;
8
+ /** Pi providers the harness can run, such as ["openrouter"]. Candidates are drawn only from these. */
9
+ providers?: string[];
10
+ /** The harness calls tools, so models without tool calls are not candidates. */
11
+ tools?: boolean;
12
+ /** Score name recorded per item in Langfuse, ideally the customer's own metric (e.g. `enrollment-correct`). */
13
+ passScore?: string;
14
+ /** Experiment metadata key `model_<role>`. */
15
+ role?: string;
16
+ /** Items evaluated at once within one probe. */
17
+ concurrency?: number;
18
+ create(context: {
19
+ env: Record<string, string | undefined>;
20
+ log: (line: string) => void;
21
+ }): Evaluate | Promise<Evaluate>;
22
+ }
23
+ /** Imports a `.ts` or `.js` evaluator module from the user's project. Node.js 22.19 strips its types. */
24
+ export declare function loadEvaluatorPlugin(path: string, cwd?: string): Promise<EvaluatorPlugin>;
@@ -0,0 +1,19 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ /** Imports a `.ts` or `.js` evaluator module from the user's project. Node.js 22.19 strips its types. */
12
+ export async function loadEvaluatorPlugin(path, cwd = process.cwd()) {
13
+ const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(cwd, path)).href));
14
+ const plugin = loaded.default;
15
+ if (!plugin || typeof plugin !== "object" || typeof plugin.create !== "function" || typeof plugin.name !== "string") {
16
+ throw new Error(`${path} must default-export an evaluator: { name, create(context) => evaluate }`);
17
+ }
18
+ return plugin;
19
+ }
@@ -0,0 +1,53 @@
1
+ import type { Api, AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
2
+ import { type Evaluate } from "../search.ts";
3
+ /** What the Pi evaluator needs from Pi: a `ModelRuntime`, or an extension's `ctx.modelRegistry` through `fromRegistry`. */
4
+ export interface ModelCaller {
5
+ getModel(provider: string, id: string): Model<Api> | undefined;
6
+ complete(model: Model<Api>, context: Context, options?: {
7
+ signal?: AbortSignal;
8
+ maxTokens?: number;
9
+ apiKey?: string;
10
+ }): Promise<AssistantMessage>;
11
+ }
12
+ export type Grader =
13
+ /** Normalized text equality with `expectedOutput` (or `expectedOutput[field]`). */
14
+ {
15
+ type: "exact";
16
+ field?: string;
17
+ }
18
+ /** The output contains the expected text, case-insensitively. */
19
+ | {
20
+ type: "contains";
21
+ field?: string;
22
+ }
23
+ /** The output's JSON contains every expected field with an equal value (strings compared case-insensitively, arrays as sets). */
24
+ | {
25
+ type: "json";
26
+ }
27
+ /** A judge model answers PASS or FAIL for the output against the expected output and rubric. */
28
+ | {
29
+ type: "judge";
30
+ rubric: string;
31
+ model?: string;
32
+ };
33
+ export interface PiTaskSpec {
34
+ /** The candidate's system prompt: the instructions the production model receives. */
35
+ systemPrompt: string;
36
+ /** Optional template for the user message; `{{input}}` is the item's input as text or JSON. */
37
+ inputTemplate?: string;
38
+ grader: Grader;
39
+ maxTokens?: number;
40
+ }
41
+ /** The first JSON object or array in a reply, with code fences removed. */
42
+ export declare function extractJson(reply: string): unknown;
43
+ /** Every expected field matches: objects by key, arrays as sets, strings case-insensitively. */
44
+ export declare function jsonMatches(expected: unknown, actual: unknown): boolean;
45
+ /**
46
+ * Runs each item as one model call through Pi, so any provider Pi connects (API key or
47
+ * subscription) can be a candidate, then grades the reply. Tool-using agents need an
48
+ * evaluator module that runs the real harness instead.
49
+ */
50
+ export declare function piEvaluator(caller: ModelCaller, spec: PiTaskSpec, options?: {
51
+ judgeModel?: string;
52
+ retryDelaysMs?: number[];
53
+ }): Evaluate;