auto-model-router 0.2.25 → 0.2.27

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.
@@ -19,11 +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
+ import { appendFileSync, readFileSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
 
26
- import { ompModelsPath, syncModelsYml } from "../src/cli/config-cmd.ts";
26
+ import { ompModelsPath } from "../src/cli/config-cmd.ts";
27
27
  import { loadConfig } from "../src/config/load.ts";
28
28
  import { startServer } from "../src/server/http.ts";
29
29
  import type { StartedServer } from "../src/server/http.ts";
@@ -52,6 +52,39 @@ function readModelsYml(): string {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * Appends one line to `$AUTO_MODEL_ROUTER_HOME/embed.log`.
57
+ *
58
+ * A FILE, deliberately: `console.*` from an extension does not reach omp's
59
+ * session log, so the first attempt at this diagnostic left no trace anywhere
60
+ * and the port lifecycle had to be reconstructed from netstat and mtimes. Best
61
+ * effort — a logging failure must never break a session.
62
+ */
63
+ function writeEmbedLog(line: string): void {
64
+ try {
65
+ appendFileSync(join(routerHome(), "embed.log"), `${new Date().toISOString()} ${line}\n`, "utf8");
66
+ } catch {
67
+ // Unwritable home: the router still works, we just lose the breadcrumb.
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Stops the router when the PROCESS ends — never when a session does.
73
+ * Idempotent: registered once, however many sessions this process hosts.
74
+ */
75
+ let exitHooked = false;
76
+ function trackProcessExit(): void {
77
+ if (exitHooked) return;
78
+ exitHooked = true;
79
+ // `exit` cannot await, and does not need to: the OS reclaims the socket.
80
+ // The signal hooks exist so a Ctrl-C releases the port promptly.
81
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
82
+ process.once(signal, () => {
83
+ void app?.stop().catch(() => {});
84
+ });
85
+ }
86
+ }
87
+
55
88
  /**
56
89
  * Registers the auto-model-router provider (and its virtual models) into omp's model
57
90
  * registry at a specific bound port.
@@ -94,16 +127,29 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
94
127
  });
95
128
  }
96
129
 
130
+ /**
131
+ * The router bound by THIS PROCESS, and the port it serves.
132
+ *
133
+ * Module scope on purpose: Bun caches the module per process, so when omp loads
134
+ * the extension into a second host (its provider-refresh / reload path does
135
+ * exactly that) these stay visible. A second host then REUSES this router
136
+ * instead of binding another port and orphaning every model handle omp already
137
+ * resolved against the first one.
138
+ */
139
+ let app: StartedServer | null = null;
140
+ let boundPort: number | null = null;
141
+
142
+ /** `$AUTO_MODEL_ROUTER_HOME`, tilde-expanded, defaulting to ~/.auto-model-router. */
143
+ function routerHome(): string {
144
+ const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
145
+ return raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
146
+ }
147
+
97
148
  export default function (pi: ExtensionAPI): void {
98
149
  pi.setLabel("auto-model-router embed");
99
150
 
100
151
  // Shared port file, written only by the main session's router.
101
- const homeRaw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
102
- const home =
103
- homeRaw === "~" || homeRaw.startsWith("~/") || homeRaw.startsWith("~\\")
104
- ? join(homedir(), homeRaw.slice(1))
105
- : homeRaw;
106
- const portFile = embedPortPath(home);
152
+ const portFile = embedPortPath(routerHome());
107
153
  // Each interactive session binds its OWN router on an ephemeral port, so
108
154
  // sessions stay independent: no shared process to contend over, and no
109
155
  // session left broken because another one exited. `server.port` from
@@ -113,12 +159,22 @@ export default function (pi: ExtensionAPI): void {
113
159
  const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
114
160
  const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
115
161
 
116
- let app: StartedServer | null = null;
117
-
118
162
  pi.on("session_start", async (_event, ctx) => {
119
163
  // The omp UI session id tags every request so the toast can scope its
120
164
  // notifications to that exact session (see router-toast.ts).
121
165
  const sessionId = ctx.sessionManager.getSessionId();
166
+
167
+ // This module is cached per PROCESS, so `app` and `boundPort` are
168
+ // process-global even when omp loads the extension into more than one
169
+ // host. A router already bound in this process is therefore reusable:
170
+ // re-register it for the new session id and return. Never rebind — a
171
+ // second bind would take a different port and orphan every model handle
172
+ // omp already resolved against the first one.
173
+ if (app !== null && boundPort !== null) {
174
+ registerRouterProvider(pi, boundPort, cfg, sessionId);
175
+ return;
176
+ }
177
+
122
178
  if (!ctx.hasUI) {
123
179
  // Subagents and headless (-p) sessions prefer the main session's
124
180
  // shared router: one process, one ledger, one place to inspect.
@@ -136,11 +192,8 @@ export default function (pi: ExtensionAPI): void {
136
192
  const started = startServer(cfg);
137
193
  if (started.server.port === undefined) return;
138
194
  app = started;
139
- registerRouterProvider(pi, started.server.port, cfg, sessionId);
140
- pi.on("session_shutdown", () => {
141
- void app?.stop().catch(() => {});
142
- app = null;
143
- });
195
+ boundPort = started.server.port;
196
+ registerRouterProvider(pi, boundPort, cfg, sessionId);
144
197
  return;
145
198
  }
146
199
 
@@ -148,68 +201,91 @@ export default function (pi: ExtensionAPI): void {
148
201
  // the provider against the exact bound port. Registration happens only
149
202
  // here — never at factory load, where a stale shared port would be
150
203
  // captured into omp's model registry and defeat the correct bound URL.
151
- if (app) return;
152
204
 
153
205
  // A fixed port was asked for (env var) and a live router already answers
154
206
  // there: share it rather than failing to bind. Ephemeral ports — the
155
207
  // default — never take this path, so sessions stay independent.
156
208
  if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
157
209
  writeEmbedPort(portFile, requestedPort);
158
- syncModelsYml(cfg, requestedPort);
159
210
  registerRouterProvider(pi, requestedPort, cfg, sessionId);
160
211
  pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
161
212
  return;
162
213
  }
163
214
 
164
- // Bind. If a FIXED port was requested and something else holds it, fall
165
- // back to an ephemeral one rather than leaving this session with no
166
- // provider at all. An ephemeral request that fails is a real error.
215
+ // ADOPT THE ADVERTISED PORT. This is the fix for "provider error: Unable
216
+ // to connect" on every real turn while utility calls kept working.
217
+ //
218
+ // omp resolves `modelRoles.default` (auto-model-router/auto) from
219
+ // models.yml during STARTUP — before this extension loads, so before we
220
+ // can bind or register anything. That resolved handle is a SNAPSHOT: a
221
+ // later registerProvider replaces the registry entry but cannot rewrite
222
+ // a handle omp already built. models.yml names the port of the LAST
223
+ // session that wrote it, which after a normal restart is the session the
224
+ // user just closed — a dead socket. Utility calls (title generation,
225
+ // auto-thinking) resolve AFTER our registration and so hit the live port,
226
+ // which is exactly the asymmetry that made this look like a router fault.
227
+ // Measured in the field via embed.log:
228
+ // embed ready pid=61872 port=54985 models.yml-advertised=50596
229
+ //
230
+ // So bind the port omp already resolved against, whenever nothing holds
231
+ // it. Each session still runs its OWN router — this only chooses which
232
+ // port that router listens on. A live peer holding it means the handle
233
+ // works anyway (that peer serves it), and we fall back to ephemeral.
234
+ const advertised = modelsYmlPort(readModelsYml());
235
+ if (requestedPort === 0 && advertised !== null) cfg.server.port = advertised;
236
+
237
+ // Fall back to an ephemeral port when the preferred one is taken, rather
238
+ // than leaving this session with no provider at all.
167
239
  let started: StartedServer;
168
240
  try {
169
241
  started = startServer(cfg);
170
- } catch (err) {
171
- if (requestedPort === 0) throw err;
242
+ } catch {
172
243
  cfg.server.port = 0;
173
244
  started = startServer(cfg);
174
245
  }
175
246
  const actualPort = started.server.port;
176
247
  if (actualPort === undefined) return;
177
248
  app = started;
249
+ boundPort = actualPort;
178
250
 
179
251
  // Publish the port; subagents and the toast read it from here.
180
252
  writeEmbedPort(portFile, actualPort);
181
253
 
182
- // Keep models.yml pointing at this port. Headless runs (`-p`) and
183
- // subagent processes resolve models from models.yml in a FRESH registry
184
- // extension registration does not reach themso without this they
185
- // fail with "Model not found" when no interactive session is live
186
- // (the print-mode gap the external benchmark hit).
187
- const advertised = modelsYmlPort(readModelsYml());
188
- const syncAction = syncModelsYml(cfg, actualPort);
254
+ // models.yml is deliberately NOT written. The port belongs to THIS
255
+ // process and changes every launch, so persisting it into a file omp
256
+ // reads at STARTUP before this extension loads makes a dead port
257
+ // authoritative for the next session's `modelRoles.default`, and that
258
+ // handle is a snapshot no later registration can repair. That is the
259
+ // regression this whole class of failure came from. The provider is
260
+ // registered dynamically below instead, which is what worked before the
261
+ // file was ever written.
262
+ //
263
+ // `auto-model-router config --write` still exists for anyone who wants a
264
+ // static block on purpose; the adoption above keeps such a block
265
+ // harmless by binding whatever port it names when that port is free.
189
266
 
190
267
  // Register BEFORE any await: everything omp resolves after this point
191
268
  // picks up the live URL, so the registration must not sit behind I/O.
192
269
  registerRouterProvider(pi, actualPort, cfg, sessionId);
193
- pi.setLabel(`auto-model-router embed :${actualPort}${syncAction === null ? "" : ` (models.yml ${syncAction})`}`);
270
+ pi.setLabel(`auto-model-router embed :${actualPort}`);
194
271
 
195
- pi.on("session_shutdown", () => {
196
- void app?.stop().catch(() => {});
197
- app = null;
198
- });
272
+ // NO `session_shutdown` teardown. That event is emitted from session
273
+ // DISPOSAL including omp's provider-refresh / extension-reload path,
274
+ // which runs in a throwaway extension host while the real session keeps
275
+ // going. Because this module is cached per process, such a handler stops
276
+ // the LIVE router: every subsequent turn then fails with Bun's
277
+ // "Unable to connect", while utility calls that resolve after a later
278
+ // rebind still work — the exact asymmetry observed in the field. The
279
+ // router's lifetime is the PROCESS, and the OS reclaims the socket when
280
+ // the process exits.
281
+ trackProcessExit();
199
282
 
200
- // Diagnostic, not a guess. "provider error: Unable to connect" on real
201
- // turns while utility calls keep working means omp dialled an address
202
- // this router does not serve — and that text is Bun's fetch error, which
203
- // names no URL. Log the facts that distinguish the cases: the port bound,
204
- // the port models.yml advertised BEFORE the sync above, and whether our
205
- // own socket answers.
206
- const selfProbeOk = await probeEmbed(actualPort);
207
- console.info(
208
- `[auto-model-router] embed ready: serving :${actualPort}` +
209
- ` | models.yml advertised :${advertised ?? "none"} at startup` +
210
- ` | self-probe ${selfProbeOk ? "ok" : "FAILED"}` +
211
- ` | session ${sessionId}`,
283
+ writeEmbedLog(
284
+ `embed ready pid=${process.pid} port=${actualPort}` +
285
+ ` models.yml-advertised=${advertised ?? "none"}` +
286
+ ` models.yml-untouched` +
287
+ ` self-probe=${(await probeEmbed(actualPort)) ? "ok" : "FAILED"}` +
288
+ ` session=${sessionId}`,
212
289
  );
213
- if (!selfProbeOk) pi.setLabel(`auto-model-router: BOUND :${actualPort} BUT NOT ANSWERING`);
214
290
  });
215
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -317,44 +317,6 @@ export function ompModelsPath(): string {
317
317
  if (agentDir !== undefined && agentDir !== "") return join(agentDir, "models.yml");
318
318
  return join(homedir(), ".omp", "agent", "models.yml");
319
319
  }
320
- /**
321
- * Keeps omp's models.yml carrying an up-to-date `auto-model-router` provider
322
- * block pointing at `port`, silently. The embedded extension calls this every
323
- * time the main session binds its router, so headless (`-p`) runs, subagent
324
- * processes, and any other consumer that builds a FRESH model registry (which
325
- * extension registration does NOT reach) still resolve
326
- * `auto-model-router/auto` — they read models.yml, not the live registry.
327
- *
328
- * Same splice + validation as `config --write`, minus the console output and
329
- * the backup: this runs on every session start, and a `.bak` per launch would
330
- * churn the directory. Failure to write is logged by the caller, never thrown —
331
- * a read-only models.yml must degrade to "registration only", which is the
332
- * pre-existing behavior.
333
- *
334
- * Returns the splice action, or null when nothing was written (already
335
- * current, or the write failed).
336
- */
337
- export function syncModelsYml(cfg: RouterConfig, port: number, target = ompModelsPath()): SpliceResult["action"] | null {
338
- try {
339
- const pointed: RouterConfig = { ...cfg, server: { ...cfg.server, port } };
340
- const block = renderProviderBlock(pointed, null);
341
- const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
342
- const result = spliceProviderBlock(existing, block);
343
- if (result.action === "replaced") {
344
- // The guards make "replaced" cheap to detect but the text may still be
345
- // byte-identical (same port, same costs): skip the write so the file
346
- // mtime stays stable for tools watching it.
347
- if (result.text === existing) return null;
348
- }
349
- assertUsableModelsYaml(result.text);
350
- mkdirSync(dirname(target), { recursive: true });
351
- writeFileSync(target, result.text, "utf8");
352
- return result.action;
353
- } catch {
354
- return null;
355
- }
356
- }
357
-
358
320
  /** omp's models.yml location: `$PI_CODING_AGENT_DIR` relocates the whole agent dir. */
359
321
 
360
322
  export async function configCommand(args: CliArgs): Promise<void> {
@@ -0,0 +1,170 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import type { ExtensionAPI, ExtensionContext, ProviderRegistration } from "@oh-my-pi/pi-coding-agent";
7
+
8
+ /**
9
+ * How the embedded router must behave, pinned against the two failures that
10
+ * produced "provider error: Unable to connect" on every real turn while utility
11
+ * calls kept working.
12
+ *
13
+ * 1. THE PORT COMES FROM THIS PROCESS, NOT FROM A FILE. omp resolves
14
+ * `modelRoles.default` from models.yml during STARTUP — before this extension
15
+ * loads — and that handle is a snapshot no later registerProvider can
16
+ * rewrite. So the extension must not persist its ephemeral port into
17
+ * models.yml (a dead port then becomes authoritative for the NEXT session),
18
+ * and when a block already exists it adopts the port that block names so the
19
+ * handle omp built is valid. Measured in the field:
20
+ * embed ready pid=61872 port=54985 models.yml-advertised=50596
21
+ *
22
+ * 2. THE ROUTER'S LIFETIME IS THE PROCESS. omp emits `session_shutdown` from
23
+ * session disposal, which includes its provider-refresh / extension-reload
24
+ * path running in a throwaway host while the real session continues. This
25
+ * module is cached per process, so a teardown there stopped the LIVE router.
26
+ *
27
+ * One boot shared by every test, deliberately: the module is cached per process
28
+ * in production too, so this is the real shape.
29
+ */
30
+
31
+ const registrations: { id: string; baseUrl: string }[] = [];
32
+ const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => void | Promise<void>)[]>();
33
+
34
+ const pi: ExtensionAPI = {
35
+ setLabel: () => {},
36
+ on: (event, handler) => {
37
+ const list = handlers.get(event) ?? [];
38
+ list.push(handler);
39
+ handlers.set(event, list);
40
+ },
41
+ registerProvider: (id: string, registration: ProviderRegistration) => {
42
+ registrations.push({ id, baseUrl: registration.baseUrl });
43
+ },
44
+ unregisterProvider: () => {},
45
+ registerCommand: () => {},
46
+ };
47
+
48
+ async function fire(event: string, sessionId: string): Promise<void> {
49
+ const ctx = { hasUI: true, sessionManager: { getSessionId: () => sessionId } } as ExtensionContext;
50
+ for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
51
+ }
52
+
53
+ /** Health-probes the router: asserts the socket's state, never used as a wait. */
54
+ async function alive(port: number): Promise<boolean> {
55
+ try {
56
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) });
57
+ return res.ok;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ const portOfLatestRegistration = (): number =>
64
+ Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
65
+
66
+ let home = "";
67
+ let modelsYmlPath = "";
68
+ let advertised = 0;
69
+ let modelsYmlBefore = "";
70
+
71
+ beforeAll(async () => {
72
+ home = mkdtempSync(join(tmpdir(), "embed-life-"));
73
+ const agentDir = join(home, "agent");
74
+ mkdirSync(agentDir, { recursive: true });
75
+ modelsYmlPath = join(agentDir, "models.yml");
76
+
77
+ // A port nothing listens on — the shape a restart leaves behind, since
78
+ // models.yml names the session the user just closed.
79
+ const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
80
+ advertised = probe.port as number;
81
+ probe.stop(true);
82
+
83
+ writeFileSync(
84
+ modelsYmlPath,
85
+ `providers:
86
+ # BEGIN auto-model-router
87
+ auto-model-router:
88
+ baseUrl: http://127.0.0.1:${advertised}/v1
89
+ api: openai-completions
90
+ auth: none
91
+ models:
92
+ - id: auto
93
+ name: Auto (auto-model-router)
94
+ # END auto-model-router
95
+ `,
96
+ "utf8",
97
+ );
98
+ modelsYmlBefore = readFileSync(modelsYmlPath, "utf8");
99
+
100
+ process.env.AUTO_MODEL_ROUTER_HOME = home;
101
+ process.env.AUTO_MODEL_ROUTER_DB = join(home, "router.db");
102
+ process.env.PI_CODING_AGENT_DIR = agentDir;
103
+ delete process.env.AUTO_MODEL_ROUTER_PORT;
104
+
105
+ const mod = (await import("../omp-extension/router-embed.ts")) as { default: (api: ExtensionAPI) => void };
106
+ mod.default(pi);
107
+ await fire("session_start", "session-one");
108
+ });
109
+
110
+ afterAll(async () => {
111
+ // Release the socket the way the extension intends: on a process signal.
112
+ process.emit("SIGTERM");
113
+ // Poll the real condition instead of guessing a duration; each probe yields.
114
+ for (let i = 0; i < 50 && (await alive(portOfLatestRegistration())); i++) {
115
+ /* keep probing until the socket stops answering */
116
+ }
117
+ delete process.env.PI_CODING_AGENT_DIR;
118
+ try {
119
+ rmSync(home, { recursive: true, force: true });
120
+ } catch {
121
+ /* SQLite may still hold the file on Windows; the temp dir is disposable */
122
+ }
123
+ });
124
+
125
+ describe("embedded router: port selection", () => {
126
+ test("adopts the port models.yml advertises, so omp's pre-resolved handle is valid", () => {
127
+ expect(portOfLatestRegistration()).toBe(advertised);
128
+ });
129
+
130
+ test("the adopted port actually serves", async () => {
131
+ expect(await alive(advertised)).toBe(true);
132
+ });
133
+
134
+ test("does NOT write its port into models.yml", () => {
135
+ // Persisting an ephemeral port makes it authoritative for the NEXT
136
+ // session's startup resolution, which is where the dead handle came from.
137
+ expect(readFileSync(modelsYmlPath, "utf8")).toBe(modelsYmlBefore);
138
+ });
139
+
140
+ test("publishes the port for subagents and the toast", () => {
141
+ const portFile = join(home, "embed.port");
142
+ expect(existsSync(portFile)).toBe(true);
143
+ expect(readFileSync(portFile, "utf8").trim()).toBe(String(advertised));
144
+ });
145
+ });
146
+
147
+ describe("embedded router: lifetime", () => {
148
+ test("registers NO session_shutdown teardown", () => {
149
+ // omp fires that from a throwaway host during provider refresh, so a
150
+ // teardown there kills a router the live session is still using.
151
+ expect(handlers.get("session_shutdown") ?? []).toHaveLength(0);
152
+ });
153
+
154
+ test("a session_shutdown leaves the router running", async () => {
155
+ await fire("session_shutdown", "session-one");
156
+ expect(await alive(advertised)).toBe(true);
157
+ });
158
+
159
+ test("a second session reuses the same port instead of rebinding", async () => {
160
+ // Rebinding would take a different port and orphan every handle omp had
161
+ // already resolved against the first one.
162
+ await fire("session_start", "session-two");
163
+ expect(portOfLatestRegistration()).toBe(advertised);
164
+ expect(await alive(advertised)).toBe(true);
165
+ });
166
+
167
+ test("each session still gets its own registration, so per-session tagging survives reuse", () => {
168
+ expect(registrations.length).toBeGreaterThanOrEqual(2);
169
+ });
170
+ });