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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/catalog/benchmark-feeds.ts +19 -1
- package/src/server/http.ts +14 -3
- package/test/benchmark-feeds.test.ts +35 -0
- package/test/reconfigure.test.ts +53 -0
|
@@ -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.
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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;
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
810
|
-
.catch((err: unknown) => log.warn("catalog refresh after
|
|
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" }];
|
package/test/reconfigure.test.ts
CHANGED
|
@@ -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);
|