auto-model-router 0.30.2 → 0.30.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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.30.2",
10
+ "version": "0.30.3",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.30.2",
17
+ "version": "0.30.3",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.30.2",
3
+ "version": "0.30.3",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
package/src/eval/run.ts CHANGED
@@ -67,6 +67,12 @@ export interface RunEvalArgs {
67
67
  concurrency?: number;
68
68
  /** Called as each model finishes, for progress logging. */
69
69
  onProgress?: (result: EvalResult, done: number, total: number) => void;
70
+ /**
71
+ * Called as each PASS of each model finishes. A run is `models x repeats` passes and takes
72
+ * the better part of an hour at ten repeats, so per-model progress is too coarse to show
73
+ * anyone: without this the only observable states are "running" and "finished".
74
+ */
75
+ onPass?: (slug: string, pass: number, of: number) => void;
70
76
  /**
71
77
  * A tool-capable completer. Absent ⇒ the agentic SCENARIOS are skipped and the axis falls
72
78
  * back to the text tasks, which only ever measured whether a model can format JSON.
@@ -171,6 +177,7 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
171
177
  let errors = 0;
172
178
  for (let i = 0; i < passes; i++) {
173
179
  const pass = await scorePass(slug, args);
180
+ args.onPass?.(slug, i + 1, passes);
174
181
  errors += pass.errors;
175
182
  for (const axis of AXES) {
176
183
  axes[axis].sum += pass.axes[axis].sum;
@@ -182,6 +182,19 @@ export function ollamaRunway(
182
182
  return { dailyBurnUsd, creditsLeftUsd, days: dailyBurnUsd > 0 ? creditsLeftUsd / dailyBurnUsd : null };
183
183
  }
184
184
 
185
+ /**
186
+ * Progress for a local benchmark run, as the dashboard needs it. The ETA is measured from
187
+ * this run's own completed passes rather than assumed — passes vary with the model's speed,
188
+ * and a guess from a fixed per-pass constant would be wrong by minutes on a slow upstream.
189
+ * Null until a pass has finished, because until then there is nothing to extrapolate from.
190
+ */
191
+ function benchmarkProgress(job: { state: string; startedAtMs: number; donePasses: number; totalPasses: number; current: string }): Record<string, unknown> {
192
+ const elapsedMs = Date.now() - job.startedAtMs;
193
+ const fraction = job.totalPasses > 0 ? job.donePasses / job.totalPasses : 0;
194
+ const etaMs = job.state === "running" && job.donePasses > 0 ? Math.round((elapsedMs / job.donePasses) * (job.totalPasses - job.donePasses)) : null;
195
+ return { donePasses: job.donePasses, totalPasses: job.totalPasses, fraction, current: job.current, elapsedMs, etaMs };
196
+ }
197
+
185
198
  function json(data: unknown, status = 200): Response {
186
199
  return new Response(JSON.stringify(data), {
187
200
  status,
@@ -252,7 +265,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
252
265
  * Background local-benchmark runs. In memory and not persisted: a run is an explicit,
253
266
  * attended admin action, and its lasting output is the `local_scores` row it writes.
254
267
  */
255
- const benchmarkJobs = new Map<string, { state: "running" | "done" | "error"; slug: string; startedAtMs: number; result?: Record<string, unknown>; error?: string }>();
268
+ const benchmarkJobs = new Map<string, { state: "running" | "done" | "error"; slug: string; startedAtMs: number; donePasses: number; totalPasses: number; current: string; result?: Record<string, unknown>; error?: string }>();
256
269
  const digester = createDigester({ cfg, catalog, ledger, upstream, log });
257
270
  const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale, digester };
258
271
 
@@ -779,15 +792,22 @@ export function startServer(cfg: RouterConfig): StartedServer {
779
792
  // lost. The caller polls instead.
780
793
  const jobId = `bench_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
781
794
  const started = Date.now();
782
- benchmarkJobs.set(jobId, { state: "running", slug, startedAtMs: started });
795
+ const repeats = Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20);
796
+ // A pass of one model is the unit of progress: models x repeats, known up front.
797
+ const totalPasses = repeats * (1 + anchors.length);
798
+ benchmarkJobs.set(jobId, { state: "running", slug, startedAtMs: started, donePasses: 0, totalPasses, current: slug });
783
799
  void (async () => {
784
800
  try {
785
801
  const results = await runEval({
786
802
  slugs: [slug, ...anchors],
787
803
  complete,
788
804
  toolComplete,
789
- repeats: Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20),
805
+ repeats,
790
806
  concurrency: Math.min(Math.max(1, asked), 8),
807
+ onPass: (passSlug, _pass, _of) => {
808
+ const job = benchmarkJobs.get(jobId);
809
+ if (job !== undefined) benchmarkJobs.set(jobId, { ...job, donePasses: job.donePasses + 1, current: passSlug });
810
+ },
791
811
  ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
792
812
  });
793
813
  const target = results[0]!;
@@ -797,28 +817,28 @@ export function startServer(cfg: RouterConfig): StartedServer {
797
817
  const fresh = toLocalFeedScores([target], cal, authorOf);
798
818
  const shared = { slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, tookMs: Date.now() - started };
799
819
  if (fresh.length === 0) {
800
- benchmarkJobs.set(jobId, { state: "done", slug, startedAtMs: started, result: { ...shared, calibrated: null, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" } });
820
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: null, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" } });
801
821
  return;
802
822
  }
803
823
  // Merge, never replace: other models' measurements are not this run's to discard.
804
824
  const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
805
825
  saveLocalScores(db, [...kept, ...fresh]);
806
826
  log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, repeats: target.repeats, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
807
- benchmarkJobs.set(jobId, { state: "done", slug, startedAtMs: started, result: { ...shared, calibrated: fresh[0], applied: cfg.benchmarks.useLocalScores } });
827
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: fresh[0], applied: cfg.benchmarks.useLocalScores } });
808
828
  } catch (err) {
809
829
  const message = err instanceof Error ? err.message : String(err);
810
830
  log.warn("local benchmark run failed", { slug, error: message });
811
- benchmarkJobs.set(jobId, { state: "error", slug, startedAtMs: started, error: message });
831
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "error", error: message });
812
832
  }
813
833
  })();
814
- return json({ jobId, state: "running", slug, anchors });
834
+ return json({ jobId, state: "running", slug, anchors, totalPasses });
815
835
  }
816
836
  if (req.method === "GET" && url.pathname === "/v1/router/benchmark") {
817
837
  const jobId = url.searchParams.get("job");
818
- if (jobId === null) return json({ jobs: [...benchmarkJobs.entries()].map(([id, j]) => ({ id, slug: j.slug, state: j.state, startedAtMs: j.startedAtMs })) });
838
+ if (jobId === null) return json({ jobs: [...benchmarkJobs.entries()].map(([id, j]) => ({ id, slug: j.slug, state: j.state, startedAtMs: j.startedAtMs, ...benchmarkProgress(j) })) });
819
839
  const job = benchmarkJobs.get(jobId);
820
840
  if (job === undefined) return wireErrorResponse({ status: 404, code: "invalid_request_error", message: `no benchmark job ${jobId}` });
821
- return json({ id: jobId, slug: job.slug, state: job.state, startedAtMs: job.startedAtMs, ...(job.result === undefined ? {} : { result: job.result }), ...(job.error === undefined ? {} : { error: job.error }) });
841
+ return json({ id: jobId, slug: job.slug, state: job.state, startedAtMs: job.startedAtMs, ...benchmarkProgress(job), ...(job.result === undefined ? {} : { result: job.result }), ...(job.error === undefined ? {} : { error: job.error }) });
822
842
  }
823
843
  if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
824
844
  // A user verdict on the newest routed turn of an omp session.