auto-model-router 0.2.26 → 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.
@@ -23,7 +23,7 @@ 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";
@@ -207,20 +207,39 @@ export default function (pi: ExtensionAPI): void {
207
207
  // default — never take this path, so sessions stay independent.
208
208
  if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
209
209
  writeEmbedPort(portFile, requestedPort);
210
- syncModelsYml(cfg, requestedPort);
211
210
  registerRouterProvider(pi, requestedPort, cfg, sessionId);
212
211
  pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
213
212
  return;
214
213
  }
215
214
 
216
- // Bind. If a FIXED port was requested and something else holds it, fall
217
- // back to an ephemeral one rather than leaving this session with no
218
- // 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.
219
239
  let started: StartedServer;
220
240
  try {
221
241
  started = startServer(cfg);
222
- } catch (err) {
223
- if (requestedPort === 0) throw err;
242
+ } catch {
224
243
  cfg.server.port = 0;
225
244
  started = startServer(cfg);
226
245
  }
@@ -232,18 +251,23 @@ export default function (pi: ExtensionAPI): void {
232
251
  // Publish the port; subagents and the toast read it from here.
233
252
  writeEmbedPort(portFile, actualPort);
234
253
 
235
- // Keep models.yml pointing at this port. Headless runs (`-p`) and
236
- // subagent processes resolve models from models.yml in a FRESH registry
237
- // extension registration does not reach themso without this they
238
- // fail with "Model not found" when no interactive session is live
239
- // (the print-mode gap the external benchmark hit).
240
- const advertised = modelsYmlPort(readModelsYml());
241
- 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.
242
266
 
243
267
  // Register BEFORE any await: everything omp resolves after this point
244
268
  // picks up the live URL, so the registration must not sit behind I/O.
245
269
  registerRouterProvider(pi, actualPort, cfg, sessionId);
246
- pi.setLabel(`auto-model-router embed :${actualPort}${syncAction === null ? "" : ` (models.yml ${syncAction})`}`);
270
+ pi.setLabel(`auto-model-router embed :${actualPort}`);
247
271
 
248
272
  // NO `session_shutdown` teardown. That event is emitted from session
249
273
  // DISPOSAL — including omp's provider-refresh / extension-reload path,
@@ -259,7 +283,7 @@ export default function (pi: ExtensionAPI): void {
259
283
  writeEmbedLog(
260
284
  `embed ready pid=${process.pid} port=${actualPort}` +
261
285
  ` models.yml-advertised=${advertised ?? "none"}` +
262
- ` sync=${syncAction ?? "current"}` +
286
+ ` models.yml-untouched` +
263
287
  ` self-probe=${(await probeEmbed(actualPort)) ? "ok" : "FAILED"}` +
264
288
  ` session=${sessionId}`,
265
289
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.26",
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> {
@@ -1,28 +1,35 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  import type { ExtensionAPI, ExtensionContext, ProviderRegistration } from "@oh-my-pi/pi-coding-agent";
7
7
 
8
8
  /**
9
- * The embedded router's lifetime is the PROCESS, not a session.
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.
10
12
  *
11
- * omp emits `session_shutdown` from session DISPOSAL, and disposal includes its
12
- * provider-refresh / extension-reload pathwhich runs in a throwaway
13
- * extension host while the real session keeps going. This module is cached per
14
- * process, so tearing the router down in that handler stopped the LIVE router.
15
- * Every later turn then failed with Bun's "Unable to connect", while utility
16
- * calls resolved after a subsequent rebind still worked: exactly the asymmetry
17
- * seen in the field, where only `toolCount: 0` dispatches reached the ledger and
18
- * the port named in `embed.port` answered nothing.
13
+ * 1. THE PORT COMES FROM THIS PROCESS, NOT FROM A FILE. omp resolves
14
+ * `modelRoles.default` from models.yml during STARTUPbefore 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
19
21
  *
20
- * One boot shared by every test here, deliberately: the extension module is
21
- * cached per process in production too, so this is the real shape.
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.
22
29
  */
23
30
 
24
- const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => void | Promise<void>)[]>();
25
31
  const registrations: { id: string; baseUrl: string }[] = [];
32
+ const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => void | Promise<void>)[]>();
26
33
 
27
34
  const pi: ExtensionAPI = {
28
35
  setLabel: () => {},
@@ -43,7 +50,7 @@ async function fire(event: string, sessionId: string): Promise<void> {
43
50
  for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
44
51
  }
45
52
 
46
- /** Health-probes the router; used to assert the socket's state, not to wait. */
53
+ /** Health-probes the router: asserts the socket's state, never used as a wait. */
47
54
  async function alive(port: number): Promise<boolean> {
48
55
  try {
49
56
  const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) });
@@ -53,64 +60,108 @@ async function alive(port: number): Promise<boolean> {
53
60
  }
54
61
  }
55
62
 
63
+ const portOfLatestRegistration = (): number =>
64
+ Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
65
+
56
66
  let home = "";
57
- let port = 0;
67
+ let modelsYmlPath = "";
68
+ let advertised = 0;
69
+ let modelsYmlBefore = "";
58
70
 
59
71
  beforeAll(async () => {
60
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
+
61
100
  process.env.AUTO_MODEL_ROUTER_HOME = home;
62
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
+
63
105
  const mod = (await import("../omp-extension/router-embed.ts")) as { default: (api: ExtensionAPI) => void };
64
106
  mod.default(pi);
65
107
  await fire("session_start", "session-one");
66
- port = Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
67
108
  });
68
109
 
69
110
  afterAll(async () => {
70
- // Release the socket the way the extension intends: on process signals.
111
+ // Release the socket the way the extension intends: on a process signal.
71
112
  process.emit("SIGTERM");
72
- // Poll the real condition rather than guessing a duration; each fetch
73
- // attempt yields, so this settles as soon as the socket is actually closed.
74
- for (let i = 0; i < 50 && (await alive(port)); i++) {
75
- /* keep probing until the port stops answering */
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 */
76
116
  }
77
- // A still-open SQLite handle can hold the file on Windows; the temp dir is
78
- // disposable either way.
117
+ delete process.env.PI_CODING_AGENT_DIR;
79
118
  try {
80
119
  rmSync(home, { recursive: true, force: true });
81
120
  } catch {
82
- /* leave it to the OS temp reaper */
121
+ /* SQLite may still hold the file on Windows; the temp dir is disposable */
83
122
  }
84
123
  });
85
124
 
86
- describe("embedded router lifetime", () => {
87
- test("binds a router and registers it for the session", () => {
88
- expect(port).toBeGreaterThan(0);
89
- expect(registrations.at(-1)?.id).toBe("auto-model-router");
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);
90
132
  });
91
133
 
92
- test("the router answers on the port it registered", async () => {
93
- expect(await alive(port)).toBe(true);
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);
94
138
  });
95
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", () => {
96
148
  test("registers NO session_shutdown teardown", () => {
97
- // omp fires this from a throwaway host during provider refresh, so a
98
- // teardown here kills a router the live session is still using.
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.
99
151
  expect(handlers.get("session_shutdown") ?? []).toHaveLength(0);
100
152
  });
101
153
 
102
154
  test("a session_shutdown leaves the router running", async () => {
103
155
  await fire("session_shutdown", "session-one");
104
- expect(await alive(port)).toBe(true);
156
+ expect(await alive(advertised)).toBe(true);
105
157
  });
106
158
 
107
159
  test("a second session reuses the same port instead of rebinding", async () => {
108
- // Rebinding would take a different port and orphan every model handle omp
109
- // had already resolved against the first one.
160
+ // Rebinding would take a different port and orphan every handle omp had
161
+ // already resolved against the first one.
110
162
  await fire("session_start", "session-two");
111
- const latest = Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
112
- expect(latest).toBe(port);
113
- expect(await alive(port)).toBe(true);
163
+ expect(portOfLatestRegistration()).toBe(advertised);
164
+ expect(await alive(advertised)).toBe(true);
114
165
  });
115
166
 
116
167
  test("each session still gets its own registration, so per-session tagging survives reuse", () => {