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
package/test/embed-logic.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
64
|
-
check("router answers /health there (so a peer session reuses it)", await probeEmbed(
|
|
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 }) });
|