auto-model-router 0.2.23 → 0.2.24

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.
@@ -106,6 +106,27 @@ export function resolveEmbedPort(envPort: string | undefined, configuredPort = 0
106
106
  return 0;
107
107
  }
108
108
 
109
+ /**
110
+ * The port omp's `models.yml` currently advertises for our provider, or null
111
+ * when the block is absent or unparseable.
112
+ *
113
+ * This is the port omp resolves `modelRoles.default` against DURING STARTUP,
114
+ * before this extension loads. If it disagrees with the port we end up serving,
115
+ * this session's main-model handle points somewhere we are not listening, and
116
+ * only a restart can rebuild it — there is no API to re-resolve an already
117
+ * built handle. Detecting the mismatch is what turns a baffling
118
+ * "Unable to connect" on every real turn into a message that names the cause.
119
+ */
120
+ export function modelsYmlPort(text: string): number | null {
121
+ const block = /^\s*auto-model-router:\s*$/m.exec(text);
122
+ if (block === null) return null;
123
+ const rest = text.slice(block.index);
124
+ const url = /baseUrl:\s*http:\/\/[^\s:]+:(\d+)/.exec(rest);
125
+ if (url === null) return null;
126
+ const port = Number.parseInt(url[1] ?? "", 10);
127
+ return Number.isInteger(port) ? port : null;
128
+ }
129
+
109
130
  /**
110
131
  * Absolute path of the shared embed port file under a router home directory.
111
132
  */
@@ -19,10 +19,11 @@
19
19
  * - /path/to/auto-model-router/omp-extension/router-toast.ts
20
20
  */
21
21
 
22
+ import { readFileSync } from "node:fs";
22
23
  import { homedir } from "node:os";
23
24
  import { join } from "node:path";
24
25
 
25
- import { syncModelsYml } from "../src/cli/config-cmd.ts";
26
+ import { ompModelsPath, syncModelsYml } from "../src/cli/config-cmd.ts";
26
27
  import { loadConfig } from "../src/config/load.ts";
27
28
  import { startServer } from "../src/server/http.ts";
28
29
  import type { StartedServer } from "../src/server/http.ts";
@@ -36,11 +37,21 @@ import {
36
37
  EMBED_PROVIDER_ID,
37
38
  embedPortPath,
38
39
  readEmbedPort,
40
+ modelsYmlPort,
39
41
  probeEmbed,
40
42
  resolveEmbedPort,
41
43
  writeEmbedPort,
42
44
  } from "./embed-logic.ts";
43
45
 
