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.
- package/omp-extension/router-embed.ts +40 -16
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +0 -38
- package/test/embed-lifecycle.test.ts +89 -38
|
@@ -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
|
|
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
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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
|
|
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
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
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}
|
|
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
|
-
`
|
|
286
|
+
` models.yml-untouched` +
|
|
263
287
|
` self-probe=${(await probeEmbed(actualPort)) ? "ok" : "FAILED"}` +
|
|
264
288
|
` session=${sessionId}`,
|
|
265
289
|
);
|
package/package.json
CHANGED
package/src/cli/config-cmd.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
19
21
|
*
|
|
20
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
111
|
+
// Release the socket the way the extension intends: on a process signal.
|
|
71
112
|
process.emit("SIGTERM");
|
|
72
|
-
// Poll the real condition
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
121
|
+
/* SQLite may still hold the file on Windows; the temp dir is disposable */
|
|
83
122
|
}
|
|
84
123
|
});
|
|
85
124
|
|
|
86
|
-
describe("embedded router
|
|
87
|
-
test("
|
|
88
|
-
expect(
|
|
89
|
-
|
|
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("
|
|
93
|
-
|
|
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
|
|
98
|
-
// 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.
|
|
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(
|
|
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
|
|
109
|
-
//
|
|
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
|
-
|
|
112
|
-
expect(
|
|
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", () => {
|