auto-model-router 0.6.1 → 0.6.2
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 +7 -0
- package/omp-extension/report-logic.ts +5 -1
- package/package.json +1 -1
- package/src/catalog/composite.ts +14 -2
- package/src/server/http.ts +5 -1
- package/src/server/providers.ts +1 -0
- package/test/ollama.test.ts +26 -0
- package/test/report-logic.test.ts +2 -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.6.
|
|
10
|
+
"version": "0.6.2",
|
|
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.6.
|
|
17
|
+
"version": "0.6.2",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1149,6 +1149,13 @@ What happens once it is on:
|
|
|
1149
1149
|
cooldown.
|
|
1150
1150
|
- **Ollama reports no cost per response**, so the ledger records the
|
|
1151
1151
|
predicted figure at list price for those rows.
|
|
1152
|
+
- **Ollama Cloud alone works.** With `ollama.enabled` and no OpenRouter key,
|
|
1153
|
+
the router routes over Ollama's models only. OpenRouter's catalog is public,
|
|
1154
|
+
so it is still read (the twins' benchmarks and capabilities come from it),
|
|
1155
|
+
but its models are never candidates; `/health` lists what can serve under
|
|
1156
|
+
`serving` (`["ollama"]`, `["openrouter","ollama"]`, …). Tier coverage is
|
|
1157
|
+
whatever Ollama's catalog spans; a tier with nothing in it relaxes to the
|
|
1158
|
+
best available band, as it does under any other narrowing.
|
|
1152
1159
|
|
|
1153
1160
|
Ollama's compatibility layer differs from OpenRouter's in a few ways the
|
|
1154
1161
|
router handles for you: no `models[]` fallback cascade, no `tool_choice`,
|
|
@@ -104,6 +104,8 @@ export function renderSoftFailureSpikes(spikes: readonly SoftFailureSpikeView[]
|
|
|
104
104
|
export interface HealthSnapshot {
|
|
105
105
|
status?: string;
|
|
106
106
|
apiKeyConfigured?: boolean;
|
|
107
|
+
/** Upstreams that can serve a turn right now (`openrouter` needs its key; `ollama` needs to be on and out of cooldown). */
|
|
108
|
+
serving?: string[];
|
|
107
109
|
apiKeySource?: string;
|
|
108
110
|
agentdox?: { url?: string; defaultScope?: string; recordTurns?: boolean } | null;
|
|
109
111
|
ollama?: {
|
|
@@ -133,7 +135,9 @@ const mins = (ms: number): string => (ms >= 3_600_000 ? `${(ms / 3_600_000).toFi
|
|
|
133
135
|
/** Renders `/health` as a few plain lines for the transcript. */
|
|
134
136
|
export function renderStatus(baseUrl: string, h: HealthSnapshot, nowMs = Date.now()): string {
|
|
135
137
|
const out: string[] = [`auto-model-router at ${baseUrl}: ${h.status ?? "unknown"}`];
|
|
136
|
-
|
|
138
|
+
const orKey = h.apiKeyConfigured === true ? `configured (${h.apiKeySource ?? "?"})` : h.serving?.includes("ollama") === true ? "missing · routing over ollama cloud only" : "MISSING";
|
|
139
|
+
out.push(`openrouter: key ${orKey}`);
|
|
140
|
+
if (h.serving !== undefined && h.serving.length === 0) out.push("serving: NOTHING (no OpenRouter key and Ollama off or cooling down)");
|
|
137
141
|
const c = h.catalog;
|
|
138
142
|
if (c !== undefined && c !== null) {
|
|
139
143
|
const shrink = c.shrink !== undefined && c.shrink !== null ? ` · SHRANK ${c.shrink.fromModels ?? "?"} -> ${c.shrink.toModels ?? "?"}` : "";
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* Ollama models are left out entirely: a candidate that will 402 or 429 is
|
|
11
11
|
* not a candidate, and hiding it here means the turn routes straight to an
|
|
12
12
|
* OpenRouter model instead of paying a doomed dispatch first.
|
|
13
|
+
*
|
|
14
|
+
* The same rule covers OpenRouter: without a key its models cannot be
|
|
15
|
+
* dispatched (its catalog is public, so they would still be listed), and
|
|
16
|
+
* `serveOpenRouter` leaves them out so an Ollama-only deployment routes over
|
|
17
|
+
* Ollama Cloud alone instead of picking models that 401 at dispatch time. The
|
|
18
|
+
* OpenRouter catalog is still fetched: Ollama's models borrow their twins'
|
|
19
|
+
* benchmarks and capabilities from it.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import type { OllamaAvailability } from "../upstream/ollama.ts";
|
|
@@ -25,6 +32,8 @@ export interface CompositeBias {
|
|
|
25
32
|
usage: OllamaUsageSource;
|
|
26
33
|
/** When given, read on every use instead of the static pair, so a config hot reload applies. */
|
|
27
34
|
live?: () => { costBias: number; biasUntilUsage: number };
|
|
35
|
+
/** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
|
|
36
|
+
serveOpenRouter?: () => boolean;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export function createCompositeCatalog(
|
|
@@ -36,6 +45,7 @@ export function createCompositeCatalog(
|
|
|
36
45
|
let lastBase: CatalogSnapshot | null = null;
|
|
37
46
|
let lastOllama: readonly CatalogModel[] = [];
|
|
38
47
|
let lastAvailable = true;
|
|
48
|
+
let lastServeBase = true;
|
|
39
49
|
let lastBias = 1;
|
|
40
50
|
let merged: CatalogSnapshot | null = null;
|
|
41
51
|
|
|
@@ -47,13 +57,15 @@ export function createCompositeCatalog(
|
|
|
47
57
|
|
|
48
58
|
function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
|
|
49
59
|
const available = availability.available();
|
|
60
|
+
const serveBase = bias.serveOpenRouter?.() ?? true;
|
|
50
61
|
const providerBias = currentBias();
|
|
51
|
-
if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && providerBias === lastBias) return merged;
|
|
62
|
+
if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && serveBase === lastServeBase && providerBias === lastBias) return merged;
|
|
52
63
|
lastBase = base;
|
|
53
64
|
lastOllama = models;
|
|
54
65
|
lastAvailable = available;
|
|
66
|
+
lastServeBase = serveBase;
|
|
55
67
|
lastBias = providerBias;
|
|
56
|
-
merged = mergeSnapshots(base, available ? models : []);
|
|
68
|
+
merged = serveBase ? mergeSnapshots(base, available ? models : []) : { ...base, models: available ? [...models] : [] };
|
|
57
69
|
// A fresh object either way once anything changed; stamp the live bias so
|
|
58
70
|
// candidate scoring reads it off the snapshot it is ranking.
|
|
59
71
|
merged = { ...merged, providerBias: { ollama: providerBias } };
|
package/src/server/http.ts
CHANGED
|
@@ -241,7 +241,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
if (cfg.openrouter.apiKey === "") {
|
|
244
|
-
log.warn("
|
|
244
|
+
if (ollama !== null) log.warn("no OpenRouter key: routing over Ollama Cloud models only (OpenRouter's catalog is read for metadata, never served)");
|
|
245
|
+
else log.warn("OPENROUTER_API_KEY is not set and Ollama is off; /v1/chat/completions will fail at dispatch time");
|
|
245
246
|
}
|
|
246
247
|
if (ollama !== null) {
|
|
247
248
|
log.info("ollama cloud upstream enabled", {
|
|
@@ -608,6 +609,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
608
609
|
return json({
|
|
609
610
|
status: "ok",
|
|
610
611
|
apiKeyConfigured: cfg.openrouter.apiKey !== "",
|
|
612
|
+
// Which upstreams turns can actually be served from: OpenRouter needs
|
|
613
|
+
// its key; Ollama needs to be on and out of cooldown.
|
|
614
|
+
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(ollama !== null && ollama.available() ? ["ollama"] : [])],
|
|
611
615
|
// Provenance only; never the key itself.
|
|
612
616
|
apiKeySource: apiKeySource(cfg).source,
|
|
613
617
|
// Provenance only; never the agentdox token itself.
|
package/src/server/providers.ts
CHANGED
|
@@ -55,6 +55,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
55
55
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
56
56
|
usage: ollamaUsage,
|
|
57
57
|
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
58
|
+
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
58
59
|
}),
|
|
59
60
|
ollama,
|
|
60
61
|
ollamaUsage,
|
package/test/ollama.test.ts
CHANGED
|
@@ -398,6 +398,32 @@ describe("multi upstream + composite catalog", () => {
|
|
|
398
398
|
expect(catalog.ollamaModels()).toHaveLength(3); // still known, just hidden
|
|
399
399
|
expect(mergeSnapshots(base, []).models).toBe(base.models);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
test("without an OpenRouter key only the Ollama models are served; the OpenRouter catalog still feeds metadata and lookups", async () => {
|
|
403
|
+
const base: CatalogSnapshot = { models: OR_MODELS, fetchedAtMs: 1, keyScoped: false };
|
|
404
|
+
const openrouter: CatalogSource = { get: async () => base, refresh: async () => base, peek: () => base, find: (s) => OR_MODELS.find((m) => m.slug === s) };
|
|
405
|
+
const ollamaModels = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg: OLLAMA, log });
|
|
406
|
+
let available = true;
|
|
407
|
+
let keyed = false;
|
|
408
|
+
const source = { get: async () => ollamaModels, peek: () => ollamaModels, invalidate: () => {} };
|
|
409
|
+
const breaker = { available: () => available, cooldownUntilMs: () => null, lastTrip: () => null };
|
|
410
|
+
const catalog = createCompositeCatalog(openrouter, source, breaker, { costBias: 1, biasUntilUsage: 1, usage: NO_USAGE, serveOpenRouter: () => keyed });
|
|
411
|
+
|
|
412
|
+
const a = await catalog.get();
|
|
413
|
+
expect(a.models.map((m) => m.provider)).toEqual(["ollama", "ollama", "ollama"]);
|
|
414
|
+
expect(a.fetchedAtMs).toBe(1);
|
|
415
|
+
expect(await catalog.get()).toBe(a); // memoised while nothing changes
|
|
416
|
+
expect(catalog.find("z-ai/glm-5.3-flash")?.provider).toBe("openrouter"); // lookups (served-model attribution) still resolve
|
|
417
|
+
expect(a.models[0]?.quality).toEqual(OR_MODELS.find((m) => m.slug === "z-ai/glm-5.3-flash")?.quality); // twin metadata borrowed
|
|
418
|
+
|
|
419
|
+
available = false;
|
|
420
|
+
expect((await catalog.get()).models).toEqual([]); // nothing can serve: breaker open, no key
|
|
421
|
+
available = true;
|
|
422
|
+
keyed = true; // a hot-reloaded key brings OpenRouter back without a restart
|
|
423
|
+
const c = await catalog.get();
|
|
424
|
+
expect(c.models.length).toBe(OR_MODELS.length + 3);
|
|
425
|
+
expect(c).not.toBe(a);
|
|
426
|
+
});
|
|
401
427
|
});
|
|
402
428
|
|
|
403
429
|
describe("selection over a mixed catalog", () => {
|
|
@@ -99,6 +99,8 @@ describe("renderStatus", () => {
|
|
|
99
99
|
test("degrades cleanly when sections are absent", () => {
|
|
100
100
|
const text = renderStatus("http://h", { status: "ok", apiKeyConfigured: false });
|
|
101
101
|
expect(text).toContain("key MISSING");
|
|
102
|
+
expect(renderStatus("http://h", { status: "ok", apiKeyConfigured: false, serving: ["ollama"] })).toContain("routing over ollama cloud only");
|
|
103
|
+
expect(renderStatus("http://h", { status: "ok", apiKeyConfigured: false, serving: [] })).toContain("serving: NOTHING");
|
|
102
104
|
expect(text).toContain("catalog: not fetched yet");
|
|
103
105
|
expect(text).toContain("ollama cloud: disabled");
|
|
104
106
|
expect(text).toContain("agentdox: off");
|