46
+ /** omp's models.yml as text, or "" when it does not exist / cannot be read. */
47
+ function readModelsYml(): string {
48
+ try {
49
+ return readFileSync(ompModelsPath(), "utf8");
50
+ } catch {
51
+ return "";
52
+ }
53
+ }
54
+
44
55
  /**
45
56
  * Registers the auto-model-router provider (and its virtual models) into omp's model
46
57
  * registry at a specific bound port.
@@ -166,13 +177,30 @@ export default function (pi: ExtensionAPI): void {
166
177
  // Publish the shared port; subagents and the toast read it from here.
167
178
  writeEmbedPort(portFile, actualPort);
168
179
 
180
+ // What omp resolved `modelRoles.default` against at STARTUP, before this
181
+ // extension loaded. If it names a different port than we serve, this
182
+ // session's main-model handle points at a socket we are not listening on
183
+ // and every real turn fails with "Unable to connect" while utility calls
184
+ // (resolved later, from the registration below) still work. Nothing here
185
+ // can rebuild that handle, so say so plainly instead of leaving the user
186
+ // to diagnose it.
187
+ const advertised = modelsYmlPort(readModelsYml());
188
+ if (advertised !== null && advertised !== actualPort) {
189
+ pi.setLabel(`auto-model-router: RESTART NEEDED — omp resolved :${advertised}, router serves :${actualPort}`);
190
+ console.warn(
191
+ `[auto-model-router] models.yml advertised port ${advertised} at startup but this router serves ${actualPort}. ` +
192
+ `omp resolves the default model before extensions load, so this session's main model still points at ${advertised}. ` +
193
+ `models.yml has been corrected — restart omp once and it will be right.`,
194
+ );
195
+ }
196
+
169
197
  // Keep models.yml pointing at this port. Headless runs (`-p`) and
170
198
  // subagent processes resolve models from models.yml in a FRESH registry
171
199
  // — extension registration does not reach them — so without this they
172
200
  // fail with "Model not found" when no interactive session is live
173
201
  // (the print-mode gap the external benchmark hit).
174
202
  const syncAction = syncModelsYml(cfg, actualPort);
175
- if (syncAction !== null) pi.setLabel(`auto-model-router embed (models.yml ${syncAction})`);
203
+ if (syncAction !== null && advertised === actualPort) pi.setLabel(`auto-model-router embed (models.yml ${syncAction})`);
176
204
  registerRouterProvider(pi, actualPort, cfg, sessionId);
177
205
 
178
206
  pi.on("session_shutdown", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.23",
3
+ "version": "0.2.24",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -9,6 +9,7 @@ import {
9
9
  EMBED_PORT_FILE,
10
10
  EMBED_PROVIDER_ID,
11
11
  embedPortPath,
12
+ modelsYmlPort,
12
13
  readEmbedPort,
13
14
  resolveEmbedPort,
14
15
  writeEmbedPort,
@@ -55,6 +56,46 @@ describe("resolveEmbedPort", () => {
55
56
  });
56
57
  });
57
58
 
59
+ describe("modelsYmlPort", () => {
60
+ // This is the port omp resolves modelRoles.default against at STARTUP,
61
+ // before the extension loads. A disagreement with the served port means
62
+ // every main-agent turn in that session fails with "Unable to connect"
63
+ // while utility calls still work, so the extension has to detect it.
64
+ const REAL = `providers:
65
+ # BEGIN auto-model-router
66
+ auto-model-router:
67
+ baseUrl: http://127.0.0.1:58724/v1
68
+ api: openai-completions
69
+ auth: none
70
+ models:
71
+ - id: auto
72
+ name: Auto (auto-model-router)
73
+ `;
74
+
75
+ test("reads the advertised port out of a real block", () => {
76
+ expect(modelsYmlPort(REAL)).toBe(58724);
77
+ });
78
+
79
+ test("returns null when our provider block is absent", () => {
80
+ expect(modelsYmlPort("providers:\n openrouter:\n baseUrl: https://openrouter.ai/api/v1\n")).toBeNull();
81
+ expect(modelsYmlPort("")).toBeNull();
82
+ });
83
+
84
+ test("is not fooled by another provider's baseUrl appearing first", () => {
85
+ const mixed = `providers:
86
+ llama.cpp:
87
+ baseUrl: http://127.0.0.1:8080/v1
88
+ auto-model-router:
89
+ baseUrl: http://127.0.0.1:8788/v1
90
+ `;
91
+ expect(modelsYmlPort(mixed)).toBe(8788);
92
+ });
93
+
94
+ test("returns null when the block carries no parseable url", () => {
95
+ expect(modelsYmlPort("providers:\n auto-model-router:\n api: openai-completions\n")).toBeNull();
96
+ });
97
+ });
98
+
58
99
  describe("embed port file", () => {
59
100
  let dir: string;
60
101
 
@@ -51,17 +51,23 @@ check("an explicit env port still wins", resolveEmbedPort("8812", configured) ==
51
51
  check("an explicit env 0 still requests an ephemeral port", resolveEmbedPort("0", configured) === 0);
52
52
 
53
53
  // 2. First session binds it; a second must see it healthy (=> reuse, no bind war).
54
- cfg1.server.port = portA;
54
+ // Uses a DISCOVERED free port, not the machine's configured one: a real
55
+ // router may already be serving 8788 here, and this check must be hermetic.
56
+ const scout = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("scout") });
57
+ const freePort = scout.port as number;
58
+ scout.stop(true);
59
+ cfg1.server.port = freePort;
60
+
55
61
  let first: StartedServer | null = null;
56
62
  try {
57
63
  first = startServer(cfg1);
58
64
  } catch (err) {
59
- check("first session can bind the deterministic port", false, String(err));
65
+ check("first session can bind its chosen port", false, String(err));
60
66
  }
61
67
 
62
68
  if (first !== null) {
63
- check("first session bound the deterministic port", first.server.port === portA, { bound: first.server.port, wanted: portA });
64
- check("router answers /health there (so a peer session reuses it)", await probeEmbed(portA), { port: portA });
69
+ check("first session bound the port it asked for", first.server.port === freePort, { bound: first.server.port, wanted: freePort });
70
+ check("router answers /health there (so a peer session reuses it)", await probeEmbed(freePort), { port: freePort });
65
71
 
66
72
  // 3. A non-router occupant must not be mistaken for our router.
67
73
  const squatter = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("not a router", { status: 404 }) });