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/dist/llm.js CHANGED
@@ -1,6 +1,10 @@
1
1
  /** Minimal provider clients for judge and strategist requests. */
2
- import { existsSync, readFileSync } from "node:fs";
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() {
@@ -100,3 +104,100 @@ export async function chat(key, model, prompt, maxTokens, temperature) {
100
104
  export async function chatWithImages(key, model, prompt, images, maxTokens) {
101
105
  return providerFor(model, key).chatWithImages(model, prompt, images, maxTokens);
102
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
+ }
203
+ }
package/dist/pi-trials.js CHANGED
@@ -8,6 +8,8 @@ import { judge, judgeWithImages } from "./judge.js";
8
8
  import { knownInvoiceScore } from "./invoice-eval.js";
9
9
  import { paths, readJson } from "./store.js";
10
10
  import { sessionFiles, sessionImages, taskInputKey } from "./task-input.js";
11
+ import { legacyOpenRouterRoute, meritModelId, modelRoute, routeKey } from "./routes.js";
12
+ import { directChatClient } from "./llm.js";
11
13
  function answerText(content) {
12
14
  if (typeof content === "string")
13
15
  return content;
@@ -107,10 +109,13 @@ export function recordedActiveTask(task, model, images = [], sessionFile, sessio
107
109
  toolErrors++;
108
110
  }
109
111
  const userImages = user ? sessionImages(user.content) : null;
110
- if (!user || !userImages || taskInputKey(answerText(user.content), userImages) !==
111
- taskInputKey(task, images))
112
+ const files = sessionFiles(task);
113
+ if (!user || !userImages || taskInputKey(answerText(user.content), userImages, sessionFiles(answerText(user.content))) !==
114
+ taskInputKey(task, images, files))
112
115
  return null;
113
- const matching = assistants.filter((m) => (m.provider === "openrouter" ? m.model : `${m.provider}/${m.model}`) === model);
116
+ const matching = assistants.filter((m) => typeof model === "string"
117
+ ? meritModelId(m.provider ?? "openrouter", m.model ?? "") === model
118
+ : m.provider === model.provider && m.model === model.modelId);
114
119
  if (matching.length === 0 || matching.some((m) => m.stopReason === "error"))
115
120
  return null;
116
121
  const answer = matching.map((m) => answerText(m.content)).filter(Boolean).at(-1) ?? "";
@@ -167,23 +172,52 @@ export function settledActiveTask(snapshot) {
167
172
  const files = sessionFiles(task);
168
173
  if (!task.trim() || taskInputKey(task, images, files) !== st.settledTaskKey)
169
174
  return null;
170
- const run = recordedActiveTask(task, st.currentModel, images, st.sessionFile, st.sessionBytes);
175
+ const route = st.currentRoute ?? legacyOpenRouterRoute(st.currentModel);
176
+ const run = recordedActiveTask(task, route, images, st.sessionFile, st.sessionBytes);
171
177
  if (!run)
172
178
  return null;
179
+ const routes = st.routes?.length ? st.routes : [route];
173
180
  return { task, images, model: st.currentModel, run, sessionFile: st.sessionFile,
174
- settledAt: st.settledAt, sessionBytes: st.sessionBytes, cwd: st.cwd ?? process.cwd(), usedTools, files };
181
+ settledAt: st.settledAt, sessionBytes: st.sessionBytes, cwd: st.cwd ?? process.cwd(), usedTools, files,
182
+ route, routes };
175
183
  }
176
- /** Limit candidates to models this installed pi can actually select. */
177
- export function availablePiModels() {
178
- const result = spawnSync("pi", ["--offline", "--list-models", "openrouter"], { encoding: "utf8" });
184
+ export function piExecutable() {
185
+ return process.env.OPENMERIT_PI_BIN?.trim() || "pi";
186
+ }
187
+ function compactNumber(value) {
188
+ const match = value.match(/^(\d+(?:\.\d+)?)([KMG])?$/i);
189
+ if (!match)
190
+ return undefined;
191
+ const scale = { K: 1_000, M: 1_000_000, G: 1_000_000_000 }[(match[2] ?? "").toUpperCase()] ?? 1;
192
+ return Math.round(Number(match[1]) * scale);
193
+ }
194
+ /** Read provider/model routes from pi when no live extension snapshot is available. */
195
+ export function availablePiRoutes(snapshot) {
196
+ if (snapshot?.length)
197
+ return snapshot;
198
+ const result = spawnSync(piExecutable(), ["--offline", "--list-models"], { encoding: "utf8" });
179
199
  if (result.error || result.status !== 0)
180
- throw new Error("could not list pi OpenRouter models");
181
- return new Set(result.stdout.split("\n").slice(1)
182
- .map((line) => line.trim().split(/\s+/)[1])
183
- .filter((id) => !!id && !id.startsWith("~")));
200
+ throw new Error("could not list models from pi");
201
+ const routes = [];
202
+ for (const line of result.stdout.split("\n").slice(1)) {
203
+ const [provider, id, context, maxOut, , images] = line.trim().split(/\s+/);
204
+ if (!provider || !id || id.startsWith("~"))
205
+ continue;
206
+ routes.push(modelRoute(provider, id, {
207
+ input: images === "yes" ? ["text", "image"] : ["text"],
208
+ contextWindow: compactNumber(context),
209
+ maxTokens: compactNumber(maxOut),
210
+ }));
211
+ }
212
+ return routes;
213
+ }
214
+ /** Backward-compatible logical-id view used by the standalone OpenRouter trial command. */
215
+ export function availablePiModels() {
216
+ return new Set(availablePiRoutes().map((route) => route.meritId));
184
217
  }
185
218
  /** Each invocation is a fresh pi run, so model B does not inherit model A's answer. */
186
219
  export async function executePiTask(model, task, cwd = process.cwd(), images = [], files = []) {
220
+ const route = typeof model === "string" ? legacyOpenRouterRoute(model) : model;
187
221
  const imageDir = images.length ? mkdtempSync(join(tmpdir(), "openmerit-pi-image-")) : null;
188
222
  const suffix = {
189
223
  "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif",
@@ -209,10 +243,10 @@ export async function executePiTask(model, task, cwd = process.cwd(), images = [
209
243
  });
210
244
  const candidateTask = [...attachmentPaths.entries()].reduce((text, [source, target]) => text.split(source).join(target), task);
211
245
  const before = fileSnapshot(workspace);
212
- const traceFile = paths.trialTrace(`${model}:${task}:${Date.now()}`);
246
+ const traceFile = paths.trialTrace(`${routeKey(route)}:${task}:${Date.now()}`);
213
247
  const traceLines = [];
214
- const child = spawn("pi", [
215
- "--provider", "openrouter", "--model", model, "--mode", "json",
248
+ const child = spawn(piExecutable(), [
249
+ "--provider", route.provider, "--model", route.modelId, "--mode", "json",
216
250
  "--offline", "--no-extensions", "--approve", ...piTrialToolArgs(),
217
251
  "--print", ...imageArgs, ...fileArgs, candidateTask,
218
252
  ], { cwd: workspace, stdio: ["ignore", "pipe", "pipe"] });
@@ -275,7 +309,7 @@ export async function executePiTask(model, task, cwd = process.cwd(), images = [
275
309
  if (!after.has(file))
276
310
  changedFiles++;
277
311
  if (exitCode !== 0 || !answer.trim()) {
278
- throw new Error(`pi trial ${model} failed: ${stderr.trim().slice(0, 180) || `exit ${exitCode}, empty answer`}`);
312
+ throw new Error(`pi trial ${route.meritId} via ${route.provider} failed: ${stderr.trim().slice(0, 180) || `exit ${exitCode}, empty answer`}`);
279
313
  }
280
314
  return { answer, costUsd, latencyMs: Date.now() - started, errors, sessionId,
281
315
  toolCalls, toolErrors, changedFiles, traceFile };
@@ -287,29 +321,30 @@ export async function executePiTask(model, task, cwd = process.cwd(), images = [
287
321
  }
288
322
  }
289
323
  /** Quality uses the same OpenMerit judge; cost and latency come from pi itself. */
290
- export async function runPiTrial(key, judgeModel, task, rubric, model, entry, cwd = process.cwd(), images = [], files = [], harness = defaultHarness) {
324
+ export async function runPiTrial(key, judgeModel, task, rubric, model, entry, cwd = process.cwd(), images = [], files = [], harness = defaultHarness, client = directChatClient(key)) {
291
325
  try {
292
- const run = await harness.runTask(model, { task, cwd, images, files });
293
- return await scorePiRun(key, judgeModel, task, rubric, model, entry, run, "pi_trial", images);
326
+ const run = await harness.runTask(entry?.route ?? model, { task, cwd, images, files });
327
+ return await scorePiRun(key, judgeModel, task, rubric, model, entry, run, "pi_trial", images, client);
294
328
  }
295
329
  catch (e) {
296
330
  const error = String(e);
297
331
  return {
298
- point: { model, score: 0, price: entry?.price ?? 0,
332
+ point: { schemaVersion: 1, model, route: entry?.route, score: 0, price: entry?.price ?? 0,
333
+ priceKnown: entry?.priceKnown ?? !!entry,
299
334
  ts: new Date().toISOString(), source: "pi_trial", toolCalls: 0, toolErrors: 1,
300
335
  changedFiles: 0, why: error.slice(0, 160) },
301
336
  costUsd: 0, error,
302
337
  };
303
338
  }
304
339
  }
305
- export async function scorePiRun(key, judgeModel, task, rubric, model, entry, run, source, images = []) {
340
+ export async function scorePiRun(key, judgeModel, task, rubric, model, entry, run, source, images = [], client = directChatClient(key)) {
306
341
  let score = 0;
307
342
  let why = "judge unavailable";
308
343
  try {
309
344
  const pinned = images.length ? await knownInvoiceScore(task, images, run.answer) : null;
310
345
  const j = pinned ?? (images.length
311
- ? await judgeWithImages(key, judgeModel, task, rubric, run.answer, images)
312
- : await judge(key, judgeModel, task, rubric, run.answer));
346
+ ? await judgeWithImages(key, judgeModel, task, rubric, run.answer, images, client)
347
+ : await judge(key, judgeModel, task, rubric, run.answer, client));
313
348
  score = Math.max(0, Math.min(1, j.score));
314
349
  why = j.why;
315
350
  }
@@ -318,7 +353,8 @@ export async function scorePiRun(key, judgeModel, task, rubric, model, entry, ru
318
353
  }
319
354
  return {
320
355
  point: {
321
- model, score, price: entry?.price ?? 0,
356
+ schemaVersion: 1, model, route: entry?.route, score, price: entry?.price ?? 0,
357
+ priceKnown: entry?.priceKnown ?? !!entry,
322
358
  latencyMs: run.latencyMs, ts: new Date().toISOString(), source,
323
359
  why: `${why}${run.errors ? `; pi errors: ${run.errors}` : ""}; ` +
324
360
  `tools=${run.toolCalls}, toolErrors=${run.toolErrors}, changedFiles=${run.changedFiles}`,
package/dist/policy.js CHANGED
@@ -60,10 +60,11 @@ export function loadPolicy(path) {
60
60
  max_usd_per_m: num(raw.max_usd_per_m, d.max_usd_per_m),
61
61
  };
62
62
  }
63
- /** Is a model id allowed by the provider allow/deny lists? ("*" matches all; "vendor/*" matches a vendor.) */
64
- export function providerAllowed(policy, modelId) {
63
+ /** Match either a logical vendor/model id or its concrete route provider. */
64
+ export function providerAllowed(policy, modelId, routeProvider) {
65
65
  const vendor = modelId.split("/")[0];
66
- const match = (pat) => pat === "*" || pat === modelId || (pat.endsWith("/*") && pat.slice(0, -2) === vendor);
66
+ const match = (pat) => pat === "*" || pat === modelId || pat === routeProvider ||
67
+ (pat.endsWith("/*") && (pat.slice(0, -2) === vendor || pat.slice(0, -2) === routeProvider));
67
68
  if (policy.providers.deny.some(match))
68
69
  return false;
69
70
  return policy.providers.allow.some(match);
@@ -87,6 +88,10 @@ export function gate(policy, input) {
87
88
  reasons.push("recommendation is not bound to a pi session");
88
89
  return { autoApply: false, reasons };
89
90
  }
91
+ if (input.priceKnown === false) {
92
+ reasons.push("candidate or current-model price is unknown");
93
+ return { autoApply: false, reasons };
94
+ }
90
95
  if (policy.mode !== "auto" || !policy.auto_apply.enabled) {
91
96
  reasons.push(`policy mode is "${policy.mode}" (auto_apply ${policy.auto_apply.enabled ? "enabled" : "disabled"})`);
92
97
  return { autoApply: false, reasons };
package/dist/recommend.js CHANGED
@@ -2,40 +2,49 @@
2
2
  import { createHash } from "node:crypto";
3
3
  import { paretoFrontier, pickBest, pickFallback } from "./frontier.js";
4
4
  import { gate, providerAllowed } from "./policy.js";
5
- export function buildRecommendation(taskKey, taskLabel, currentModel, points, policy, sessionFile = null) {
5
+ import { pointKey, routeLabel } from "./routes.js";
6
+ export function buildRecommendation(taskKey, taskLabel, currentModel, points, policy, sessionFile = null, currentRoute = currentModel ? points.find((p) => p.model === currentModel)?.route ?? null : null) {
6
7
  const scored = points.filter((p) => p.score > 0);
7
8
  if (scored.length === 0)
8
9
  return null;
9
10
  const best = pickBest(scored);
10
11
  if (!best)
11
12
  return null;
12
- if (currentModel && best.model === currentModel)
13
- return null; // already on the best model
14
- const current = currentModel ? scored.find((p) => p.model === currentModel) : undefined;
13
+ if (currentModel && best.model === currentModel &&
14
+ (!currentRoute || pointKey(best) === `${currentRoute.provider}:${currentRoute.modelId}`))
15
+ return null;
16
+ const current = currentModel ? scored.find((p) => currentRoute
17
+ ? pointKey(p) === `${currentRoute.provider}:${currentRoute.modelId}`
18
+ : p.model === currentModel) : undefined;
15
19
  const scoreGain = current ? best.score - current.score : 0;
16
20
  // pickBest may choose a cheaper or faster model at equal quality.
17
21
  if (current && scoreGain < 0)
18
22
  return null;
19
- const priceRatio = current && current.price > 0 ? best.price / current.price : 1;
20
- const frontierModels = paretoFrontier(scored).map((p) => p.model);
21
- const onFrontier = frontierModels.includes(best.model);
23
+ const priceKnown = best.priceKnown !== false && (!current || current.priceKnown !== false);
24
+ const priceRatio = priceKnown && current && current.price > 0 ? best.price / current.price : 1;
25
+ const frontierModels = paretoFrontier(scored).map(pointKey);
26
+ const onFrontier = frontierModels.includes(pointKey(best));
22
27
  const fb = pickFallback(scored, best, policy.fallback.min_score);
23
28
  const decision = gate(policy, {
24
29
  scoreGain,
25
30
  priceRatio,
31
+ priceKnown,
26
32
  onFrontier,
27
- providerOk: providerAllowed(policy, best.model),
33
+ providerOk: providerAllowed(policy, best.model, best.route?.provider),
28
34
  baselineMeasured: !!current,
29
35
  sessionBound: !!sessionFile,
30
36
  });
31
- const reason = `${best.model} scores ${best.score.toFixed(2)} vs ` +
37
+ const bestLabel = best.route ? routeLabel(best.route) : best.model;
38
+ const reason = `${bestLabel} scores ${best.score.toFixed(2)} vs ` +
32
39
  (current ? `${current.score.toFixed(2)} for ${current.model}` : "no baseline measured") +
33
- ` (${scoreGain > 0 ? `quality gain ${scoreGain.toFixed(2)}` : "equal measured quality"}), at $${best.price.toFixed(2)}/M` +
34
- (current ? ` (${priceRatio.toFixed(2)}x current price)` : "") +
40
+ ` (${scoreGain > 0 ? `quality gain ${scoreGain.toFixed(2)}` : "equal measured quality"}), ` +
41
+ (best.priceKnown === false ? "with unknown price" : `at $${best.price.toFixed(2)}/M`) +
42
+ (current && priceKnown ? ` (${priceRatio.toFixed(2)}x current price)` : "") +
35
43
  (best.why ? `. Judge: ${best.why}` : "");
36
44
  return {
45
+ schemaVersion: 1,
37
46
  id: createHash("sha1")
38
- .update(`${taskKey}:${best.model}:${Date.now()}`)
47
+ .update(`${taskKey}:${pointKey(best)}:${Date.now()}`)
39
48
  .digest("hex")
40
49
  .slice(0, 10),
41
50
  ts: new Date().toISOString(),
@@ -43,17 +52,21 @@ export function buildRecommendation(taskKey, taskLabel, currentModel, points, po
43
52
  taskLabel,
44
53
  sessionFile,
45
54
  currentModel,
46
- recommended: { model: best.model, reason },
55
+ currentRoute,
56
+ recommended: { model: best.model, route: best.route, reason },
47
57
  fallback: fb
48
58
  ? {
49
59
  model: fb.model,
50
- reason: `score ${fb.score.toFixed(2)} at $${fb.price.toFixed(2)}/M` +
60
+ route: fb.route,
61
+ reason: `score ${fb.score.toFixed(2)} ` +
62
+ (fb.priceKnown === false ? "with unknown price" : `at $${fb.price.toFixed(2)}/M`) +
51
63
  (fb.latencyMs ? `, ~${Math.round(fb.latencyMs)}ms` : ""),
52
64
  }
53
65
  : null,
54
66
  evidence: {
55
67
  scoreGain: Math.round(scoreGain * 1000) / 1000,
56
68
  priceRatio: Math.round(priceRatio * 100) / 100,
69
+ priceKnown,
57
70
  onFrontier,
58
71
  trials: scored.length,
59
72
  },
package/dist/routes.js ADDED
@@ -0,0 +1,59 @@
1
+ /** Provider-neutral model-route normalization. */
2
+ export function meritModelId(provider, modelId) {
3
+ if (provider === "openrouter")
4
+ return modelId;
5
+ return modelId.startsWith(`${provider}/`) ? modelId : `${provider}/${modelId}`;
6
+ }
7
+ export function modelRoute(provider, modelId, details = {}) {
8
+ return {
9
+ provider,
10
+ modelId,
11
+ meritId: meritModelId(provider, modelId),
12
+ input: details.input ?? ["text"],
13
+ ...details,
14
+ };
15
+ }
16
+ export function legacyOpenRouterRoute(meritId) {
17
+ return modelRoute("openrouter", meritId);
18
+ }
19
+ export function routeKey(route) {
20
+ return `${route.provider}:${route.modelId}`;
21
+ }
22
+ export function pointKey(point) {
23
+ return point.route ? routeKey(point.route) : `openrouter:${point.model}`;
24
+ }
25
+ export function routeLabel(route) {
26
+ return route.provider === "openrouter" ? route.meritId : `${route.meritId} via ${route.provider}`;
27
+ }
28
+ export function routePrice(route) {
29
+ if (!route.cost || !Number.isFinite(route.cost.input) || !Number.isFinite(route.cost.output))
30
+ return { price: 0, known: false };
31
+ return { price: Math.round(((route.cost.input + route.cost.output) / 2 + Number.EPSILON) * 1e4) / 1e4,
32
+ known: true };
33
+ }
34
+ export function catalogEntryFromRoute(route) {
35
+ const priced = routePrice(route);
36
+ return {
37
+ id: route.meritId,
38
+ name: routeLabel(route),
39
+ ctx: route.contextWindow ?? 0,
40
+ pp: route.cost ? route.cost.input / 1e6 : 0,
41
+ pc: route.cost ? route.cost.output / 1e6 : 0,
42
+ price: priced.price,
43
+ priceKnown: priced.known,
44
+ inputModalities: route.input,
45
+ route,
46
+ };
47
+ }
48
+ export function routeCatalog(routes) {
49
+ const out = new Map();
50
+ for (const route of routes)
51
+ out.set(routeKey(route), catalogEntryFromRoute(route));
52
+ return out;
53
+ }
54
+ /** Preserve the route while enriching an OpenRouter entry with its live catalog metadata. */
55
+ export function enrichRouteEntry(entry, live) {
56
+ if (!live)
57
+ return entry;
58
+ return { ...live, route: entry.route, id: entry.id, name: entry.name, priceKnown: true };
59
+ }