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/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 { ingestNewTraces } from "./traces.js";
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 { availablePiModels, runPiTrial, scorePiRun, settledActiveTask } from "./pi-trials.js";
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 = ingestNewTraces();
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
- appendJsonl(paths.trials(), { ...point, taskKey: tKey });
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
- const cat = await fetchCatalog(key);
134
- const piModels = availablePiModels();
135
- for (const id of [...cat.keys()]) {
136
- const e = cat.get(id);
137
- if (!piModels.has(id) || e.price > policy.max_usd_per_m || !providerAllowed(policy, id) ||
138
- (task.images.length > 0 && !e.inputModalities?.includes("image")))
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
- if (!cat.has(task.model))
142
- throw new Error(`active model ${task.model} is not trialable in pi/OpenRouter`);
143
- const judge = resolvePref(cat, [policy.judge_model, ...JUDGE_PREFS], "judge");
144
- const strategist = resolvePref(cat, [policy.strategist_model, ...STRAT_PREFS], "strategist");
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
- benchmarkCandidates = await openRouterBenchmarkCandidates(key, category, cat);
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.model, index: 1, total });
162
- const baseline = await scorePiRun(key, judge, task.task, rubric, task.model, cat.get(task.model), task.run, "trace", task.images);
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.model);
165
- appendJsonl(paths.trials(), { ...baseline.point, taskKey: tKey, sessionFile: task.sessionFile });
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.model, index: 1, total, score: baseline.point.score,
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 { point, costUsd, error } = await runPiTrial(key, judge, task.task, rubric, pick.model, cat.get(pick.model), task.cwd, task.images);
182
- tried.add(pick.model);
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
- appendJsonl(paths.trials(), { ...point, taskKey: tKey, sessionFile: task.sessionFile });
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)}${error ? ` error=${error.slice(0, 80)}` : ""}`);
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}; skipping provider ticks`);
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
- if (key) {
291
- try {
292
- if (wantCatalog) {
293
- await catalogTick(key, policy);
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 noWorse = a.score >= b.score && a.price <= b.price && aLat <= bLat;
7
- const better = a.score > b.score || a.price < b.price || aLat < bLat;
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.price - b.price || b.score - a.score);
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.price - b.price || b.score - a.score);
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 || a.price - b.price || (a.latencyMs ?? 1e18) - (b.latencyMs ?? 1e18))[0];
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.model !== best.model);
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 @@
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 { chat, chatWithImages } from "./llm.js";
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(key, judgeModel, JUDGE_PROMPT.replace("{task}", task)
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(key, judgeModel, prompt, images, 1024);
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 OpenRouter chat client for catalog and trial requests. */
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(OR + path, {
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
- const r = await request(key, "/chat/completions", {
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
- const r = await request(key, "/chat/completions", {
56
- model,
57
- messages: [{ role: "user", content: [
58
- { type: "text", text: prompt },
59
- ...images.map((image) => ({ type: "image_url",
60
- image_url: { url: `data:${image.mimeType};base64,${image.data}` } })),
61
- ] }],
62
- max_tokens: maxTokens,
63
- temperature: 0,
64
- });
65
- return { content: r.choices[0]?.message?.content ?? "", usage: r.usage ?? {} };
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
  }