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.
@@ -26,16 +26,21 @@ jobs:
26
26
  - name: Install dependencies
27
27
  run: npm install
28
28
 
29
- - name: Check if version already published
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
- if [ "$LOCAL" != "$PUBLISHED" ]; then
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.1",
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.1",
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
- out.push(`openrouter: key ${h.apiKeyConfigured === true ? `configured (${h.apiKeySource ?? "?"})` : "MISSING"}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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 } };
@@ -241,7 +241,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
241
241
  }
242
242
 
243
243
  if (cfg.openrouter.apiKey === "") {
244
- log.warn("OPENROUTER_API_KEY is not set; /v1/chat/completions will fail at dispatch time");
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.
@@ -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,
@@ -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", () => {
@@ -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");