openmerit 0.1.0 → 0.1.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.
- package/README.md +92 -42
- package/dist/catalog.js +11 -1
- package/dist/cli.js +62 -8
- package/dist/daemon.js +111 -51
- package/dist/frontier.js +13 -6
- package/dist/harness.js +1 -0
- package/dist/integrations.js +19 -0
- package/dist/judge.js +5 -5
- package/dist/llm.js +159 -22
- package/dist/pi-trials.js +203 -74
- package/dist/policy.js +8 -3
- package/dist/providers.js +1 -0
- package/dist/recommend.js +27 -14
- package/dist/routes.js +59 -0
- package/dist/store.js +2 -0
- package/dist/strategist.js +18 -14
- package/dist/task-input.js +27 -3
- package/dist/traces.js +6 -2
- package/dist/trials.js +38 -8
- package/extension/openmerit.ts +116 -27
- package/instructions/OPENMERIT.md +8 -5
- package/package.json +5 -2
- package/rules.md +39 -0
package/dist/daemon.js
CHANGED
|
@@ -9,15 +9,46 @@ import { loadKey } from "./llm.js";
|
|
|
9
9
|
import { providerAllowed } from "./policy.js";
|
|
10
10
|
import { buildRecommendation } from "./recommend.js";
|
|
11
11
|
import { appendJsonl, paths, readJson, readJsonl, writeJson, } from "./store.js";
|
|
12
|
-
import {
|
|
13
|
-
import { budgetOk, ensureRubric, recordTrialSpend, runTrial } from "./trials.js";
|
|
12
|
+
import { budgetOk, ensureRubric, recordMeritSpend, recordTrialSpend, runTrial, trialBudgetOk } from "./trials.js";
|
|
14
13
|
import { JUDGE_PREFS } from "./judge.js";
|
|
15
|
-
import {
|
|
14
|
+
import { runPiTrial, scorePiRun, settledActiveTask } from "./pi-trials.js";
|
|
16
15
|
import { taskInputKey } from "./task-input.js";
|
|
17
16
|
import { pickNext, STRAT_PREFS } from "./strategist.js";
|
|
17
|
+
import { enrichRouteEntry, routeCatalog, routeKey, routeLabel } from "./routes.js";
|
|
18
|
+
import { PiCliChatClient } from "./llm.js";
|
|
19
|
+
import { LocalJsonlEventSink, PiTraceObservationSource, meritEvent } from "./integrations.js";
|
|
20
|
+
function resolveRoutePref(cat, prefs, label, requireImages = false) {
|
|
21
|
+
const available = [...cat.values()].filter((entry) => !!entry.route && entry.priceKnown !== false &&
|
|
22
|
+
(!requireImages || entry.inputModalities?.includes("image")));
|
|
23
|
+
for (const pref of prefs) {
|
|
24
|
+
if (!pref)
|
|
25
|
+
continue;
|
|
26
|
+
const routed = cat.get(pref);
|
|
27
|
+
if (routed && available.includes(routed))
|
|
28
|
+
return routed;
|
|
29
|
+
const exact = available.find((entry) => entry.id === pref);
|
|
30
|
+
if (exact)
|
|
31
|
+
return exact;
|
|
32
|
+
}
|
|
33
|
+
for (const pref of prefs) {
|
|
34
|
+
if (!pref)
|
|
35
|
+
continue;
|
|
36
|
+
const fuzzy = available.find((entry) => entry.id.includes(pref));
|
|
37
|
+
if (fuzzy)
|
|
38
|
+
return fuzzy;
|
|
39
|
+
}
|
|
40
|
+
const fallback = available.sort((a, b) => (a.priceKnown === false ? 1 : 0) - (b.priceKnown === false ? 1 : 0) || a.price - b.price)[0];
|
|
41
|
+
if (fallback)
|
|
42
|
+
return fallback;
|
|
43
|
+
throw new Error(`could not resolve ${label} route from Pi's eligible models`);
|
|
44
|
+
}
|
|
18
45
|
function emitTrialProgress(progress) {
|
|
19
46
|
console.log(`[openmerit/progress] ${JSON.stringify(progress)}`);
|
|
20
47
|
}
|
|
48
|
+
function persistTrial(point, sink) {
|
|
49
|
+
appendJsonl(paths.trials(), point);
|
|
50
|
+
sink.emit(meritEvent("trial.completed", point));
|
|
51
|
+
}
|
|
21
52
|
function resolvePref(cat, prefs, label) {
|
|
22
53
|
for (const p of prefs) {
|
|
23
54
|
if (p && cat.has(p))
|
|
@@ -34,10 +65,12 @@ function resolvePref(cat, prefs, label) {
|
|
|
34
65
|
throw new Error(`could not resolve ${label} model from preferences`);
|
|
35
66
|
}
|
|
36
67
|
/** Ingest new session-trace bytes into the observation log. */
|
|
37
|
-
export function tracesTick() {
|
|
38
|
-
const fresh =
|
|
39
|
-
for (const o of fresh)
|
|
68
|
+
export function tracesTick(source = new PiTraceObservationSource(), sink = new LocalJsonlEventSink()) {
|
|
69
|
+
const fresh = source.read();
|
|
70
|
+
for (const o of fresh) {
|
|
40
71
|
appendJsonl(paths.observations(), o);
|
|
72
|
+
sink.emit(meritEvent("observation.recorded", o, source.id));
|
|
73
|
+
}
|
|
41
74
|
if (fresh.length > 0)
|
|
42
75
|
console.log(`[openmerit] traces: ${fresh.length} new observation(s)`);
|
|
43
76
|
return fresh;
|
|
@@ -68,6 +101,7 @@ export async function catalogTick(key, policy) {
|
|
|
68
101
|
* thin). Budget-capped per policy.
|
|
69
102
|
*/
|
|
70
103
|
export async function trialTick(key, policy) {
|
|
104
|
+
const sink = new LocalJsonlEventSink();
|
|
71
105
|
const budget = budgetOk(policy);
|
|
72
106
|
if (!budget.ok) {
|
|
73
107
|
console.log(`[openmerit] trials paused: ${budget.reason}`);
|
|
@@ -106,7 +140,7 @@ export async function trialTick(key, policy) {
|
|
|
106
140
|
const rubric = await ensureRubric(key, judge, obs.taskLabel, rubricCache);
|
|
107
141
|
const { point, costUsd, error } = await runTrial(key, judge, obs.taskLabel, rubric, candidate.id, cat.get(candidate.id));
|
|
108
142
|
recordTrialSpend(costUsd);
|
|
109
|
-
|
|
143
|
+
persistTrial({ ...point, taskKey: tKey }, sink);
|
|
110
144
|
if (error)
|
|
111
145
|
console.log(`[openmerit] trial failed: ${error.slice(0, 120)}`);
|
|
112
146
|
else
|
|
@@ -115,6 +149,7 @@ export async function trialTick(key, policy) {
|
|
|
115
149
|
}
|
|
116
150
|
/** Compare the one completed active pi task, sequentially, then target its session. */
|
|
117
151
|
export async function autoTaskTick(key, policy, settledTask) {
|
|
152
|
+
const sink = new LocalJsonlEventSink();
|
|
118
153
|
const task = settledTask === undefined ? settledActiveTask() : settledTask;
|
|
119
154
|
if (!task)
|
|
120
155
|
return null;
|
|
@@ -130,70 +165,94 @@ export async function autoTaskTick(key, policy, settledTask) {
|
|
|
130
165
|
// Claim the settled turn before provider calls so the next poll cannot duplicate it.
|
|
131
166
|
writeJson(processedPath, { marker, status: "running", updatedAt: new Date().toISOString() });
|
|
132
167
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
168
|
+
let cat = routeCatalog(task.routes);
|
|
169
|
+
if (!cat.has(routeKey(task.route)))
|
|
170
|
+
cat.set(routeKey(task.route), routeCatalog([task.route]).values().next().value);
|
|
171
|
+
if (key) {
|
|
172
|
+
try {
|
|
173
|
+
const live = await fetchCatalog(key);
|
|
174
|
+
saveSnapshot(live);
|
|
175
|
+
cat = new Map([...cat].map(([id, entry]) => [id,
|
|
176
|
+
entry.route?.provider === "openrouter" ? enrichRouteEntry(entry, live.get(entry.id)) : entry]));
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.log(`[openmerit] OpenRouter enrichment unavailable: ${error.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const [id, entry] of [...cat]) {
|
|
183
|
+
if ((entry.priceKnown !== false && entry.price > policy.max_usd_per_m) ||
|
|
184
|
+
!providerAllowed(policy, entry.id, entry.route?.provider) ||
|
|
185
|
+
(task.images.length > 0 && !entry.inputModalities?.includes("image")) ||
|
|
186
|
+
(id !== routeKey(task.route) && !trialBudgetOk(policy, entry, task.task.length).ok))
|
|
139
187
|
cat.delete(id);
|
|
140
188
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
189
|
+
const activeEntry = cat.get(routeKey(task.route));
|
|
190
|
+
if (!activeEntry)
|
|
191
|
+
throw new Error(`active route ${task.route.provider}/${task.route.modelId} is excluded by policy or task capabilities`);
|
|
192
|
+
if (cat.size < 2)
|
|
193
|
+
throw new Error("Pi exposes no eligible alternate model route for this task");
|
|
194
|
+
const judge = resolveRoutePref(cat, [policy.judge_model, ...JUDGE_PREFS, task.model], "judge", task.images.length > 0);
|
|
195
|
+
const strategist = resolveRoutePref(cat, [policy.strategist_model, ...STRAT_PREFS, task.model], "strategist");
|
|
196
|
+
const client = new PiCliChatClient(undefined, recordMeritSpend);
|
|
145
197
|
const { category, benchmarks } = relevantBenchmarks(task.task);
|
|
146
198
|
let benchmarkCandidates = [];
|
|
147
199
|
try {
|
|
148
|
-
|
|
200
|
+
if (key)
|
|
201
|
+
benchmarkCandidates = await openRouterBenchmarkCandidates(key, category, new Map([...cat.values()].map((entry) => [entry.id, entry])));
|
|
149
202
|
}
|
|
150
203
|
catch (e) {
|
|
151
204
|
console.log(`[openmerit] benchmark shortlist unavailable: ${e.message}`);
|
|
152
205
|
}
|
|
153
|
-
const rubric = await ensureRubric(key, judge, task.task, new Map());
|
|
154
|
-
const tKey = taskInputKey(task.task, task.images);
|
|
206
|
+
const rubric = await ensureRubric(key ?? "", judge.route, task.task, new Map(), client);
|
|
207
|
+
const tKey = taskInputKey(task.task, task.images, task.files);
|
|
155
208
|
const points = [];
|
|
156
209
|
const tried = new Set();
|
|
157
210
|
const failedVendors = new Set();
|
|
158
211
|
const total = Math.max(1, Math.floor(policy.watch.models_per_task));
|
|
159
212
|
if (settledTask !== undefined)
|
|
160
213
|
emitTrialProgress({ phase: "start", taskKey: tKey,
|
|
161
|
-
model: task.
|
|
162
|
-
const baseline = await scorePiRun(key, judge, task.task, rubric, task.model,
|
|
214
|
+
model: routeLabel(task.route), index: 1, total });
|
|
215
|
+
const baseline = await scorePiRun(key ?? "", judge.route, task.task, rubric, task.model, activeEntry, task.run, "trace", task.images, client);
|
|
163
216
|
points.push(baseline.point);
|
|
164
|
-
tried.add(task.
|
|
165
|
-
|
|
217
|
+
tried.add(routeKey(task.route));
|
|
218
|
+
persistTrial({ ...baseline.point, taskKey: tKey, sessionFile: task.sessionFile }, sink);
|
|
166
219
|
console.log(`[openmerit] task ${tKey}: A=${task.model} score=${baseline.point.score.toFixed(2)} from pi trace`);
|
|
167
220
|
if (settledTask !== undefined)
|
|
168
221
|
emitTrialProgress({ phase: "complete", taskKey: tKey,
|
|
169
|
-
model: task.
|
|
222
|
+
model: routeLabel(task.route), index: 1, total, score: baseline.point.score,
|
|
170
223
|
price: baseline.point.price, latencyMs: baseline.point.latencyMs,
|
|
171
|
-
costUsd: baseline.costUsd
|
|
224
|
+
costUsd: baseline.costUsd, toolCalls: baseline.point.toolCalls,
|
|
225
|
+
toolErrors: baseline.point.toolErrors, changedFiles: baseline.point.changedFiles });
|
|
172
226
|
for (let i = 1; i < total; i++) {
|
|
173
227
|
if (!budgetOk(policy).ok)
|
|
174
228
|
break;
|
|
175
|
-
const pick = await pickNext(key, strategist, task.task, rubric, benchmarks, points, cat, tried, policy.max_usd_per_m, failedVendors, benchmarkCandidates);
|
|
229
|
+
const pick = await pickNext(key ?? "", strategist.route, task.task, rubric, benchmarks, points, cat, tried, policy.max_usd_per_m, failedVendors, benchmarkCandidates, client);
|
|
176
230
|
if (!pick)
|
|
177
231
|
break;
|
|
178
232
|
if (settledTask !== undefined)
|
|
179
233
|
emitTrialProgress({ phase: "start", taskKey: tKey,
|
|
180
|
-
model: pick.model, index: i + 1, total });
|
|
181
|
-
const
|
|
182
|
-
|
|
234
|
+
model: pick.route ? routeLabel(pick.route) : pick.model, index: i + 1, total });
|
|
235
|
+
const entry = pick.route ? cat.get(routeKey(pick.route)) : cat.get(pick.model);
|
|
236
|
+
const { point, costUsd, error } = await runPiTrial(key ?? "", judge.route, task.task, rubric, pick.model, entry, task.cwd, task.images, task.files, undefined, client);
|
|
237
|
+
tried.add(pick.route ? routeKey(pick.route) : pick.model);
|
|
183
238
|
points.push(point);
|
|
184
239
|
recordTrialSpend(costUsd);
|
|
185
|
-
|
|
240
|
+
persistTrial({ ...point, taskKey: tKey, sessionFile: task.sessionFile }, sink);
|
|
186
241
|
if (error)
|
|
187
|
-
failedVendors.add(pick.model.split("/")[0]);
|
|
188
|
-
console.log(`[openmerit] task ${tKey}: ${pick.model} score=${point.score.toFixed(2)} cost=$${costUsd.toFixed(4)}
|
|
242
|
+
failedVendors.add(pick.route?.provider ?? pick.model.split("/")[0]);
|
|
243
|
+
console.log(`[openmerit] task ${tKey}: ${pick.model} score=${point.score.toFixed(2)} cost=$${costUsd.toFixed(4)} ` +
|
|
244
|
+
`tools=${point.toolCalls ?? 0} toolErrors=${point.toolErrors ?? 0} changedFiles=${point.changedFiles ?? 0}` +
|
|
245
|
+
`${error ? ` error=${error.slice(0, 80)}` : ""}`);
|
|
189
246
|
if (settledTask !== undefined)
|
|
190
247
|
emitTrialProgress({ phase: "complete", taskKey: tKey,
|
|
191
|
-
model: pick.model, index: i + 1, total, score: point.score, price: point.price,
|
|
192
|
-
latencyMs: point.latencyMs, costUsd, error: error?.slice(0, 120)
|
|
248
|
+
model: pick.route ? routeLabel(pick.route) : pick.model, index: i + 1, total, score: point.score, price: point.price,
|
|
249
|
+
latencyMs: point.latencyMs, costUsd, error: error?.slice(0, 120),
|
|
250
|
+
toolCalls: point.toolCalls, toolErrors: point.toolErrors, changedFiles: point.changedFiles });
|
|
193
251
|
}
|
|
194
|
-
const rec = buildRecommendation(tKey, task.task.slice(0, 120), task.model, points, policy, task.sessionFile);
|
|
252
|
+
const rec = buildRecommendation(tKey, task.task.slice(0, 120), task.model, points, policy, task.sessionFile, task.route);
|
|
195
253
|
if (rec) {
|
|
196
254
|
appendJsonl(paths.recommendations(), rec);
|
|
255
|
+
sink.emit(meritEvent("recommendation.created", rec));
|
|
197
256
|
console.log(`[openmerit] task ${tKey}: selected ${rec.recommended.model}; auto=${rec.policy.autoApply}`);
|
|
198
257
|
}
|
|
199
258
|
else
|
|
@@ -210,8 +269,10 @@ export async function autoTaskTick(key, policy, settledTask) {
|
|
|
210
269
|
}
|
|
211
270
|
/** Rebuild frontiers from trials and emit recommendations for the harness's current model. */
|
|
212
271
|
export function recommendTick(policy) {
|
|
272
|
+
const sink = new LocalJsonlEventSink();
|
|
213
273
|
const harness = readJson(paths.harnessState(), {});
|
|
214
274
|
const currentModel = harness.currentModel ?? null;
|
|
275
|
+
const currentRoute = harness.currentRoute ?? null;
|
|
215
276
|
const trials = readJsonl(paths.trials());
|
|
216
277
|
const observations = readJsonl(paths.observations());
|
|
217
278
|
const labelByTask = new Map();
|
|
@@ -233,16 +294,17 @@ export function recommendTick(policy) {
|
|
|
233
294
|
latestById.set(r.id, r);
|
|
234
295
|
const existing = new Set([...latestById.values()]
|
|
235
296
|
.filter((r) => r.status === "pending" || r.status === "dismissed")
|
|
236
|
-
.map((r) => `${r.taskKey}:${r.recommended.model}`));
|
|
297
|
+
.map((r) => `${r.taskKey}:${r.recommended.route ? routeKey(r.recommended.route) : `openrouter:${r.recommended.model}`}`));
|
|
237
298
|
const emitted = [];
|
|
238
299
|
for (const [tKey, points] of byTask) {
|
|
239
|
-
const rec = buildRecommendation(tKey, labelByTask.get(tKey) ?? tKey, currentModel, points, policy);
|
|
300
|
+
const rec = buildRecommendation(tKey, labelByTask.get(tKey) ?? tKey, currentModel, points, policy, null, currentRoute);
|
|
240
301
|
if (!rec)
|
|
241
302
|
continue;
|
|
242
|
-
const dedupeKey = `${rec.taskKey}:${rec.recommended.model}`;
|
|
303
|
+
const dedupeKey = `${rec.taskKey}:${rec.recommended.route ? routeKey(rec.recommended.route) : `openrouter:${rec.recommended.model}`}`;
|
|
243
304
|
if (existing.has(dedupeKey))
|
|
244
305
|
continue;
|
|
245
306
|
appendJsonl(paths.recommendations(), rec);
|
|
307
|
+
sink.emit(meritEvent("recommendation.created", rec));
|
|
246
308
|
emitted.push(rec);
|
|
247
309
|
console.log(`[openmerit] recommendation: ${rec.recommended.model} <- ${currentModel ?? "unknown"} ` +
|
|
248
310
|
`(gain ${rec.evidence.scoreGain}, auto=${rec.policy.autoApply})`);
|
|
@@ -257,12 +319,12 @@ export async function tickOnce(policy) {
|
|
|
257
319
|
key = loadKey();
|
|
258
320
|
}
|
|
259
321
|
catch (e) {
|
|
260
|
-
console.log(`[openmerit] ${e.message};
|
|
322
|
+
console.log(`[openmerit] ${e.message}; OpenRouter enrichment disabled`);
|
|
261
323
|
}
|
|
262
324
|
if (key) {
|
|
263
325
|
await catalogTick(key, policy);
|
|
264
|
-
await autoTaskTick(key, policy);
|
|
265
326
|
}
|
|
327
|
+
await autoTaskTick(key, policy);
|
|
266
328
|
}
|
|
267
329
|
/** Run forever, honoring policy watch intervals. */
|
|
268
330
|
export async function runDaemon(policy) {
|
|
@@ -287,17 +349,15 @@ export async function runDaemon(policy) {
|
|
|
287
349
|
catch {
|
|
288
350
|
/* no key; skip provider ticks */
|
|
289
351
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
lastCatalog = now;
|
|
295
|
-
}
|
|
296
|
-
await autoTaskTick(key, policy);
|
|
297
|
-
}
|
|
298
|
-
catch (e) {
|
|
299
|
-
console.log(`[openmerit] provider tick failed: ${e.message}`);
|
|
352
|
+
try {
|
|
353
|
+
if (key && wantCatalog) {
|
|
354
|
+
await catalogTick(key, policy);
|
|
355
|
+
lastCatalog = now;
|
|
300
356
|
}
|
|
357
|
+
await autoTaskTick(key, policy);
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
console.log(`[openmerit] provider tick failed: ${e.message}`);
|
|
301
361
|
}
|
|
302
362
|
}
|
|
303
363
|
await new Promise((r) => setTimeout(r, tracesMs));
|
package/dist/frontier.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/** Pareto frontier over (score up, price down, latency down) + best-fit/fallback picks. */
|
|
2
|
+
import { pointKey } from "./routes.js";
|
|
3
|
+
function effectivePrice(point) {
|
|
4
|
+
return point.priceKnown === false ? Number.POSITIVE_INFINITY : point.price;
|
|
5
|
+
}
|
|
2
6
|
/** a dominates b if a is no worse on every objective and strictly better on one. */
|
|
3
7
|
export function dominates(a, b) {
|
|
4
8
|
const aLat = a.latencyMs ?? Number.POSITIVE_INFINITY;
|
|
5
9
|
const bLat = b.latencyMs ?? Number.POSITIVE_INFINITY;
|
|
6
|
-
const
|
|
7
|
-
const
|
|
10
|
+
const aPrice = effectivePrice(a);
|
|
11
|
+
const bPrice = effectivePrice(b);
|
|
12
|
+
const noWorse = a.score >= b.score && aPrice <= bPrice && aLat <= bLat;
|
|
13
|
+
const better = a.score > b.score || aPrice < bPrice || aLat < bLat;
|
|
8
14
|
return noWorse && better;
|
|
9
15
|
}
|
|
10
16
|
/**
|
|
@@ -14,11 +20,11 @@ export function dominates(a, b) {
|
|
|
14
20
|
*/
|
|
15
21
|
export function paretoFrontier(points) {
|
|
16
22
|
const fr = points.filter((p) => !points.some((q) => q !== p && dominates(q, p)));
|
|
17
|
-
return fr.sort((a, b) => a
|
|
23
|
+
return fr.sort((a, b) => effectivePrice(a) - effectivePrice(b) || b.score - a.score);
|
|
18
24
|
}
|
|
19
25
|
/** Simple 2-objective chain (score vs price only), cheapest first. */
|
|
20
26
|
export function scorePriceChain(points) {
|
|
21
|
-
const pts = [...points].sort((a, b) => a
|
|
27
|
+
const pts = [...points].sort((a, b) => effectivePrice(a) - effectivePrice(b) || b.score - a.score);
|
|
22
28
|
const chain = [];
|
|
23
29
|
let best = -1;
|
|
24
30
|
for (const p of pts) {
|
|
@@ -31,7 +37,8 @@ export function scorePriceChain(points) {
|
|
|
31
37
|
}
|
|
32
38
|
/** Highest score; ties broken by lower price then lower latency. */
|
|
33
39
|
export function pickBest(points) {
|
|
34
|
-
return [...points].sort((a, b) => b.score - a.score ||
|
|
40
|
+
return [...points].sort((a, b) => b.score - a.score || effectivePrice(a) - effectivePrice(b) ||
|
|
41
|
+
(a.latencyMs ?? 1e18) - (b.latencyMs ?? 1e18))[0];
|
|
35
42
|
}
|
|
36
43
|
/**
|
|
37
44
|
* Fallback for `best`: prefer the cheapest frontier point that still clears
|
|
@@ -39,7 +46,7 @@ export function pickBest(points) {
|
|
|
39
46
|
* `best` itself.
|
|
40
47
|
*/
|
|
41
48
|
export function pickFallback(points, best, minScore) {
|
|
42
|
-
const others = points.filter((p) => p
|
|
49
|
+
const others = points.filter((p) => pointKey(p) !== pointKey(best));
|
|
43
50
|
if (others.length === 0)
|
|
44
51
|
return undefined;
|
|
45
52
|
const fr = paretoFrontier(others).filter((p) => p.score >= minScore);
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Stable boundaries for trace inputs and telemetry outputs. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { appendJsonl, paths } from "./store.js";
|
|
4
|
+
import { ingestNewTraces } from "./traces.js";
|
|
5
|
+
export class PiTraceObservationSource {
|
|
6
|
+
sessionsDir;
|
|
7
|
+
id = "pi-jsonl";
|
|
8
|
+
constructor(sessionsDir) {
|
|
9
|
+
this.sessionsDir = sessionsDir;
|
|
10
|
+
}
|
|
11
|
+
read() { return ingestNewTraces(this.sessionsDir); }
|
|
12
|
+
}
|
|
13
|
+
export class LocalJsonlEventSink {
|
|
14
|
+
id = "local-jsonl";
|
|
15
|
+
emit(event) { appendJsonl(paths.events(), event); }
|
|
16
|
+
}
|
|
17
|
+
export function meritEvent(type, payload, source = "openmerit") {
|
|
18
|
+
return { schemaVersion: 1, id: randomUUID(), ts: new Date().toISOString(), source, type, payload };
|
|
19
|
+
}
|
package/dist/judge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Judge: score a model's answer against a rubric. */
|
|
2
|
-
import {
|
|
2
|
+
import { directChatClient } from "./llm.js";
|
|
3
3
|
export const JUDGE_PREFS = ["openai/gpt-4o-mini", "openai/gpt-4o"];
|
|
4
4
|
const JUDGE_PROMPT = `You are grading a model's answer.
|
|
5
5
|
TASK: {task}
|
|
@@ -13,8 +13,8 @@ export function parseObj(txt) {
|
|
|
13
13
|
throw new Error("no json object found");
|
|
14
14
|
return JSON.parse(m[0]);
|
|
15
15
|
}
|
|
16
|
-
export async function judge(key, judgeModel, task, rubric, answer) {
|
|
17
|
-
const { content } = await chat(
|
|
16
|
+
export async function judge(key, judgeModel, task, rubric, answer, client = directChatClient(key)) {
|
|
17
|
+
const { content } = await client.chat(judgeModel, JUDGE_PROMPT.replace("{task}", task)
|
|
18
18
|
.replace("{rubric}", rubric)
|
|
19
19
|
.replace("{answer}", answer.slice(0, 16000)), 1024, 0);
|
|
20
20
|
try {
|
|
@@ -26,13 +26,13 @@ export async function judge(key, judgeModel, task, rubric, answer) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
/** Grade OCR answers against the actual uploaded pixels, not answer text alone. */
|
|
29
|
-
export async function judgeWithImages(key, judgeModel, task, rubric, answer, images) {
|
|
29
|
+
export async function judgeWithImages(key, judgeModel, task, rubric, answer, images, client = directChatClient(key)) {
|
|
30
30
|
const prompt = `You are grading an answer to a visual task. Inspect the attached image carefully.\n` +
|
|
31
31
|
`TASK: ${task}\nRUBRIC: ${rubric}\nANSWER: ${answer.slice(0, 16000)}\n` +
|
|
32
32
|
`For invoice extraction, check every visible field and line item against the image. ` +
|
|
33
33
|
`Penalize missing, invented, or mistyped values and invalid JSON. ` +
|
|
34
34
|
`Return ONLY json: {"score": <0..1>, "why": "<one line>"}`;
|
|
35
|
-
const { content } = await chatWithImages(
|
|
35
|
+
const { content } = await client.chatWithImages(judgeModel, prompt, images, 1024);
|
|
36
36
|
try {
|
|
37
37
|
const j = parseObj(content);
|
|
38
38
|
return { score: Number(j.score ?? 0), why: String(j.why ?? "") };
|
package/dist/llm.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/** Minimal
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
/** Minimal provider clients for judge and strategist requests. */
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
import { paths } from "./store.js";
|
|
7
|
+
import { legacyOpenRouterRoute } from "./routes.js";
|
|
4
8
|
const OR = "https://openrouter.ai/api/v1";
|
|
5
9
|
/** Load OPENROUTER_API_KEY from env or ~/.openmerit/.env. */
|
|
6
10
|
export function loadKey() {
|
|
@@ -20,9 +24,9 @@ export function loadKey() {
|
|
|
20
24
|
throw new Error("missing OPENROUTER_API_KEY (env or ~/.openmerit/.env)");
|
|
21
25
|
return key;
|
|
22
26
|
}
|
|
23
|
-
async function request(key, path, body, tries = 3) {
|
|
27
|
+
async function request(key, path, body, baseUrl = OR, tries = 3) {
|
|
24
28
|
for (let attempt = 0; attempt < tries; attempt++) {
|
|
25
|
-
const res = await fetch(
|
|
29
|
+
const res = await fetch(baseUrl + path, {
|
|
26
30
|
method: body === undefined ? "GET" : "POST",
|
|
27
31
|
headers: {
|
|
28
32
|
Authorization: `Bearer ${key}`,
|
|
@@ -41,26 +45,159 @@ async function request(key, path, body, tries = 3) {
|
|
|
41
45
|
}
|
|
42
46
|
throw new Error("unreachable");
|
|
43
47
|
}
|
|
48
|
+
class OpenAICompatibleProvider {
|
|
49
|
+
id;
|
|
50
|
+
baseUrl;
|
|
51
|
+
key;
|
|
52
|
+
constructor(id, baseUrl, key) {
|
|
53
|
+
this.id = id;
|
|
54
|
+
this.baseUrl = baseUrl;
|
|
55
|
+
this.key = key;
|
|
56
|
+
}
|
|
57
|
+
model(model) { return this.id === "openrouter" ? model : model.split("/", 2).at(-1) ?? model; }
|
|
58
|
+
async chat(model, prompt, maxTokens, temperature) {
|
|
59
|
+
const r = await request(this.key, "/chat/completions", { model: this.model(model), messages: [{ role: "user", content: prompt }], max_tokens: maxTokens, temperature }, this.baseUrl);
|
|
60
|
+
return { content: r.choices[0]?.message?.content ?? "", usage: r.usage ?? {} };
|
|
61
|
+
}
|
|
62
|
+
async chatWithImages(model, prompt, images, maxTokens) {
|
|
63
|
+
const content = [{ type: "text", text: prompt }, ...images.map((image) => ({ type: "image_url", image_url: { url: `data:${image.mimeType};base64,${image.data}` } }))];
|
|
64
|
+
const r = await request(this.key, "/chat/completions", { model: this.model(model), messages: [{ role: "user", content }], max_tokens: maxTokens, temperature: 0 }, this.baseUrl);
|
|
65
|
+
return { content: r.choices[0]?.message?.content ?? "", usage: r.usage ?? {} };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
class GeminiProvider {
|
|
69
|
+
key;
|
|
70
|
+
id = "google";
|
|
71
|
+
constructor(key) {
|
|
72
|
+
this.key = key;
|
|
73
|
+
}
|
|
74
|
+
async call(model, prompt, images = [], maxTokens) {
|
|
75
|
+
const name = model.split("/", 2).at(-1) ?? model;
|
|
76
|
+
const parts = [{ text: prompt }, ...images.map((image) => ({ inline_data: { mime_type: image.mimeType, data: image.data } }))];
|
|
77
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(name)}:generateContent?key=${encodeURIComponent(this.key)}`, {
|
|
78
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
79
|
+
body: JSON.stringify({ contents: [{ role: "user", parts }], generationConfig: { maxOutputTokens: maxTokens, temperature: images.length ? 0 : undefined } }),
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok)
|
|
82
|
+
throw new Error(`Google Gemini HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
83
|
+
const raw = await res.json();
|
|
84
|
+
return { content: raw.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "", usage: { prompt_tokens: raw.usageMetadata?.promptTokenCount, completion_tokens: raw.usageMetadata?.candidatesTokenCount, total_tokens: raw.usageMetadata?.totalTokenCount } };
|
|
85
|
+
}
|
|
86
|
+
chat(model, prompt, maxTokens) { return this.call(model, prompt, [], maxTokens); }
|
|
87
|
+
chatWithImages(model, prompt, images, maxTokens) { return this.call(model, prompt, images, maxTokens); }
|
|
88
|
+
}
|
|
89
|
+
export function providerFor(model, fallbackKey) {
|
|
90
|
+
const id = model.split("/", 1)[0] || "openrouter";
|
|
91
|
+
// OpenRouter model IDs are vendor/model (for example openai/gpt-4o-mini).
|
|
92
|
+
// Direct providers require an explicit opt-in to avoid routing those IDs
|
|
93
|
+
// away from OpenRouter when users have multiple credentials configured.
|
|
94
|
+
if (id === "openai" && process.env.OPENMERIT_DIRECT_PROVIDER === "openai" && process.env.OPENAI_API_KEY)
|
|
95
|
+
return new OpenAICompatibleProvider(id, "https://api.openai.com/v1", process.env.OPENAI_API_KEY);
|
|
96
|
+
if (id === "google" && process.env.OPENMERIT_DIRECT_PROVIDER === "google" && process.env.GEMINI_API_KEY)
|
|
97
|
+
return new GeminiProvider(process.env.GEMINI_API_KEY);
|
|
98
|
+
return new OpenAICompatibleProvider("openrouter", OR, fallbackKey);
|
|
99
|
+
}
|
|
44
100
|
export async function chat(key, model, prompt, maxTokens, temperature) {
|
|
45
|
-
|
|
46
|
-
model,
|
|
47
|
-
messages: [{ role: "user", content: prompt }],
|
|
48
|
-
max_tokens: maxTokens,
|
|
49
|
-
temperature,
|
|
50
|
-
});
|
|
51
|
-
return { content: r.choices[0]?.message?.content ?? "", usage: r.usage ?? {} };
|
|
101
|
+
return providerFor(model, key).chat(model, prompt, maxTokens, temperature);
|
|
52
102
|
}
|
|
53
103
|
/** Send a visual grading request through the same OpenRouter account. */
|
|
54
104
|
export async function chatWithImages(key, model, prompt, images, maxTokens) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
105
|
+
return providerFor(model, key).chatWithImages(model, prompt, images, maxTokens);
|
|
106
|
+
}
|
|
107
|
+
export function directChatClient(key) {
|
|
108
|
+
const id = (model) => typeof model === "string" ? model : model.meritId;
|
|
109
|
+
return {
|
|
110
|
+
chat: (model, prompt, maxTokens, temperature) => chat(key, id(model), prompt, maxTokens, temperature),
|
|
111
|
+
chatWithImages: (model, prompt, images, maxTokens) => chatWithImages(key, id(model), prompt, images, maxTokens),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function textContent(content) {
|
|
115
|
+
if (typeof content === "string")
|
|
116
|
+
return content;
|
|
117
|
+
if (!Array.isArray(content))
|
|
118
|
+
return "";
|
|
119
|
+
return content.filter((part) => !!part && typeof part === "object" && part.type === "text")
|
|
120
|
+
.map((part) => part.text ?? "").join("\n");
|
|
121
|
+
}
|
|
122
|
+
/** Use pi's model registry and credential store without exposing provider secrets to OpenMerit. */
|
|
123
|
+
export class PiCliChatClient {
|
|
124
|
+
executable;
|
|
125
|
+
onSpend;
|
|
126
|
+
id = "pi-cli";
|
|
127
|
+
constructor(executable = process.env.OPENMERIT_PI_BIN?.trim() || "pi", onSpend) {
|
|
128
|
+
this.executable = executable;
|
|
129
|
+
this.onSpend = onSpend;
|
|
130
|
+
}
|
|
131
|
+
chat(model, prompt, _maxTokens, _temperature) {
|
|
132
|
+
return this.run(model, prompt, []);
|
|
133
|
+
}
|
|
134
|
+
chatWithImages(model, prompt, images, _maxTokens) {
|
|
135
|
+
return this.run(model, prompt, images);
|
|
136
|
+
}
|
|
137
|
+
async run(target, prompt, images) {
|
|
138
|
+
const route = typeof target === "string" ? legacyOpenRouterRoute(target) : target;
|
|
139
|
+
const dir = mkdtempSync(join(tmpdir(), "openmerit-pi-chat-"));
|
|
140
|
+
try {
|
|
141
|
+
const imageArgs = images.map((image, index) => {
|
|
142
|
+
const suffix = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif" }[image.mimeType];
|
|
143
|
+
if (!suffix)
|
|
144
|
+
throw new Error(`unsupported pi image: ${image.mimeType}`);
|
|
145
|
+
const file = join(dir, `image-${index}.${suffix}`);
|
|
146
|
+
writeFileSync(file, Buffer.from(image.data, "base64"));
|
|
147
|
+
return `@${file}`;
|
|
148
|
+
});
|
|
149
|
+
const child = spawn(this.executable, [
|
|
150
|
+
"--provider", route.provider, "--model", route.modelId, "--mode", "json", "--offline",
|
|
151
|
+
"--no-extensions", "--no-tools", "--print", ...imageArgs, prompt,
|
|
152
|
+
], { cwd: dir, stdio: ["ignore", "pipe", "pipe"] });
|
|
153
|
+
let stdout = "";
|
|
154
|
+
let stderr = "";
|
|
155
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
|
|
156
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
|
|
157
|
+
const code = await new Promise((resolve, reject) => {
|
|
158
|
+
child.on("error", reject);
|
|
159
|
+
child.on("close", (value) => resolve(value ?? 1));
|
|
160
|
+
});
|
|
161
|
+
let answer = "";
|
|
162
|
+
let promptTokens = 0;
|
|
163
|
+
let completionTokens = 0;
|
|
164
|
+
let totalTokens = 0;
|
|
165
|
+
let costUsd = 0;
|
|
166
|
+
let failed = false;
|
|
167
|
+
for (const line of stdout.split("\n")) {
|
|
168
|
+
if (!line.trim())
|
|
169
|
+
continue;
|
|
170
|
+
let event;
|
|
171
|
+
try {
|
|
172
|
+
event = JSON.parse(line);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (event.type !== "message_end" || event.message?.role !== "assistant")
|
|
178
|
+
continue;
|
|
179
|
+
answer = textContent(event.message.content) || answer;
|
|
180
|
+
promptTokens += event.message.usage?.input ?? 0;
|
|
181
|
+
completionTokens += event.message.usage?.output ?? 0;
|
|
182
|
+
totalTokens += event.message.usage?.totalTokens ?? 0;
|
|
183
|
+
costUsd += event.message.usage?.cost?.total ?? 0;
|
|
184
|
+
if (event.message.stopReason === "error")
|
|
185
|
+
failed = true;
|
|
186
|
+
}
|
|
187
|
+
if (code !== 0 || failed || !answer.trim()) {
|
|
188
|
+
throw new Error(`pi model call ${route.provider}/${route.modelId} failed: ` +
|
|
189
|
+
(stderr.trim().slice(0, 240) || `exit ${code}, empty answer`));
|
|
190
|
+
}
|
|
191
|
+
this.onSpend?.(costUsd);
|
|
192
|
+
return { content: answer, usage: {
|
|
193
|
+
prompt_tokens: promptTokens || undefined,
|
|
194
|
+
completion_tokens: completionTokens || undefined,
|
|
195
|
+
total_tokens: totalTokens || promptTokens + completionTokens || undefined,
|
|
196
|
+
costUsd,
|
|
197
|
+
} };
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
66
203
|
}
|