auto-model-router 0.21.0 → 0.23.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/cost/ledger.ts +4 -0
- package/src/cost/types.ts +7 -0
- package/src/server/http.ts +14 -3
- package/test/benchmark-feeds.test.ts +35 -0
- package/test/reconfigure.test.ts +53 -0
- package/test/views.test.ts +16 -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.23.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.23.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/cost/ledger.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { computeBlendedRate } from "./blended.ts";
|
|
|
20
20
|
import { computeCost } from "./forecast.ts";
|
|
21
21
|
import type {
|
|
22
22
|
BlendedRate,
|
|
23
|
+
CostBreakdown,
|
|
23
24
|
EscalationCost,
|
|
24
25
|
Ledger,
|
|
25
26
|
LedgerEntry,
|
|
@@ -283,6 +284,9 @@ export function toEntry(row: LedgerRow): LedgerEntry {
|
|
|
283
284
|
// Likewise a row from before v19, or a turn with redaction off: absent,
|
|
284
285
|
// which is a different fact from 0 (the rules ran and matched nothing).
|
|
285
286
|
...(row.redactions === null || row.redactions === undefined ? {} : { redactions: row.redactions }),
|
|
287
|
+
// A row recorded before pricing, or one whose model could not be priced,
|
|
288
|
+
// stores NULL; absent is the front door's cue to fall back to the blend.
|
|
289
|
+
...(row.cost_breakdown === null || row.cost_breakdown === undefined ? {} : { costBreakdown: JSON.parse(row.cost_breakdown) as CostBreakdown }),
|
|
286
290
|
};
|
|
287
291
|
}
|
|
288
292
|
|
package/src/cost/types.ts
CHANGED
|
@@ -167,6 +167,13 @@ export interface LedgerEntry {
|
|
|
167
167
|
* provider (Ollama) exists only in memory, so the orchestrator hands it over.
|
|
168
168
|
*/
|
|
169
169
|
priceModel?: CatalogModel;
|
|
170
|
+
/**
|
|
171
|
+
* The cost component split the router computed when it recorded this row,
|
|
172
|
+
* from its own catalog prices. Absent when the row predates pricing, or
|
|
173
|
+
* when the model could not be priced (NULL in the ledger) — a front door
|
|
174
|
+
* shows input/output/cache costs from this rather than re-deriving them.
|
|
175
|
+
*/
|
|
176
|
+
costBreakdown?: CostBreakdown;
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
/**
|
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);
|
package/test/views.test.ts
CHANGED
|
@@ -215,4 +215,20 @@ describe("decision entries", () => {
|
|
|
215
215
|
expect(bob.find((e) => e.id === "l3")?.error).toBe("boom");
|
|
216
216
|
expect(bob.find((e) => e.id === "l3")?.feedback[0]?.note).toBe("looped");
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
test("the recorded cost split rides along; unpriced rows leave it absent", () => {
|
|
220
|
+
const { decisionEntries } = require("../src/cost/views.ts") as typeof import("../src/cost/views.ts");
|
|
221
|
+
// The fixture records before any catalog fetch, so every row stored NULL;
|
|
222
|
+
// one priced row stands in for a turn the router could price at record time.
|
|
223
|
+
db.run("UPDATE ledger SET cost_breakdown = ? WHERE id = 'l1'", [
|
|
224
|
+
JSON.stringify({ freshPrompt: 0.006, cacheRead: 0.004, cacheWrite: 0, completion: 0.002, reasoning: 0, images: 0, request: 0, total: 0.006, tierAtPromptTokens: 0 }),
|
|
225
|
+
]);
|
|
226
|
+
const entries = decisionEntries(db, { sinceMs: since, harness: null });
|
|
227
|
+
const priced = entries.find((e) => e.id === "l1");
|
|
228
|
+
expect(priced?.costBreakdown?.total).toBeCloseTo(0.006, 6);
|
|
229
|
+
expect(priced?.costBreakdown?.cacheRead).toBeCloseTo(0.004, 6);
|
|
230
|
+
// A row the ledger could not price (all of them here, NULL column) has no
|
|
231
|
+
// breakdown at all — the front door's cue to fall back to the blend.
|
|
232
|
+
expect(entries.find((e) => e.id === "l2")?.costBreakdown).toBeUndefined();
|
|
233
|
+
});
|
|
218
234
|
});
|