auto-model-router 0.30.1 → 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.1",
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.1",
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.1",
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,
@@ -248,6 +261,11 @@ export function startServer(cfg: RouterConfig): StartedServer {
248
261
  const overrides = createSessionOverrides();
249
262
  const feedback = createFeedbackStore(db);
250
263
  const kv = createKv(db);
264
+ /**
265
+ * Background local-benchmark runs. In memory and not persisted: a run is an explicit,
266
+ * attended admin action, and its lasting output is the `local_scores` row it writes.
267
+ */
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 }>();
251
269
  const digester = createDigester({ cfg, catalog, ledger, upstream, log });
252
270
  const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale, digester };
253
271
 
@@ -768,28 +786,59 @@ export function startServer(cfg: RouterConfig): StartedServer {
768
786
  };
769
787
  const judgeSlug = typeof body?.judge === "string" && body.judge !== "" ? body.judge : "";
770
788
  const asked = typeof body?.concurrency === "number" ? Math.floor(body.concurrency) : 2;
771
- const results = await runEval({
772
- slugs: [slug, ...anchors],
773
- complete,
774
- toolComplete,
775
- repeats: Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20),
776
- concurrency: Math.min(Math.max(1, asked), 8),
777
- ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
778
- });
779
- const target = results[0]!;
780
- const anchorResults = results.slice(1);
781
- const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
782
- const cal = fitCalibration(anchorResults, published);
783
- const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
784
- const fresh = toLocalFeedScores([target], cal, authorOf);
785
- if (fresh.length === 0) {
786
- return json({ slug, anchors, calibrated: null, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" });
787
- }
788
- // Merge, never replace: other models' measurements are not this run's to discard.
789
- const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
790
- saveLocalScores(db, [...kept, ...fresh]);
791
- log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
792
- return json({ slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, calibrated: fresh[0], errors: target.errors, applied: cfg.benchmarks.useLocalScores });
789
+ // The run is a BACKGROUND job, not a long request. Bun caps `idleTimeout` at 255
790
+ // seconds, so a suite of any size over ten repeats outlives the socket: measured,
791
+ // a single pass returned in 193s and two passes died at ~350s with the response
792
+ // lost. The caller polls instead.
793
+ const jobId = `bench_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
794
+ const started = Date.now();
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 });
799
+ void (async () => {
800
+ try {
801
+ const results = await runEval({
802
+ slugs: [slug, ...anchors],
803
+ complete,
804
+ toolComplete,
805
+ repeats,
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
+ },
811
+ ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
812
+ });
813
+ const target = results[0]!;
814
+ const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
815
+ const cal = fitCalibration(results.slice(1), published);
816
+ const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
817
+ const fresh = toLocalFeedScores([target], cal, authorOf);
818
+ const shared = { slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, tookMs: Date.now() - started };
819
+ if (fresh.length === 0) {
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" } });
821
+ return;
822
+ }
823
+ // Merge, never replace: other models' measurements are not this run's to discard.
824
+ const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
825
+ saveLocalScores(db, [...kept, ...fresh]);
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 });
827
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: fresh[0], applied: cfg.benchmarks.useLocalScores } });
828
+ } catch (err) {
829
+ const message = err instanceof Error ? err.message : String(err);
830
+ log.warn("local benchmark run failed", { slug, error: message });
831
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "error", error: message });
832
+ }
833
+ })();
834
+ return json({ jobId, state: "running", slug, anchors, totalPasses });
835
+ }
836
+ if (req.method === "GET" && url.pathname === "/v1/router/benchmark") {
837
+ const jobId = url.searchParams.get("job");
838
+ if (jobId === null) return json({ jobs: [...benchmarkJobs.entries()].map(([id, j]) => ({ id, slug: j.slug, state: j.state, startedAtMs: j.startedAtMs, ...benchmarkProgress(j) })) });
839
+ const job = benchmarkJobs.get(jobId);
840
+ if (job === undefined) return wireErrorResponse({ status: 404, code: "invalid_request_error", message: `no benchmark job ${jobId}` });
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 }) });
793
842
  }
794
843
  if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
795
844
  // A user verdict on the newest routed turn of an omp session.