auto-model-router 0.2.25 → 0.2.26
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/omp-extension/router-embed.ts +84 -32
- package/package.json +1 -1
- package/test/embed-lifecycle.test.ts +119 -0
|
@@ -19,7 +19,7 @@
|
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
140
|
-
pi
|
|
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,7 +201,6 @@ 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
|
|
@@ -175,6 +227,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
175
227
|
const actualPort = started.server.port;
|
|
176
228
|
if (actualPort === undefined) return;
|
|
177
229
|
app = started;
|
|
230
|
+
boundPort = actualPort;
|
|
178
231
|
|
|
179
232
|
// Publish the port; subagents and the toast read it from here.
|
|
180
233
|
writeEmbedPort(portFile, actualPort);
|
|
@@ -192,24 +245,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
192
245
|
registerRouterProvider(pi, actualPort, cfg, sessionId);
|
|
193
246
|
pi.setLabel(`auto-model-router embed :${actualPort}${syncAction === null ? "" : ` (models.yml ${syncAction})`}`);
|
|
194
247
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
248
|
+
// NO `session_shutdown` teardown. That event is emitted from session
|
|
249
|
+
// DISPOSAL — including omp's provider-refresh / extension-reload path,
|
|
250
|
+
// which runs in a throwaway extension host while the real session keeps
|
|
251
|
+
// going. Because this module is cached per process, such a handler stops
|
|
252
|
+
// the LIVE router: every subsequent turn then fails with Bun's
|
|
253
|
+
// "Unable to connect", while utility calls that resolve after a later
|
|
254
|
+
// rebind still work — the exact asymmetry observed in the field. The
|
|
255
|
+
// router's lifetime is the PROCESS, and the OS reclaims the socket when
|
|
256
|
+
// the process exits.
|
|
257
|
+
trackProcessExit();
|
|
199
258
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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}`,
|
|
259
|
+
writeEmbedLog(
|
|
260
|
+
`embed ready pid=${process.pid} port=${actualPort}` +
|
|
261
|
+
` models.yml-advertised=${advertised ?? "none"}` +
|
|
262
|
+
` sync=${syncAction ?? "current"}` +
|
|
263
|
+
` self-probe=${(await probeEmbed(actualPort)) ? "ok" : "FAILED"}` +
|
|
264
|
+
` session=${sessionId}`,
|
|
212
265
|
);
|
|
213
|
-
if (!selfProbeOk) pi.setLabel(`auto-model-router: BOUND :${actualPort} BUT NOT ANSWERING`);
|
|
214
266
|
});
|
|
215
267
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } 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
|
+
* The embedded router's lifetime is the PROCESS, not a session.
|
|
10
|
+
*
|
|
11
|
+
* omp emits `session_shutdown` from session DISPOSAL, and disposal includes its
|
|
12
|
+
* provider-refresh / extension-reload path — which 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.
|
|
19
|
+
*
|
|
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
|
+
*/
|
|
23
|
+
|
|
24
|
+
const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => void | Promise<void>)[]>();
|
|
25
|
+
const registrations: { id: string; baseUrl: string }[] = [];
|
|
26
|
+
|
|
27
|
+
const pi: ExtensionAPI = {
|
|
28
|
+
setLabel: () => {},
|
|
29
|
+
on: (event, handler) => {
|
|
30
|
+
const list = handlers.get(event) ?? [];
|
|
31
|
+
list.push(handler);
|
|
32
|
+
handlers.set(event, list);
|
|
33
|
+
},
|
|
34
|
+
registerProvider: (id: string, registration: ProviderRegistration) => {
|
|
35
|
+
registrations.push({ id, baseUrl: registration.baseUrl });
|
|
36
|
+
},
|
|
37
|
+
unregisterProvider: () => {},
|
|
38
|
+
registerCommand: () => {},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function fire(event: string, sessionId: string): Promise<void> {
|
|
42
|
+
const ctx = { hasUI: true, sessionManager: { getSessionId: () => sessionId } } as ExtensionContext;
|
|
43
|
+
for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Health-probes the router; used to assert the socket's state, not to wait. */
|
|
47
|
+
async function alive(port: number): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) });
|
|
50
|
+
return res.ok;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let home = "";
|
|
57
|
+
let port = 0;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
home = mkdtempSync(join(tmpdir(), "embed-life-"));
|
|
61
|
+
process.env.AUTO_MODEL_ROUTER_HOME = home;
|
|
62
|
+
process.env.AUTO_MODEL_ROUTER_DB = join(home, "router.db");
|
|
63
|
+
const mod = (await import("../omp-extension/router-embed.ts")) as { default: (api: ExtensionAPI) => void };
|
|
64
|
+
mod.default(pi);
|
|
65
|
+
await fire("session_start", "session-one");
|
|
66
|
+
port = Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterAll(async () => {
|
|
70
|
+
// Release the socket the way the extension intends: on process signals.
|
|
71
|
+
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 */
|
|
76
|
+
}
|
|
77
|
+
// A still-open SQLite handle can hold the file on Windows; the temp dir is
|
|
78
|
+
// disposable either way.
|
|
79
|
+
try {
|
|
80
|
+
rmSync(home, { recursive: true, force: true });
|
|
81
|
+
} catch {
|
|
82
|
+
/* leave it to the OS temp reaper */
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
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");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the router answers on the port it registered", async () => {
|
|
93
|
+
expect(await alive(port)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
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.
|
|
99
|
+
expect(handlers.get("session_shutdown") ?? []).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a session_shutdown leaves the router running", async () => {
|
|
103
|
+
await fire("session_shutdown", "session-one");
|
|
104
|
+
expect(await alive(port)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
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.
|
|
110
|
+
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);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("each session still gets its own registration, so per-session tagging survives reuse", () => {
|
|
117
|
+
expect(registrations.length).toBeGreaterThanOrEqual(2);
|
|
118
|
+
});
|
|
119
|
+
});
|