auto-model-router 0.21.0 → 0.22.0

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.21.0",
10
+ "version": "0.22.0",
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.21.0",
17
+ "version": "0.22.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1942,6 +1942,19 @@ rather than the refresh failing.
1942
1942
  **Roughly 60% of the catalog is unscored anyway.** Scores are never imputed
1943
1943
  from price, so unscored models are only ever eligible where the floor is zero.
1944
1944
 
1945
+ **External feeds backfill what neither list scores** (the `benchmarks` block, on
1946
+ by default). Artificial Analysis's own v2 data API
1947
+ (`benchmarks.artificialAnalysisApiKey`, or `ARTIFICIAL_ANALYSIS_API_KEY`) and the
1948
+ keyless BenchLM leaderboard fill only the axes a model is *missing* — a score
1949
+ OpenRouter published is never overwritten, and a name matches exactly or the
1950
+ model stays honestly unscored. The feeds are cached in `benchmark_cache` on their
1951
+ own slow cadence (`benchmarks.refreshMs`, a day) so the minute-scale catalog
1952
+ refresh never hits them. Changing the `benchmarks` block itself is the exception:
1953
+ a key pasted into a front door's settings, or cleared out of them, ages that cache
1954
+ out and rebuilds the catalog immediately instead of leaving the change inert until
1955
+ tomorrow. Every fetch stays best-effort — one that fails or returns nothing leaves
1956
+ the scores already serving in place.
1957
+
1945
1958
  ---
1946
1959
 
1947
1960
  ## Adaptive tier floors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -318,7 +318,10 @@ export async function refreshFeedScores(cfg: RouterConfig, db: Database, opts: R
318
318
 
319
319
  const row = db.query("SELECT payload, fetched_at_ms FROM benchmark_cache WHERE id = 1").get() as CacheRow | null;
320
320
  const cached: FeedScore[] | null = row === null ? null : parseFeedScores(row.payload);
321
- if (row !== null && cached !== null && now - row.fetched_at_ms < bm.refreshMs) return cached;
321
+ // A zero timestamp is `invalidateFeedCache`'s marker, not a real fetch time:
322
+ // the payload stays readable as a fallback but never counts as fresh again.
323
+ const fresh = row !== null && row.fetched_at_ms > 0 && now - row.fetched_at_ms < bm.refreshMs;
324
+ if (fresh && cached !== null) return cached;
322
325
 
323
326
  const feedOpts: FetchOpts = { timeoutMs: bm.timeoutMs };
324
327
  if (opts.fetchImpl !== undefined) feedOpts.fetchImpl = opts.fetchImpl;
@@ -347,6 +350,21 @@ export async function refreshFeedScores(cfg: RouterConfig, db: Database, opts: R
347
350
  return merged;
348
351
  }
349
352
 
353
+ /**
354
+ * Mark the cached feeds stale so the next `refreshFeedScores` re-fetches instead
355
+ * of sitting out `benchmarks.refreshMs` (~a day). The benchmarks config changing
356
+ * is what calls this: an Artificial Analysis key that only takes effect tomorrow
357
+ * is a key the operator will believe is broken, and a key taken away has to stop
358
+ * filling scores just as promptly.
359
+ *
360
+ * The row is aged out, never deleted. A forced re-fetch that then fails must
361
+ * still find the previous scores to fall back on — best-effort is the rule here,
362
+ * and invalidation must not be the one path that empties the catalog.
363
+ */
364
+ export function invalidateFeedCache(db: Database): void {
365
+ db.query("UPDATE benchmark_cache SET fetched_at_ms = 0 WHERE id = 1").run();
366
+ }
367
+
350
368
  /** Validate a persisted `FeedScore[]` blob, skipping any entry that drifted. */
351
369
  function parseFeedScores(payload: string): FeedScore[] | null {
352
370
  let parsed: unknown;
@@ -10,6 +10,7 @@ import { redactionRulesFor } from "../config/redaction.ts";
10
10
  import { createSessionOverrides } from "./overrides.ts";
11
11
  import { catalogView } from "./catalog-view.ts";
12
12
  import { buildUpstreamModels } from "../catalog/static-catalog.ts";
13
+ import { invalidateFeedCache } from "../catalog/benchmark-feeds.ts";
13
14
  import { applyRequestPolicy, resolveProfile } from "../router/index.ts";
14
15
  import { parsePolicyHeader } from "../wire/openai/request.ts";
15
16
  import { createDigester } from "./digest.ts";
@@ -799,15 +800,25 @@ export function startServer(cfg: RouterConfig): StartedServer {
799
800
  defaultScope: cfg.context.defaultScope === "" ? "(per-request header only)" : cfg.context.defaultScope,
800
801
  });
