openmerit 0.1.1 → 0.1.3
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 +89 -47
- package/dist/cli.js +48 -121
- package/dist/daemon.js +131 -52
- package/dist/diagnostics.js +194 -0
- package/dist/frontier.js +13 -6
- package/dist/integrations.js +19 -0
- package/dist/judge.js +5 -5
- package/dist/llm.js +102 -1
- package/dist/pi-trials.js +60 -24
- package/dist/policy.js +8 -3
- package/dist/recommend.js +27 -14
- package/dist/routes.js +59 -0
- package/dist/standalone.js +220 -0
- package/dist/store.js +43 -10
- package/dist/strategist.js +18 -14
- package/dist/traces.js +6 -2
- package/dist/trials.js +50 -11
- package/examples/task.example.json +1 -0
- package/extension/openmerit.ts +134 -32
- package/instructions/OPENMERIT.md +2 -2
- package/package.json +5 -2
- package/rules.md +39 -0
package/dist/daemon.js
CHANGED
|
@@ -9,15 +9,68 @@ 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
|
+
}
|
|
45
|
+
function processIsAlive(pid) {
|
|
46
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
47
|
+
return false;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return error.code === "EPERM";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Completed jobs stay final; live owners retain their claim; abandoned claims can retry. */
|
|
57
|
+
export function taskJobIsClaimed(prior, marker, now = Date.now()) {
|
|
58
|
+
if (!prior || prior.marker !== marker || prior.status === "failed")
|
|
59
|
+
return false;
|
|
60
|
+
if (prior.status !== "running")
|
|
61
|
+
return true;
|
|
62
|
+
if (prior.ownerPid !== undefined)
|
|
63
|
+
return processIsAlive(prior.ownerPid);
|
|
64
|
+
const updated = Date.parse(prior.updatedAt);
|
|
65
|
+
return Number.isFinite(updated) && now - updated < 15 * 60_000;
|
|
66
|
+
}
|
|
18
67
|
function emitTrialProgress(progress) {
|
|
19
68
|
console.log(`[openmerit/progress] ${JSON.stringify(progress)}`);
|
|
20
69
|
}
|
|
70
|
+
function persistTrial(point, sink) {
|
|
71
|
+
appendJsonl(paths.trials(), point);
|
|
72
|
+
sink.emit(meritEvent("trial.completed", point));
|
|
73
|
+
}
|
|
21
74
|
function resolvePref(cat, prefs, label) {
|
|
22
75
|
for (const p of prefs) {
|
|
23
76
|
if (p && cat.has(p))
|
|
@@ -34,10 +87,12 @@ function resolvePref(cat, prefs, label) {
|
|
|
34
87
|
throw new Error(`could not resolve ${label} model from preferences`);
|
|
35
88
|
}
|
|
36
89
|
/** Ingest new session-trace bytes into the observation log. */
|
|
37
|
-
export function tracesTick() {
|
|
38
|
-
const fresh =
|
|
39
|
-
for (const o of fresh)
|
|
90
|
+
export function tracesTick(source = new PiTraceObservationSource(), sink = new LocalJsonlEventSink()) {
|
|
91
|
+
const fresh = source.read();
|
|
92
|
+
for (const o of fresh) {
|
|
40
93
|
appendJsonl(paths.observations(), o);
|
|
94
|
+
sink.emit(meritEvent("observation.recorded", o, source.id));
|
|
95
|
+
}
|
|
41
96
|
if (fresh.length > 0)
|
|
42
97
|
console.log(`[openmerit] traces: ${fresh.length} new observation(s)`);
|
|
43
98
|
return fresh;
|
|
@@ -68,6 +123,7 @@ export async function catalogTick(key, policy) {
|
|
|
68
123
|
* thin). Budget-capped per policy.
|
|
69
124
|
*/
|
|
70
125
|
export async function trialTick(key, policy) {
|
|
126
|
+
const sink = new LocalJsonlEventSink();
|
|
71
127
|
const budget = budgetOk(policy);
|
|
72
128
|
if (!budget.ok) {
|
|
73
129
|
console.log(`[openmerit] trials paused: ${budget.reason}`);
|
|
@@ -106,7 +162,7 @@ export async function trialTick(key, policy) {
|
|
|
106
162
|
const rubric = await ensureRubric(key, judge, obs.taskLabel, rubricCache);
|
|
107
163
|
const { point, costUsd, error } = await runTrial(key, judge, obs.taskLabel, rubric, candidate.id, cat.get(candidate.id));
|
|
108
164
|
recordTrialSpend(costUsd);
|
|
109
|
-
|
|
165
|
+
persistTrial({ ...point, taskKey: tKey }, sink);
|
|
110
166
|
if (error)
|
|
111
167
|
console.log(`[openmerit] trial failed: ${error.slice(0, 120)}`);
|
|
112
168
|
else
|
|
@@ -115,6 +171,7 @@ export async function trialTick(key, policy) {
|
|
|
115
171
|
}
|
|
116
172
|
/** Compare the one completed active pi task, sequentially, then target its session. */
|
|
117
173
|
export async function autoTaskTick(key, policy, settledTask) {
|
|
174
|
+
const sink = new LocalJsonlEventSink();
|
|
118
175
|
const task = settledTask === undefined ? settledActiveTask() : settledTask;
|
|
119
176
|
if (!task)
|
|
120
177
|
return null;
|
|
@@ -123,99 +180,122 @@ export async function autoTaskTick(key, policy, settledTask) {
|
|
|
123
180
|
: `${task.sessionFile}:${task.sessionBytes ?? task.settledAt}`;
|
|
124
181
|
const processedPath = settledTask === undefined ? paths.watchProcessed() : paths.sessionJob(marker);
|
|
125
182
|
const prior = readJson(processedPath, null);
|
|
126
|
-
if (prior
|
|
183
|
+
if (taskJobIsClaimed(prior, marker))
|
|
127
184
|
return null;
|
|
128
185
|
if (!budgetOk(policy).ok)
|
|
129
186
|
return null;
|
|
130
187
|
// Claim the settled turn before provider calls so the next poll cannot duplicate it.
|
|
131
|
-
writeJson(processedPath, { marker, status: "running",
|
|
188
|
+
writeJson(processedPath, { marker, status: "running", ownerPid: process.pid,
|
|
189
|
+
updatedAt: new Date().toISOString() });
|
|
132
190
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
191
|
+
let cat = routeCatalog(task.routes);
|
|
192
|
+
if (!cat.has(routeKey(task.route)))
|
|
193
|
+
cat.set(routeKey(task.route), routeCatalog([task.route]).values().next().value);
|
|
194
|
+
if (key) {
|
|
195
|
+
try {
|
|
196
|
+
const live = await fetchCatalog(key);
|
|
197
|
+
saveSnapshot(live);
|
|
198
|
+
cat = new Map([...cat].map(([id, entry]) => [id,
|
|
199
|
+
entry.route?.provider === "openrouter" ? enrichRouteEntry(entry, live.get(entry.id)) : entry]));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
console.log(`[openmerit] OpenRouter enrichment unavailable: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const [id, entry] of [...cat]) {
|
|
206
|
+
if ((entry.priceKnown !== false && entry.price > policy.max_usd_per_m) ||
|
|
207
|
+
!providerAllowed(policy, entry.id, entry.route?.provider) ||
|
|
208
|
+
(task.images.length > 0 && !entry.inputModalities?.includes("image")) ||
|
|
209
|
+
(id !== routeKey(task.route) && !trialBudgetOk(policy, entry, task.task.length).ok))
|
|
139
210
|
cat.delete(id);
|
|
140
211
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
212
|
+
const activeEntry = cat.get(routeKey(task.route));
|
|
213
|
+
if (!activeEntry)
|
|
214
|
+
throw new Error(`active route ${task.route.provider}/${task.route.modelId} is excluded by policy or task capabilities`);
|
|
215
|
+
if (cat.size < 2)
|
|
216
|
+
throw new Error("Pi exposes no eligible alternate model route for this task");
|
|
217
|
+
const judge = resolveRoutePref(cat, [policy.judge_model, ...JUDGE_PREFS, task.model], "judge", task.images.length > 0);
|
|
218
|
+
const strategist = resolveRoutePref(cat, [policy.strategist_model, ...STRAT_PREFS, task.model], "strategist");
|
|
219
|
+
const client = new PiCliChatClient(undefined, recordMeritSpend);
|
|
145
220
|
const { category, benchmarks } = relevantBenchmarks(task.task);
|
|
146
221
|
let benchmarkCandidates = [];
|
|
147
222
|
try {
|
|
148
|
-
|
|
223
|
+
if (key)
|
|
224
|
+
benchmarkCandidates = await openRouterBenchmarkCandidates(key, category, new Map([...cat.values()].map((entry) => [entry.id, entry])));
|
|
149
225
|
}
|
|
150
226
|
catch (e) {
|
|
151
227
|
console.log(`[openmerit] benchmark shortlist unavailable: ${e.message}`);
|
|
152
228
|
}
|
|
153
|
-
const rubric = await ensureRubric(key, judge, task.task, new Map());
|
|
154
|
-
const tKey = taskInputKey(task.task, task.images);
|
|
229
|
+
const rubric = await ensureRubric(key ?? "", judge.route, task.task, new Map(), client);
|
|
230
|
+
const tKey = taskInputKey(task.task, task.images, task.files);
|
|
155
231
|
const points = [];
|
|
156
232
|
const tried = new Set();
|
|
157
233
|
const failedVendors = new Set();
|
|
158
234
|
const total = Math.max(1, Math.floor(policy.watch.models_per_task));
|
|
159
235
|
if (settledTask !== undefined)
|
|
160
236
|
emitTrialProgress({ phase: "start", taskKey: tKey,
|
|
161
|
-
model: task.
|
|
162
|
-
const baseline = await scorePiRun(key, judge, task.task, rubric, task.model,
|
|
237
|
+
model: routeLabel(task.route), index: 1, total });
|
|
238
|
+
const baseline = await scorePiRun(key ?? "", judge.route, task.task, rubric, task.model, activeEntry, task.run, "trace", task.images, client);
|
|
163
239
|
points.push(baseline.point);
|
|
164
|
-
tried.add(task.
|
|
165
|
-
|
|
240
|
+
tried.add(routeKey(task.route));
|
|
241
|
+
persistTrial({ ...baseline.point, taskKey: tKey, sessionFile: task.sessionFile }, sink);
|
|
166
242
|
console.log(`[openmerit] task ${tKey}: A=${task.model} score=${baseline.point.score.toFixed(2)} from pi trace`);
|
|
167
243
|
if (settledTask !== undefined)
|
|
168
244
|
emitTrialProgress({ phase: "complete", taskKey: tKey,
|
|
169
|
-
model: task.
|
|
245
|
+
model: routeLabel(task.route), index: 1, total, score: baseline.point.score,
|
|
170
246
|
price: baseline.point.price, latencyMs: baseline.point.latencyMs,
|
|
171
247
|
costUsd: baseline.costUsd, toolCalls: baseline.point.toolCalls,
|
|
172
248
|
toolErrors: baseline.point.toolErrors, changedFiles: baseline.point.changedFiles });
|
|
173
249
|
for (let i = 1; i < total; i++) {
|
|
174
250
|
if (!budgetOk(policy).ok)
|
|
175
251
|
break;
|
|
176
|
-
const pick = await pickNext(key, strategist, task.task, rubric, benchmarks, points, cat, tried, policy.max_usd_per_m, failedVendors, benchmarkCandidates);
|
|
252
|
+
const pick = await pickNext(key ?? "", strategist.route, task.task, rubric, benchmarks, points, cat, tried, policy.max_usd_per_m, failedVendors, benchmarkCandidates, client);
|
|
177
253
|
if (!pick)
|
|
178
254
|
break;
|
|
179
255
|
if (settledTask !== undefined)
|
|
180
256
|
emitTrialProgress({ phase: "start", taskKey: tKey,
|
|
181
|
-
model: pick.model, index: i + 1, total });
|
|
182
|
-
const
|
|
183
|
-
|
|
257
|
+
model: pick.route ? routeLabel(pick.route) : pick.model, index: i + 1, total });
|
|
258
|
+
const entry = pick.route ? cat.get(routeKey(pick.route)) : cat.get(pick.model);
|
|
259
|
+
const { point, costUsd, error } = await runPiTrial(key ?? "", judge.route, task.task, rubric, pick.model, entry, task.cwd, task.images, task.files, undefined, client);
|
|
260
|
+
tried.add(pick.route ? routeKey(pick.route) : pick.model);
|
|
184
261
|
points.push(point);
|
|
185
262
|
recordTrialSpend(costUsd);
|
|
186
|
-
|
|
263
|
+
persistTrial({ ...point, taskKey: tKey, sessionFile: task.sessionFile }, sink);
|
|
187
264
|
if (error)
|
|
188
|
-
failedVendors.add(pick.model.split("/")[0]);
|
|
265
|
+
failedVendors.add(pick.route?.provider ?? pick.model.split("/")[0]);
|
|
189
266
|
console.log(`[openmerit] task ${tKey}: ${pick.model} score=${point.score.toFixed(2)} cost=$${costUsd.toFixed(4)} ` +
|
|
190
267
|
`tools=${point.toolCalls ?? 0} toolErrors=${point.toolErrors ?? 0} changedFiles=${point.changedFiles ?? 0}` +
|
|
191
268
|
`${error ? ` error=${error.slice(0, 80)}` : ""}`);
|
|
192
269
|
if (settledTask !== undefined)
|
|
193
270
|
emitTrialProgress({ phase: "complete", taskKey: tKey,
|
|
194
|
-
model: pick.model, index: i + 1, total, score: point.score, price: point.price,
|
|
271
|
+
model: pick.route ? routeLabel(pick.route) : pick.model, index: i + 1, total, score: point.score, price: point.price,
|
|
195
272
|
latencyMs: point.latencyMs, costUsd, error: error?.slice(0, 120),
|
|
196
273
|
toolCalls: point.toolCalls, toolErrors: point.toolErrors, changedFiles: point.changedFiles });
|
|
197
274
|
}
|
|
198
|
-
const rec = buildRecommendation(tKey, task.task.slice(0, 120), task.model, points, policy, task.sessionFile);
|
|
275
|
+
const rec = buildRecommendation(tKey, task.task.slice(0, 120), task.model, points, policy, task.sessionFile, task.route);
|
|
199
276
|
if (rec) {
|
|
200
277
|
appendJsonl(paths.recommendations(), rec);
|
|
278
|
+
sink.emit(meritEvent("recommendation.created", rec));
|
|
201
279
|
console.log(`[openmerit] task ${tKey}: selected ${rec.recommended.model}; auto=${rec.policy.autoApply}`);
|
|
202
280
|
}
|
|
203
281
|
else
|
|
204
282
|
console.log(`[openmerit] task ${tKey}: active model remains best`);
|
|
205
283
|
writeJson(processedPath, { marker, status: rec ? "complete" : "no_swap",
|
|
206
|
-
updatedAt: new Date().toISOString() });
|
|
284
|
+
ownerPid: process.pid, updatedAt: new Date().toISOString() });
|
|
207
285
|
return rec;
|
|
208
286
|
}
|
|
209
287
|
catch (error) {
|
|
210
288
|
writeJson(processedPath, { marker, status: "failed",
|
|
211
|
-
updatedAt: new Date().toISOString() });
|
|
289
|
+
ownerPid: process.pid, updatedAt: new Date().toISOString() });
|
|
212
290
|
throw error;
|
|
213
291
|
}
|
|
214
292
|
}
|
|
215
293
|
/** Rebuild frontiers from trials and emit recommendations for the harness's current model. */
|
|
216
294
|
export function recommendTick(policy) {
|
|
295
|
+
const sink = new LocalJsonlEventSink();
|
|
217
296
|
const harness = readJson(paths.harnessState(), {});
|
|
218
297
|
const currentModel = harness.currentModel ?? null;
|
|
298
|
+
const currentRoute = harness.currentRoute ?? null;
|
|
219
299
|
const trials = readJsonl(paths.trials());
|
|
220
300
|
const observations = readJsonl(paths.observations());
|
|
221
301
|
const labelByTask = new Map();
|
|
@@ -237,16 +317,17 @@ export function recommendTick(policy) {
|
|
|
237
317
|
latestById.set(r.id, r);
|
|
238
318
|
const existing = new Set([...latestById.values()]
|
|
239
319
|
.filter((r) => r.status === "pending" || r.status === "dismissed")
|
|
240
|
-
.map((r) => `${r.taskKey}:${r.recommended.model}`));
|
|
320
|
+
.map((r) => `${r.taskKey}:${r.recommended.route ? routeKey(r.recommended.route) : `openrouter:${r.recommended.model}`}`));
|
|
241
321
|
const emitted = [];
|
|
242
322
|
for (const [tKey, points] of byTask) {
|
|
243
|
-
const rec = buildRecommendation(tKey, labelByTask.get(tKey) ?? tKey, currentModel, points, policy);
|
|
323
|
+
const rec = buildRecommendation(tKey, labelByTask.get(tKey) ?? tKey, currentModel, points, policy, null, currentRoute);
|
|
244
324
|
if (!rec)
|
|
245
325
|
continue;
|
|
246
|
-
const dedupeKey = `${rec.taskKey}:${rec.recommended.model}`;
|
|
326
|
+
const dedupeKey = `${rec.taskKey}:${rec.recommended.route ? routeKey(rec.recommended.route) : `openrouter:${rec.recommended.model}`}`;
|
|
247
327
|
if (existing.has(dedupeKey))
|
|
248
328
|
continue;
|
|
249
329
|
appendJsonl(paths.recommendations(), rec);
|
|
330
|
+
sink.emit(meritEvent("recommendation.created", rec));
|
|
250
331
|
emitted.push(rec);
|
|
251
332
|
console.log(`[openmerit] recommendation: ${rec.recommended.model} <- ${currentModel ?? "unknown"} ` +
|
|
252
333
|
`(gain ${rec.evidence.scoreGain}, auto=${rec.policy.autoApply})`);
|
|
@@ -261,12 +342,12 @@ export async function tickOnce(policy) {
|
|
|
261
342
|
key = loadKey();
|
|
262
343
|
}
|
|
263
344
|
catch (e) {
|
|
264
|
-
console.log(`[openmerit] ${e.message};
|
|
345
|
+
console.log(`[openmerit] ${e.message}; OpenRouter enrichment disabled`);
|
|
265
346
|
}
|
|
266
347
|
if (key) {
|
|
267
348
|
await catalogTick(key, policy);
|
|
268
|
-
await autoTaskTick(key, policy);
|
|
269
349
|
}
|
|
350
|
+
await autoTaskTick(key, policy);
|
|
270
351
|
}
|
|
271
352
|
/** Run forever, honoring policy watch intervals. */
|
|
272
353
|
export async function runDaemon(policy) {
|
|
@@ -291,17 +372,15 @@ export async function runDaemon(policy) {
|
|
|
291
372
|
catch {
|
|
292
373
|
/* no key; skip provider ticks */
|
|
293
374
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
lastCatalog = now;
|
|
299
|
-
}
|
|
300
|
-
await autoTaskTick(key, policy);
|
|
301
|
-
}
|
|
302
|
-
catch (e) {
|
|
303
|
-
console.log(`[openmerit] provider tick failed: ${e.message}`);
|
|
375
|
+
try {
|
|
376
|
+
if (key && wantCatalog) {
|
|
377
|
+
await catalogTick(key, policy);
|
|
378
|
+
lastCatalog = now;
|
|
304
379
|
}
|
|
380
|
+
await autoTaskTick(key, policy);
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
console.log(`[openmerit] provider tick failed: ${e.message}`);
|
|
305
384
|
}
|
|
306
385
|
}
|
|
307
386
|
await new Promise((r) => setTimeout(r, tracesMs));
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Read-only diagnostics plus a provider-free self-test for first-run support. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { LocalJsonlEventSink, meritEvent } from "./integrations.js";
|
|
7
|
+
import { piExecutable, availablePiRoutes } from "./pi-trials.js";
|
|
8
|
+
import { DEFAULT_POLICY, loadPolicy, providerAllowed } from "./policy.js";
|
|
9
|
+
import { buildRecommendation } from "./recommend.js";
|
|
10
|
+
import { modelRoute, routeCatalog, routeKey, routeLabel } from "./routes.js";
|
|
11
|
+
import { appendJsonl, paths, readJsonReport, readJsonlReport, writeJson, } from "./store.js";
|
|
12
|
+
function check(id, status, message, details) {
|
|
13
|
+
return { id, status, message, ...(details?.length ? { details } : {}) };
|
|
14
|
+
}
|
|
15
|
+
function packageSources(output) {
|
|
16
|
+
return [...new Set(output.split("\n").filter((line) => /^ {2}\S/.test(line)).map((line) => line.trim())
|
|
17
|
+
.filter((line) => /openmerit/i.test(line) && /^(npm:|git:|https?:|\.?\.?\/|\/)/.test(line)))];
|
|
18
|
+
}
|
|
19
|
+
function safePackageSource(source) {
|
|
20
|
+
if (source.startsWith("npm:/"))
|
|
21
|
+
return `npm:local/${basename(source)}`;
|
|
22
|
+
if (source.startsWith("/"))
|
|
23
|
+
return `local:${basename(source)}`;
|
|
24
|
+
return source;
|
|
25
|
+
}
|
|
26
|
+
/** No credentials, prompts, or trace contents are included in this report. */
|
|
27
|
+
export function collectDoctorReport() {
|
|
28
|
+
const checks = [];
|
|
29
|
+
const executable = piExecutable();
|
|
30
|
+
const versionResult = spawnSync(executable, ["--version"], { encoding: "utf8" });
|
|
31
|
+
const piVersion = versionResult.status === 0 ? versionResult.stdout.trim() : null;
|
|
32
|
+
checks.push(piVersion
|
|
33
|
+
? check("pi", "pass", `Pi detected (${piVersion})`)
|
|
34
|
+
: check("pi", "fail", "Pi is not executable"));
|
|
35
|
+
try {
|
|
36
|
+
const policy = loadPolicy(paths.policy());
|
|
37
|
+
checks.push(check("policy", "pass", `Policy is valid (v${policy.version}, ${policy.mode})`));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
checks.push(check("policy", "fail", `Policy is invalid: ${error.message}`));
|
|
41
|
+
}
|
|
42
|
+
const stateRead = readJsonReport(paths.harnessState(), {});
|
|
43
|
+
checks.push(!stateRead.valid
|
|
44
|
+
? check("harness-state", "fail", "harness-state.json is malformed; start Pi to replace it atomically")
|
|
45
|
+
: stateRead.exists
|
|
46
|
+
? check("harness-state", "pass", "Pi extension state snapshot is readable")
|
|
47
|
+
: check("harness-state", "warn", "No Pi extension state yet; start Pi once with OpenMerit installed"));
|
|
48
|
+
let routes = stateRead.value.routes ?? [];
|
|
49
|
+
const liveSnapshot = routes.length > 0;
|
|
50
|
+
if (!routes.length && piVersion) {
|
|
51
|
+
try {
|
|
52
|
+
routes = availablePiRoutes();
|
|
53
|
+
}
|
|
54
|
+
catch { /* the check below explains it */ }
|
|
55
|
+
}
|
|
56
|
+
let policy = DEFAULT_POLICY;
|
|
57
|
+
try {
|
|
58
|
+
policy = loadPolicy(paths.policy());
|
|
59
|
+
}
|
|
60
|
+
catch { /* already reported */ }
|
|
61
|
+
routes = routes.filter((route) => providerAllowed(policy, route.meritId, route.provider));
|
|
62
|
+
const priced = routes.filter((route) => !!route.cost);
|
|
63
|
+
checks.push(routes.length >= 2
|
|
64
|
+
? check("routes", "pass", `${routes.length} Pi model routes are visible`, routes.slice(0, 8).map((route) => `${routeLabel(route)}${route.cost ? "" : " (price unknown)"}`))
|
|
65
|
+
: check("routes", "fail", `Only ${routes.length} policy-eligible Pi route${routes.length === 1 ? " is" : "s are"} visible; at least two are required`));
|
|
66
|
+
checks.push(liveSnapshot
|
|
67
|
+
? priced.length >= 2
|
|
68
|
+
? check("pricing", "pass", `${priced.length} eligible routes expose pricing`)
|
|
69
|
+
: check("pricing", "warn", "Fewer than two eligible routes expose pricing; unknown-price routes cannot auto-apply")
|
|
70
|
+
: check("pricing", "warn", "Route prices are unavailable until the extension reports a live Pi snapshot"));
|
|
71
|
+
const session = stateRead.value.sessionFile;
|
|
72
|
+
checks.push(!session
|
|
73
|
+
? check("session", "warn", "No active saved Pi session is reported yet")
|
|
74
|
+
: existsSync(session)
|
|
75
|
+
? check("session", "pass", "The reported Pi session file is readable")
|
|
76
|
+
: check("session", "warn", "The reported Pi session file no longer exists"));
|
|
77
|
+
if (piVersion) {
|
|
78
|
+
const listed = spawnSync(executable, ["list"], { encoding: "utf8" });
|
|
79
|
+
const sources = listed.status === 0 ? packageSources(listed.stdout) : [];
|
|
80
|
+
const safeSources = sources.map(safePackageSource);
|
|
81
|
+
checks.push(sources.length > 1
|
|
82
|
+
? check("installation", "fail", "OpenMerit is installed from multiple Pi package sources", safeSources)
|
|
83
|
+
: sources.length === 1
|
|
84
|
+
? check("installation", "pass", `One Pi package source is installed`, safeSources)
|
|
85
|
+
: check("installation", "warn", "OpenMerit is not listed as an installed Pi package; use `pi install npm:openmerit`"));
|
|
86
|
+
}
|
|
87
|
+
const jsonlFiles = [
|
|
88
|
+
["recommendations.jsonl", paths.recommendations()],
|
|
89
|
+
["trials.jsonl", paths.trials()],
|
|
90
|
+
["traces/observations.jsonl", paths.observations()],
|
|
91
|
+
["events.jsonl", paths.events()],
|
|
92
|
+
];
|
|
93
|
+
const malformed = jsonlFiles.flatMap(([label, file]) => {
|
|
94
|
+
const invalid = readJsonlReport(file).invalidLines;
|
|
95
|
+
return invalid.length ? [`${label}: lines ${invalid.join(", ")}`] : [];
|
|
96
|
+
});
|
|
97
|
+
checks.push(malformed.length
|
|
98
|
+
? check("jsonl", "warn", "Malformed JSONL lines were skipped; valid later records remain readable", malformed)
|
|
99
|
+
: check("jsonl", "pass", "Append-only JSONL state is readable"));
|
|
100
|
+
const ledger = readJsonReport(paths.ledger(), { date: "", trials: 0, usd: 0 });
|
|
101
|
+
checks.push(ledger.valid
|
|
102
|
+
? check("ledger", "pass", "Trial-spend ledger is readable")
|
|
103
|
+
: check("ledger", "fail", "Trial-spend ledger is malformed; spending remains disabled until repaired"));
|
|
104
|
+
let openRouter = false;
|
|
105
|
+
try {
|
|
106
|
+
const env = process.env.OPENROUTER_API_KEY;
|
|
107
|
+
if (env?.trim())
|
|
108
|
+
openRouter = true;
|
|
109
|
+
else if (existsSync(paths.envFile()))
|
|
110
|
+
openRouter = /^\s*OPENROUTER_API_KEY\s*=\s*\S+/m
|
|
111
|
+
.test(readFileSync(paths.envFile(), "utf8"));
|
|
112
|
+
}
|
|
113
|
+
catch { /* optional */ }
|
|
114
|
+
checks.push(check("openrouter-enrichment", "pass", openRouter ? "Optional OpenRouter enrichment is configured" : "Optional OpenRouter enrichment is not configured"));
|
|
115
|
+
const activeRoute = stateRead.value.currentRoute ? routeLabel(stateRead.value.currentRoute) : null;
|
|
116
|
+
return {
|
|
117
|
+
schemaVersion: 1,
|
|
118
|
+
ok: !checks.some((item) => item.status === "fail"),
|
|
119
|
+
checks,
|
|
120
|
+
summary: { piVersion, routeCount: routes.length, pricedRouteCount: priced.length, activeRoute },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function renderDiagnosticReport(report) {
|
|
124
|
+
const icon = { pass: "PASS", warn: "WARN", fail: "FAIL" };
|
|
125
|
+
return report.checks.flatMap((item) => [
|
|
126
|
+
`${icon[item.status].padEnd(4)} ${item.id.padEnd(22)} ${item.message}`,
|
|
127
|
+
...(item.details ?? []).map((detail) => ` - ${detail}`),
|
|
128
|
+
]).join("\n");
|
|
129
|
+
}
|
|
130
|
+
/** Exercise core state, routing, recommendation, and event paths without provider calls. */
|
|
131
|
+
export function runOfflineVerify() {
|
|
132
|
+
const previousHome = process.env.OPENMERIT_HOME;
|
|
133
|
+
const root = mkdtempSync(join(tmpdir(), "openmerit-verify-"));
|
|
134
|
+
const checks = [];
|
|
135
|
+
try {
|
|
136
|
+
process.env.OPENMERIT_HOME = root;
|
|
137
|
+
const active = modelRoute("native", "model-a", {
|
|
138
|
+
input: ["text"], cost: { input: 1, output: 2 }, contextWindow: 8192, maxTokens: 4096,
|
|
139
|
+
});
|
|
140
|
+
const candidate = modelRoute("local", "model-b", {
|
|
141
|
+
input: ["text"], cost: { input: 0.2, output: 0.4 }, contextWindow: 8192, maxTokens: 4096,
|
|
142
|
+
});
|
|
143
|
+
writeJson(paths.harnessState(), { schemaVersion: 1, currentRoute: active, routes: [active, candidate] });
|
|
144
|
+
const state = readJsonReport(paths.harnessState(), {});
|
|
145
|
+
checks.push(state.valid && state.value.routes?.length === 2
|
|
146
|
+
? check("atomic-json", "pass", "Atomic JSON state round-trip passed")
|
|
147
|
+
: check("atomic-json", "fail", "Atomic JSON state round-trip failed"));
|
|
148
|
+
const entries = routeCatalog([active, candidate]);
|
|
149
|
+
const point = (route, score) => {
|
|
150
|
+
const entry = entries.get(routeKey(route));
|
|
151
|
+
return { schemaVersion: 1, model: route.meritId, route, score, price: entry.price,
|
|
152
|
+
priceKnown: entry.priceKnown, ts: new Date().toISOString(), source: "pi_trial" };
|
|
153
|
+
};
|
|
154
|
+
const recommendation = buildRecommendation("verify-task", "offline verification", active.meritId, [point(active, 0.6), point(candidate, 0.9)], DEFAULT_POLICY, "/verify/session.jsonl", active);
|
|
155
|
+
checks.push(recommendation?.recommended.route?.provider === "local" &&
|
|
156
|
+
recommendation.policy.reasons.length > 0
|
|
157
|
+
? check("provider-neutral", "pass", "Exact provider routes and policy reasons survive recommendation")
|
|
158
|
+
: check("provider-neutral", "fail", "Provider route or policy evidence was lost"));
|
|
159
|
+
if (recommendation)
|
|
160
|
+
appendJsonl(paths.recommendations(), recommendation);
|
|
161
|
+
appendJsonl(paths.recommendations(), { schemaVersion: 0, id: "legacy", status: "dismissed" });
|
|
162
|
+
appendFileSync(paths.recommendations(), "{interrupted");
|
|
163
|
+
const history = readJsonlReport(paths.recommendations());
|
|
164
|
+
checks.push(history.records.length === 2 && history.invalidLines.length === 1
|
|
165
|
+
? check("jsonl-recovery", "pass", "Valid records survive legacy and interrupted JSONL lines")
|
|
166
|
+
: check("jsonl-recovery", "fail", "JSONL recovery did not preserve valid history"));
|
|
167
|
+
const sink = new LocalJsonlEventSink();
|
|
168
|
+
if (recommendation)
|
|
169
|
+
sink.emit(meritEvent("recommendation.created", recommendation));
|
|
170
|
+
const events = readJsonlReport(paths.events());
|
|
171
|
+
checks.push(events.records.length === 1
|
|
172
|
+
? check("events", "pass", "Versioned neutral event sink round-trip passed")
|
|
173
|
+
: check("events", "fail", "Neutral event sink round-trip failed"));
|
|
174
|
+
checks.push(loadPolicy(paths.policy()).mode === "recommend"
|
|
175
|
+
? check("compatibility", "pass", "Missing 0.1.x policy fields normalize to safe defaults")
|
|
176
|
+
: check("compatibility", "fail", "Policy defaults are unsafe"));
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
checks.push(check("verify", "fail", error.message));
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
if (previousHome === undefined)
|
|
183
|
+
delete process.env.OPENMERIT_HOME;
|
|
184
|
+
else
|
|
185
|
+
process.env.OPENMERIT_HOME = previousHome;
|
|
186
|
+
rmSync(root, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
schemaVersion: 1,
|
|
190
|
+
ok: !checks.some((item) => item.status === "fail"),
|
|
191
|
+
checks,
|
|
192
|
+
summary: { piVersion: null, routeCount: 2, pricedRouteCount: 2, activeRoute: "native/model-a via native" },
|
|
193
|
+
};
|
|
194
|
+
}
|
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);
|
|
@@ -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 ?? "") };
|