auto-model-router 0.6.1 → 0.6.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.
- package/.github/workflows/release.yml +8 -2
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +12 -0
- package/omp-extension/remote-logic.ts +5 -1
- package/omp-extension/report-logic.ts +5 -1
- package/omp-extension/router-embed.ts +2 -12
- 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/remote.test.ts +4 -0
- package/test/report-logic.test.ts +2 -0
|
@@ -26,16 +26,21 @@ jobs:
|
|
|
26
26
|
- name: Install dependencies
|
|
27
27
|
run: npm install
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# Publish only when the tagged version is NEWER than npm's latest. A tag that
|
|
30
|
+
# reaches GitHub late (git push --tags after the fact) must never publish an
|
|
31
|
+
# old version as a fresh release.
|
|
32
|
+
- name: Check if version is newer than the published one
|
|
30
33
|
id: version
|
|
31
34
|
run: |
|
|
32
35
|
LOCAL=$(node -p "require('./package.json').version")
|
|
33
36
|
PUBLISHED=$(npm view auto-model-router version 2>/dev/null || echo "0.0.0")
|
|
34
37
|
echo "local=$LOCAL published=$PUBLISHED"
|
|
35
|
-
|
|
38
|
+
NEWEST=$(printf '%s\n' "$PUBLISHED" "$LOCAL" | sort -V | tail -1)
|
|
39
|
+
if [ "$LOCAL" != "$PUBLISHED" ] && [ "$NEWEST" = "$LOCAL" ]; then
|
|
36
40
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
37
41
|
else
|
|
38
42
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
43
|
+
echo "::warning::$LOCAL is not newer than the published $PUBLISHED; nothing published"
|
|
39
44
|
fi
|
|
40
45
|
|
|
41
46
|
- name: Publish to npm
|
|
@@ -45,6 +50,7 @@ jobs:
|
|
|
45
50
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
46
51
|
|
|
47
52
|
- name: Create GitHub Release
|
|
53
|
+
if: steps.version.outputs.changed == 'true'
|
|
48
54
|
uses: softprops/action-gh-release@v2
|
|
49
55
|
with:
|
|
50
56
|
generate_release_notes: true
|
|
@@ -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.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.6.
|
|
17
|
+
"version": "0.6.3",
|
|
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`,
|
|
@@ -1260,6 +1267,11 @@ adds the Codex provider and the Aider settings, and prints (or with `--profile`
|
|
|
1260
1267
|
the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
|
|
1261
1268
|
shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
|
|
1262
1269
|
|
|
1270
|
+
In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
|
|
1271
|
+
remote router serves every repo on the machine with that repo's shared context. The remote
|
|
1272
|
+
decides what to do with it: a team edition that pins a scope on the member's group
|
|
1273
|
+
overrides it, and one that pins none follows the workspace.
|
|
1274
|
+
|
|
1263
1275
|
## Multiple coding harnesses, one router
|
|
1264
1276
|
|
|
1265
1277
|
**One router process for everything.** omp's embed extension binds a private
|
|
@@ -63,7 +63,7 @@ export const REMOTE_MODELS: readonly { id: string; name: string }[] = [
|
|
|
63
63
|
* the session and subagent tags, and the virtual models. Costs are USD per
|
|
64
64
|
* million tokens, like the embedded config.
|
|
65
65
|
*/
|
|
66
|
-
export function remoteProviderRegistration(remote: RemoteRouter, sessionId: string, subagent: boolean, blend: { inputPerMtok: number; outputPerMtok: number }): {
|
|
66
|
+
export function remoteProviderRegistration(remote: RemoteRouter, sessionId: string, subagent: boolean, blend: { inputPerMtok: number; outputPerMtok: number }, agentdoxScope = ""): {
|
|
67
67
|
baseUrl: string;
|
|
68
68
|
api: string;
|
|
69
69
|
apiKey: string;
|
|
@@ -73,6 +73,10 @@ export function remoteProviderRegistration(remote: RemoteRouter, sessionId: stri
|
|
|
73
73
|
const headers: Record<string, string> = {};
|
|
74
74
|
if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
|
|
75
75
|
if (subagent) headers["X-Omp-Subagent"] = "1";
|
|
76
|
+
// Which project's shared context this workspace draws on. The bridge lives on the remote
|
|
77
|
+
// router, so this is sent whatever the local config says; the remote decides what to do with
|
|
78
|
+
// it (a team that pins a scope for the group overrides it, and one that pins none follows it).
|
|
79
|
+
if (agentdoxScope !== "") headers["X-Agentdox-Scope"] = agentdoxScope;
|
|
76
80
|
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
77
81
|
return {
|
|
78
82
|
baseUrl: `${remote.url}/v1`,
|
|
@@ -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 ?? "?"}` : "";
|
|
@@ -32,17 +32,7 @@ import type { RouterConfig } from "../src/config/types.ts";
|
|
|
32
32
|
|
|
33
33
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
34
34
|
|
|
35
|
-
import {
|
|
36
|
-
buildProviderConfig,
|
|
37
|
-
EMBED_DUMMY_API_KEY,
|
|
38
|
-
EMBED_PROVIDER_ID,
|
|
39
|
-
embedPortPath,
|
|
40
|
-
readEmbedPort,
|
|
41
|
-
modelsYmlPort,
|
|
42
|
-
probeEmbed,
|
|
43
|
-
resolveEmbedPort,
|
|
44
|
-
writeEmbedPort,
|
|
45
|
-
} from "./embed-logic.ts";
|
|
35
|
+
import { EMBED_DUMMY_API_KEY, EMBED_PROVIDER_ID, buildProviderConfig, deriveAgentdoxScope, embedPortPath, modelsYmlPort, probeEmbed, readEmbedPort, resolveEmbedPort, writeEmbedPort } from "./embed-logic.ts";
|
|
46
36
|
|
|
47
37
|
/** omp's models.yml as text, or "" when it does not exist / cannot be read. */
|
|
48
38
|
function readModelsYml(): string {
|
|
@@ -173,7 +163,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
173
163
|
// locally; the other extensions find it through remote.json.
|
|
174
164
|
const remote = readRemoteRouter(routerHome());
|
|
175
165
|
if (remote !== null) {
|
|
176
|
-
pi.registerProvider(EMBED_PROVIDER_ID, remoteProviderRegistration(remote, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend));
|
|
166
|
+
pi.registerProvider(EMBED_PROVIDER_ID, remoteProviderRegistration(remote, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend, deriveAgentdoxScope(process.cwd())));
|
|
177
167
|
pi.setLabel(`auto-model-router remote (${remote.url.replace(/^https?:\/\//, "")})`);
|
|
178
168
|
writeEmbedLog(`remote mode url=${remote.url} user=${remote.userId} session=${sessionId}`);
|
|
179
169
|
return;
|
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", () => {
|
package/test/remote.test.ts
CHANGED
|
@@ -22,6 +22,10 @@ describe("remote-logic", () => {
|
|
|
22
22
|
expect(reg).toMatchObject({ baseUrl: "https://team.example/v1", api: "openai-completions", apiKey: "amrt_k", headers: { "X-Omp-Session": "sess-1", "X-Omp-Subagent": "1" } });
|
|
23
23
|
expect(reg.models.map((m) => m.id)).toEqual(["auto", "auto-cheap", "auto-max"]);
|
|
24
24
|
expect(reg.models[0]!.cost).toEqual({ input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1.25 });
|
|
25
|
+
// The workspace's project travels with the turn, so one remote router serves every repo on
|
|
26
|
+
// the machine with the right context; the remote may still override it.
|
|
27
|
+
expect(reg.headers["X-Agentdox-Scope"]).toBeUndefined();
|
|
28
|
+
expect(remoteProviderRegistration(t, "", false, { inputPerMtok: 1, outputPerMtok: 4 }, "omp-router").headers).toEqual({ "X-Agentdox-Scope": "omp-router" });
|
|
25
29
|
const dir = mkdtempSync(join(tmpdir(), "amr-remote-"));
|
|
26
30
|
expect(readRemoteRouter(dir)).toBeNull();
|
|
27
31
|
writeFileSync(join(dir, "remote.json"), JSON.stringify({ url: "https://t", key: "k" }));
|
|
@@ -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");
|