801
802
  }
802
- if (!touched(changed, "openrouter", "ollama")) return false;
803
+ const benchmarksChanged = touched(changed, "benchmarks");
804
+ if (!touched(changed, "openrouter", "ollama") && !benchmarksChanged) return false;
805
+ if (benchmarksChanged) {
806
+ // The feed cache keeps its own ~daily TTL, so a refresh alone would rebuild
807
+ // the catalog from yesterday's feeds and the new Artificial Analysis key
808
+ // would do nothing until it expired. Age the row out so the refresh below
809
+ // re-fetches; the payload stays put, so a fetch that fails leaves the
810
+ // scores already serving in place. Only a benchmarks change does this —
811
+ // every other reconfigure keeps the cadence the cache is there for.
812
+ invalidateFeedCache(db);
813
+ }
803
814
  // A key change makes the catalog key-scoped (or not), and enabling Ollama adds
804
815
  // its models: the snapshot is rebuilt before the next turn ranks. Started, not
805
816
  // awaited — the caller is a settings save, not a network client, and the
806
817
  // previous snapshot serves turns until the new one lands.
807
818
  void catalog
808
819
  .refresh()
809
- .then((snap) => log.info("catalog refreshed after an upstream change", { models: snap.models.length, ollama: cfg.ollama.enabled }))
810
- .catch((err: unknown) => log.warn("catalog refresh after an upstream change failed; the previous snapshot stands", { error: err instanceof Error ? err.message : String(err) }));
820
+ .then((snap) => log.info("catalog refreshed after a live config change", { models: snap.models.length, ollama: cfg.ollama.enabled, benchmarks: benchmarksChanged }))
821
+ .catch((err: unknown) => log.warn("catalog refresh after a live config change failed; the previous snapshot stands", { error: err instanceof Error ? err.message : String(err) }));
811
822
  return true;
812
823
  }
813
824
 
@@ -4,6 +4,7 @@ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
4
  import {
5
5
  applyFeedScores,
6
6
  fetchBenchlmScores,
7
+ invalidateFeedCache,
7
8
  normalizeModelKey,
8
9
  parseAaModels,
9
10
  parseBenchlmModels,
@@ -198,6 +199,40 @@ describe("refreshFeedScores", () => {
198
199
  db.close();
199
200
  });
200
201
 
202
+ test("invalidateFeedCache re-fetches inside the TTL, and a failed forced fetch keeps the scores", async () => {
203
+ const db = openDb(":memory:");
204
+ let calls = 0;
205
+ let coding = 58;
206
+ const fakeFetch: FetchLike = async () => {
207
+ calls += 1;
208
+ return Response.json({
209
+ models: coding === 0 ? [] : [{ model: "MiniMax M3", creator: "MiniMax", evidenceStatus: "supported", categoryScores: { coding } }],
210
+ });
211
+ };
212
+
213
+ const cfg = cfgWith({ enabled: true, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 1_000_000 });
214
+ await refreshFeedScores(cfg, db, { fetchImpl: fakeFetch, now: 1000 });
215
+ await refreshFeedScores(cfg, db, { fetchImpl: fakeFetch, now: 2000 });
216
+ expect(calls).toBe(1); // deep inside the TTL
217
+
218
+ // An Artificial Analysis key arriving cannot wait out the day still left on
219
+ // the cache; invalidating is what makes the next refresh actually fetch.
220
+ invalidateFeedCache(db);
221
+ coding = 71;
222
+ const forced = await refreshFeedScores(cfg, db, { fetchImpl: fakeFetch, now: 3000 });
223
+ expect(calls).toBe(2);
224
+ expect(forced[0]).toMatchObject({ key: "minimax-m3", coding: 71 });
225
+
226
+ // And the forced fetch is still best-effort: a feed that answers with nothing
227
+ // leaves the scores already serving in place rather than emptying them.
228
+ invalidateFeedCache(db);
229
+ coding = 0;
230
+ const after = await refreshFeedScores(cfg, db, { fetchImpl: fakeFetch, now: 4000 });
231
+ expect(calls).toBe(3);
232
+ expect(after[0]).toMatchObject({ key: "minimax-m3", coding: 71 });
233
+ db.close();
234
+ });
235
+
201
236
  test("falls back to the stale cache when a refresh returns nothing", async () => {
202
237
  const db = openDb(":memory:");
203
238
  const seed = [{ key: "minimax-m3", creator: "minimax", coding: 58, source: "benchlm" }];
@@ -1,8 +1,12 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { describe, expect, test } from "bun:test";
2
5
  import { applyConfigPatch, assignInPlace, touched } from "../src/config/apply.ts";
3
6
  import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
7
  import type { RouterConfig } from "../src/config/types.ts";
5
8
  import { startServer } from "../src/server/http.ts";
9
+ import { openDb } from "../src/util/sqlite.ts";
6
10
 
7
11
  /**
8
12
  * Live reconfiguration: a running router follows a config change without a
@@ -101,6 +105,55 @@ describe("a running server reconfigures", () => {
101
105
  }
102
106
  });
103
107
 
108
+ test("a benchmarks change ages the feed cache out; an unrelated change leaves it alone", async () => {
109
+ const dir = mkdtempSync(join(tmpdir(), "amr-benchfeed-"));
110
+ const cfg: RouterConfig = {
111
+ ...base(),
112
+ ledger: { ...DEFAULT_CONFIG.ledger, path: join(dir, "ledger.db") },
113
+ // Off so the background catalog refresh cannot re-fetch the feeds under the
114
+ // assertion. Invalidation does not depend on it: the point is that the row
115
+ // is aged out before the refresh runs, whatever the refresh then does.
116
+ benchmarks: { ...DEFAULT_CONFIG.benchmarks, enabled: false },
117
+ };
118
+ const started = startServer(cfg);
119
+ const db = openDb(cfg.ledger.path);
120
+ const fetchedAt = (): number | null => {
121
+ const row = db.query("SELECT fetched_at_ms FROM benchmark_cache WHERE id = 1").get() as { fetched_at_ms: number } | null;
122
+ return row === null ? null : row.fetched_at_ms;
123
+ };
124
+ try {
125
+ const seeded = Date.now();
126
+ db.query("INSERT INTO benchmark_cache (id, payload, fetched_at_ms) VALUES (1, ?, ?)").run("[]", seeded);
127
+
128
+ // The ~daily feed cadence is not every settings save's to reset.
129
+ const unrelated = await started.reconfigure({ filters: { latencyWeight: 0.42 } });
130
+ expect(unrelated.catalogRefreshing).toBe(false);
131
+ expect(fetchedAt()).toBe(seeded);
132
+
133
+ // A key an operator just pasted has to reach the catalog now, not tomorrow.
134
+ const r = await started.reconfigure({ benchmarks: { artificialAnalysisApiKey: "aa-key" } });
135
+ expect(r.rejected).toEqual([]);
136
+ expect(r.changed).toEqual(["benchmarks.artificialAnalysisApiKey"]);
137
+ expect(r.catalogRefreshing).toBe(true);
138
+ expect(fetchedAt()).toBe(0);
139
+ // The payload survives the invalidation, so a failed re-fetch has something
140
+ // to fall back on.
141
+ const row = db.query("SELECT payload FROM benchmark_cache WHERE id = 1").get() as { payload: string } | null;
142
+ expect(row?.payload).toBe("[]");
143
+
144
+ // Clearing it again is a benchmarks change too: scores from a key that is
145
+ // gone must stop being used just as promptly.
146
+ db.query("UPDATE benchmark_cache SET fetched_at_ms = ? WHERE id = 1").run(seeded);
147
+ const cleared = await started.reconfigure({ benchmarks: { artificialAnalysisApiKey: "" } });
148
+ expect(cleared.catalogRefreshing).toBe(true);
149
+ expect(fetchedAt()).toBe(0);
150
+ } finally {
151
+ db.close();
152
+ await started.stop();
153
+ rmSync(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
104
157
  test("the socket and the ledger file are refused rather than half-applied", async () => {
105
158
  const cfg = base();
106
159
  const started = startServer(cfg);