auto-model-router 0.2.24 → 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/CLAUDE.md +1 -1
- package/omp-extension/embed-logic.ts +8 -15
- package/omp-extension/pi-coding-agent.d.ts +76 -0
- package/omp-extension/router-embed.ts +110 -54
- package/package.json +3 -2
- package/src/config/defaults.ts +4 -0
- package/src/config/load.ts +15 -1
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +8 -0
- package/src/server/http.ts +7 -1
- package/test/config.test.ts +1 -1
- package/test/embed-lifecycle.test.ts +119 -0
- package/test/failover.test.ts +1 -1
- package/test/http-resilience.test.ts +1 -1
- package/test/turn.test.ts +1 -1
- package/tools/agentdox-e2e.ts +4 -0
- package/tools/mock-openrouter.ts +1 -1
- package/tools/replay.ts +6 -2
- package/tools/smoke.ts +2 -2
- package/tools/verify-plan-persist.ts +7 -4
- package/tsconfig.all.json +4 -0
package/CLAUDE.md
CHANGED
|
@@ -141,4 +141,4 @@ A URL + token is enough to enable it; `GET /health` on the router confirms.
|
|
|
141
141
|
guard, and `USER_VERSION` is bumped. `test/trust-attribution.test.ts` asserts the version.
|
|
142
142
|
- **bun:sqlite named params must be written `$name`** in the bind object. Bare keys bind
|
|
143
143
|
nothing and every column silently lands NULL.
|
|
144
|
-
- Verify with `
|
|
144
|
+
- Verify with `bun run typecheck` (covers src, test, omp-extension, tools — the extension and tools dirs were UNCHECKED until tsconfig.all.json, which is how two port bugs shipped) and `bun test` (483 tests) before declaring done.
|
|
@@ -80,22 +80,15 @@ export function deriveAgentdoxScope(cwd: string): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Resolves the
|
|
84
|
-
*
|
|
85
|
-
* 0 (let the OS pick a free one).
|
|
83
|
+
* Resolves the desired bind port: an explicit `AUTO_MODEL_ROUTER_PORT` when set
|
|
84
|
+
* and valid, else 0 so the OS assigns a free ephemeral port.
|
|
86
85
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* still work. A deterministic port makes the pre-bind block correct by
|
|
94
|
-
* construction. Sessions sharing that port share one router, which is already
|
|
95
|
-
* how subagents behave.
|
|
96
|
-
*
|
|
97
|
-
* `0` is still honoured when asked for explicitly, and remains the fallback
|
|
98
|
-
* when the desired port is occupied by something that is not our router.
|
|
86
|
+
* Ephemeral is the DEFAULT ON PURPOSE: each interactive omp session gets its
|
|
87
|
+
* OWN router process, so sessions cannot interfere with one another and no
|
|
88
|
+
* session depends on another staying alive. `configuredPort` is honoured only
|
|
89
|
+
* when a deployment asks for a fixed port explicitly (env var, or `server.port`
|
|
90
|
+
* passed in by a caller that wants it), which is the shared/always-on shape
|
|
91
|
+
* `serve` uses for other harnesses.
|
|
99
92
|
*/
|
|
100
93
|
export function resolveEmbedPort(envPort: string | undefined, configuredPort = 0): number {
|
|
101
94
|
if (envPort !== undefined && envPort !== "") {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOCAL ambient stub for omp's extension API — not the vendor's types.
|
|
3
|
+
*
|
|
4
|
+
* `@oh-my-pi/pi-coding-agent` is injected by the omp process at runtime and is
|
|
5
|
+
* not an installed dependency, so without this declaration every extension file
|
|
6
|
+
* fails to resolve it, TS gives up, and the whole `omp-extension/` directory
|
|
7
|
+
* goes unchecked. That gap was not theoretical: two user-visible port bugs
|
|
8
|
+
* shipped from these files while `tsconfig.json` only included `src` and `test`.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately minimal — it declares the surface these extensions actually use.
|
|
11
|
+
* It buys checking of OUR logic (control flow, ports, async, config shapes), not
|
|
12
|
+
* validation against omp's real signatures; treat a change here as a claim about
|
|
13
|
+
* omp's API that only a live session can confirm.
|
|
14
|
+
*/
|
|
15
|
+
declare module "@oh-my-pi/pi-coding-agent" {
|
|
16
|
+
export interface ProviderModelCost {
|
|
17
|
+
input: number;
|
|
18
|
+
output: number;
|
|
19
|
+
cacheRead?: number;
|
|
20
|
+
cacheWrite?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProviderModel {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
api: string;
|
|
27
|
+
reasoning?: boolean;
|
|
28
|
+
input?: string[];
|
|
29
|
+
contextWindow?: number;
|
|
30
|
+
maxTokens?: number;
|
|
31
|
+
cost?: ProviderModelCost;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProviderRegistration {
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
api: string;
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
models: ProviderModel[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SessionManager {
|
|
43
|
+
getSessionId(): string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Mirrors `ConfigUi` in configure-logic.ts, which is what /router drives. */
|
|
47
|
+
export interface ExtensionUI {
|
|
48
|
+
select(title: string, options: string[], selected?: number): Promise<string | undefined>;
|
|
49
|
+
input(title: string, placeholder?: string, initial?: string): Promise<string | undefined>;
|
|
50
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
51
|
+
notify(text: string, level?: "info" | "warn" | "error"): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ExtensionContext {
|
|
55
|
+
/** False for subagents and headless (`-p`) runs: the discriminator the embed extension keys on. */
|
|
56
|
+
hasUI: boolean;
|
|
57
|
+
sessionManager: SessionManager;
|
|
58
|
+
ui: ExtensionUI;
|
|
59
|
+
/** Interval whose errors omp isolates, and whose handle `clearTimer` cancels. */
|
|
60
|
+
setInterval(handler: () => void | Promise<void>, ms: number): unknown;
|
|
61
|
+
clearTimer(timer: unknown): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CommandDefinition {
|
|
65
|
+
description: string;
|
|
66
|
+
handler(args: string, ctx: ExtensionContext): void | Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ExtensionAPI {
|
|
70
|
+
setLabel(label: string): void;
|
|
71
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>): void;
|
|
72
|
+
registerProvider(id: string, registration: ProviderRegistration): void;
|
|
73
|
+
unregisterProvider(id: string): void;
|
|
74
|
+
registerCommand(name: string, command: CommandDefinition): void;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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,28 +127,54 @@ 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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1" } } });
|
|
110
|
-
const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT, cfg.server.port);
|
|
111
|
-
cfg.server.port = requestedPort;
|
|
112
|
-
|
|
113
|
-
let app: StartedServer | null = null;
|
|
152
|
+
const portFile = embedPortPath(routerHome());
|
|
153
|
+
// Each interactive session binds its OWN router on an ephemeral port, so
|
|
154
|
+
// sessions stay independent: no shared process to contend over, and no
|
|
155
|
+
// session left broken because another one exited. `server.port` from
|
|
156
|
+
// config.yml is deliberately NOT used here — that port belongs to the
|
|
157
|
+
// standalone `serve` daemon, which may legitimately be running alongside.
|
|
158
|
+
// Set AUTO_MODEL_ROUTER_PORT to pin a fixed port on purpose.
|
|
159
|
+
const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
|
|
160
|
+
const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
|
|
114
161
|
|
|
115
162
|
pi.on("session_start", async (_event, ctx) => {
|
|
116
163
|
// The omp UI session id tags every request so the toast can scope its
|
|
117
164
|
// notifications to that exact session (see router-toast.ts).
|
|
118
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
|
+
|
|
119
178
|
if (!ctx.hasUI) {
|
|
120
179
|
// Subagents and headless (-p) sessions prefer the main session's
|
|
121
180
|
// shared router: one process, one ledger, one place to inspect.
|
|
@@ -133,23 +192,19 @@ export default function (pi: ExtensionAPI): void {
|
|
|
133
192
|
const started = startServer(cfg);
|
|
134
193
|
if (started.server.port === undefined) return;
|
|
135
194
|
app = started;
|
|
136
|
-
|
|
137
|
-
pi
|
|
138
|
-
void app?.stop().catch(() => {});
|
|
139
|
-
app = null;
|
|
140
|
-
});
|
|
195
|
+
boundPort = started.server.port;
|
|
196
|
+
registerRouterProvider(pi, boundPort, cfg, sessionId);
|
|
141
197
|
return;
|
|
142
198
|
}
|
|
143
199
|
|
|
144
|
-
// Main interactive session
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
if (app) return;
|
|
200
|
+
// Main interactive session: bind this session's OWN router, then register
|
|
201
|
+
// the provider against the exact bound port. Registration happens only
|
|
202
|
+
// here — never at factory load, where a stale shared port would be
|
|
203
|
+
// captured into omp's model registry and defeat the correct bound URL.
|
|
149
204
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
205
|
+
// A fixed port was asked for (env var) and a live router already answers
|
|
206
|
+
// there: share it rather than failing to bind. Ephemeral ports — the
|
|
207
|
+
// default — never take this path, so sessions stay independent.
|
|
153
208
|
if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
|
|
154
209
|
writeEmbedPort(portFile, requestedPort);
|
|
155
210
|
syncModelsYml(cfg, requestedPort);
|
|
@@ -158,10 +213,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
158
213
|
return;
|
|
159
214
|
}
|
|
160
215
|
|
|
161
|
-
// Bind
|
|
162
|
-
//
|
|
163
|
-
// provider at all.
|
|
164
|
-
// and subagents still resolve.
|
|
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.
|
|
165
219
|
let started: StartedServer;
|
|
166
220
|
try {
|
|
167
221
|
started = startServer(cfg);
|
|
@@ -173,39 +227,41 @@ export default function (pi: ExtensionAPI): void {
|
|
|
173
227
|
const actualPort = started.server.port;
|
|
174
228
|
if (actualPort === undefined) return;
|
|
175
229
|
app = started;
|
|
230
|
+
boundPort = actualPort;
|
|
176
231
|
|
|
177
|
-
// Publish the
|
|
232
|
+
// Publish the port; subagents and the toast read it from here.
|
|
178
233
|
writeEmbedPort(portFile, actualPort);
|
|
179
234
|
|
|
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
|
-
|
|
197
235
|
// Keep models.yml pointing at this port. Headless runs (`-p`) and
|
|
198
236
|
// subagent processes resolve models from models.yml in a FRESH registry
|
|
199
237
|
// — extension registration does not reach them — so without this they
|
|
200
238
|
// fail with "Model not found" when no interactive session is live
|
|
201
239
|
// (the print-mode gap the external benchmark hit).
|
|
240
|
+
const advertised = modelsYmlPort(readModelsYml());
|
|
202
241
|
const syncAction = syncModelsYml(cfg, actualPort);
|
|
203
|
-
|
|
242
|
+
|
|
243
|
+
// Register BEFORE any await: everything omp resolves after this point
|
|
244
|
+
// picks up the live URL, so the registration must not sit behind I/O.
|
|
204
245
|
registerRouterProvider(pi, actualPort, cfg, sessionId);
|
|
246
|
+
pi.setLabel(`auto-model-router embed :${actualPort}${syncAction === null ? "" : ` (models.yml ${syncAction})`}`);
|
|
205
247
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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();
|
|
258
|
+
|
|
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}`,
|
|
265
|
+
);
|
|
210
266
|
});
|
|
211
267
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-model-router",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.26",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"auto-model-router": "./src/index.ts"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"typecheck": "tsc --noEmit",
|
|
11
|
+
"typecheck": "tsc --noEmit --project tsconfig.all.json",
|
|
12
|
+
"typecheck:src": "tsc --noEmit",
|
|
12
13
|
"test": "bun test",
|
|
13
14
|
"smoke": "bun run tools/smoke.ts",
|
|
14
15
|
"version": "bun run tools/sync-marketplace-version.ts",
|
package/src/config/defaults.ts
CHANGED
|
@@ -11,6 +11,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
11
11
|
server: {
|
|
12
12
|
host: "127.0.0.1",
|
|
13
13
|
port: 8788,
|
|
14
|
+
// One router process serves every omp session on this machine (the port
|
|
15
|
+
// is deterministic, so peers reuse it), so this covers N sessions plus
|
|
16
|
+
// their subagents. Was effectively 8 per session when each bound its own.
|
|
17
|
+
maxConcurrentTurns: 24,
|
|
14
18
|
},
|
|
15
19
|
openrouter: {
|
|
16
20
|
baseUrl: "https://openrouter.ai/api/v1",
|
package/src/config/load.ts
CHANGED
|
@@ -40,6 +40,20 @@ export function deepMerge(base: RouterConfig, override: unknown): RouterConfig {
|
|
|
40
40
|
return mergeValue(base, override) as RouterConfig;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Recursive partial: `loadConfig` merges overrides key by key, so a caller may
|
|
45
|
+
* legitimately supply just `{ server: { host } }`. Typing the parameter as a
|
|
46
|
+
* flat `Partial<RouterConfig>` demanded a COMPLETE `ServerConfig` for that,
|
|
47
|
+
* which made honest call sites (the omp extension, the smoke tools) type
|
|
48
|
+
* errors — invisible ones, since those directories were outside the
|
|
49
|
+
* typechecked project until `tsconfig.all.json`.
|
|
50
|
+
*/
|
|
51
|
+
export type DeepPartial<T> = T extends readonly unknown[] | Date | RegExp
|
|
52
|
+
? T
|
|
53
|
+
: T extends object
|
|
54
|
+
? { [K in keyof T]?: DeepPartial<T[K]> }
|
|
55
|
+
: T;
|
|
56
|
+
|
|
43
57
|
/**
|
|
44
58
|
* Resolves the effective configuration:
|
|
45
59
|
* DEFAULT_CONFIG
|
|
@@ -52,7 +66,7 @@ export function deepMerge(base: RouterConfig, override: unknown): RouterConfig {
|
|
|
52
66
|
* config work keyless; an embedded router warns at startup and completions
|
|
53
67
|
* fail at dispatch time.
|
|
54
68
|
*/
|
|
55
|
-
export function loadConfig(opts?: { path?: string; overrides?:
|
|
69
|
+
export function loadConfig(opts?: { path?: string; overrides?: DeepPartial<RouterConfig> }): RouterConfig {
|
|
56
70
|
const home = resolveTilde(process.env.AUTO_MODEL_ROUTER_HOME ?? "~/.auto-model-router");
|
|
57
71
|
|
|
58
72
|
// Config file, when present.
|
package/src/config/schema.ts
CHANGED
|
@@ -20,6 +20,7 @@ const server = z.strictObject({
|
|
|
20
20
|
port: z.number().int().min(0).max(65_535).optional(),
|
|
21
21
|
apiKey: z.string().optional(),
|
|
22
22
|
harnessId: z.string().optional(),
|
|
23
|
+
maxConcurrentTurns: z.number().int().positive().max(1_000).optional(),
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
const openrouter = z.strictObject({
|
package/src/config/types.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface ServerConfig {
|
|
|
38
38
|
* ⇒ no header (single-harness default).
|
|
39
39
|
*/
|
|
40
40
|
harnessId?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Concurrent in-flight turns this router process will accept; excess gets a
|
|
43
|
+
* 429 rather than being queued, so a local flood cannot pile up unbounded
|
|
44
|
+
* upstream spend. The budget is per PROCESS, and one process now serves
|
|
45
|
+
* every omp session on the machine, so it must cover all live sessions plus
|
|
46
|
+
* their subagents.
|
|
47
|
+
*/
|
|
48
|
+
maxConcurrentTurns: number;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export interface OpenRouterConfig {
|
package/src/server/http.ts
CHANGED
|
@@ -247,7 +247,13 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
247
247
|
// upstream streams at once (each can run up to idleTimeout). Excess requests
|
|
248
248
|
// are rejected with 429 rather than queued, so a local flood cannot pile up
|
|
249
249
|
// unbounded upstream spend or memory.
|
|
250
|
-
|
|
250
|
+
//
|
|
251
|
+
// This budget is per ROUTER PROCESS, and since v0.2.23 one process serves
|
|
252
|
+
// every omp session on the machine (the port is deterministic, so peers
|
|
253
|
+
// reuse it). It therefore has to cover N interactive sessions plus their
|
|
254
|
+
// subagents, not one session — hence configurable, and defaulted higher
|
|
255
|
+
// than the 8 that used to be private to a single session.
|
|
256
|
+
const MAX_CONCURRENT_TURNS = cfg.server.maxConcurrentTurns;
|
|
251
257
|
let inFlightTurns = 0;
|
|
252
258
|
const acquireTurn = (): boolean => {
|
|
253
259
|
if (inFlightTurns >= MAX_CONCURRENT_TURNS) return false;
|
package/test/config.test.ts
CHANGED
|
@@ -96,7 +96,7 @@ describe("loadConfig", () => {
|
|
|
96
96
|
|
|
97
97
|
test("explicit overrides beat the environment", () => {
|
|
98
98
|
setEnv("AUTO_MODEL_ROUTER_PORT", "9999");
|
|
99
|
-
const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: 7777 } } });
|
|
99
|
+
const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: 7777, maxConcurrentTurns: 24 } } });
|
|
100
100
|
expect(cfg.server.port).toBe(7777);
|
|
101
101
|
});
|
|
102
102
|
|
|
@@ -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
|
+
});
|
package/test/failover.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
|
|
29
29
|
function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
30
30
|
return {
|
|
31
|
-
server: { host: "127.0.0.1", port: 8787 },
|
|
31
|
+
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24 },
|
|
32
32
|
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
33
33
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
|
34
34
|
tiers: {
|
|
@@ -10,7 +10,7 @@ describe("HTTP server resilience against dead streams", () => {
|
|
|
10
10
|
beforeAll(() => {
|
|
11
11
|
const cfg: RouterConfig = {
|
|
12
12
|
...DEFAULT_CONFIG,
|
|
13
|
-
server: { host: "127.0.0.1", port: 0 },
|
|
13
|
+
server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24 },
|
|
14
14
|
ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
|
|
15
15
|
context: { ...DEFAULT_CONFIG.context, enabled: false },
|
|
16
16
|
logLevel: "silent",
|
package/test/turn.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type {
|
|
|
29
29
|
|
|
30
30
|
function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
31
31
|
return {
|
|
32
|
-
server: { host: "127.0.0.1", port: 8787 },
|
|
32
|
+
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24 },
|
|
33
33
|
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
34
34
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
|
35
35
|
tiers: {
|
package/tools/agentdox-e2e.ts
CHANGED
package/tools/mock-openrouter.ts
CHANGED
package/tools/replay.ts
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
import { Database } from "bun:sqlite";
|
|
55
55
|
|
|
56
56
|
import { createCatalog } from "../src/catalog/openrouter-catalog.ts";
|
|
57
|
-
import type { CatalogModel } from "../src/catalog/types.ts";
|
|
57
|
+
import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
|
|
58
58
|
import { loadConfig } from "../src/config/load.ts";
|
|
59
59
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
60
60
|
import { computeCost } from "../src/cost/forecast.ts";
|
|
@@ -207,6 +207,9 @@ function stateOf(row: Row, prior: PriorTurn | undefined): ConversationState {
|
|
|
207
207
|
cacheWarmAtMs: prior?.atMs ?? 0,
|
|
208
208
|
contextVersion: null,
|
|
209
209
|
contextFetchedAtMs: 0,
|
|
210
|
+
// Compaction cannot be replanned offline (messages are not recorded), so
|
|
211
|
+
// replay carries no plan: forced off in the config it replays under.
|
|
212
|
+
compactionPlan: null,
|
|
210
213
|
updatedAtMs: prior?.atMs ?? 0,
|
|
211
214
|
};
|
|
212
215
|
}
|
|
@@ -256,6 +259,7 @@ if (snapshot === null) {
|
|
|
256
259
|
console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
|
|
257
260
|
process.exit(2);
|
|
258
261
|
}
|
|
262
|
+
const catalogSnapshot: CatalogSnapshot = snapshot;
|
|
259
263
|
const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
|
|
260
264
|
const ledger = createLedger(db, cfgA);
|
|
261
265
|
|
|
@@ -302,7 +306,7 @@ function run(cfg: RouterConfig, row: Row, usage: UsageCounts, prior: PriorTurn |
|
|
|
302
306
|
classification: scoreHeuristic(f, cfg),
|
|
303
307
|
profile: profileOf(cfg, row.requested_model),
|
|
304
308
|
state: stateOf(row, prior),
|
|
305
|
-
snapshot,
|
|
309
|
+
snapshot: catalogSnapshot,
|
|
306
310
|
ledger,
|
|
307
311
|
cfg,
|
|
308
312
|
nowMs: Date.now(),
|
package/tools/smoke.ts
CHANGED
|
@@ -140,7 +140,7 @@ async function drain(res: Response) {
|
|
|
140
140
|
const frame = FrameSchema.safeParse(parsed);
|
|
141
141
|
if (!frame.success) continue;
|
|
142
142
|
if (frame.data.model !== undefined) seenModel = frame.data.model;
|
|
143
|
-
if (frame.data.x_auto_model_router !== undefined) meta = frame.data.x_auto_model_router;
|
|
143
|
+
if (frame.data.x_auto_model_router !== undefined) meta = { ...(frame.data.x_auto_model_router.model === undefined ? {} : { model: frame.data.x_auto_model_router.model }), ...(frame.data.x_auto_model_router.tier === undefined ? {} : { tier: frame.data.x_auto_model_router.tier }) };
|
|
144
144
|
const delta = frame.data.choices?.[0]?.delta;
|
|
145
145
|
if (typeof delta?.content === "string") content += delta.content;
|
|
146
146
|
for (const call of delta?.tool_calls ?? []) toolArgs += call.function?.arguments ?? "";
|
|
@@ -153,7 +153,7 @@ const mock = await startMockOpenRouter("test/fixtures/openrouter-models.json");
|
|
|
153
153
|
console.log(`mock openrouter: ${mock.url}`);
|
|
154
154
|
|
|
155
155
|
const cfg = loadConfig({});
|
|
156
|
-
cfg.server = { host: "127.0.0.1", port: 0 };
|
|
156
|
+
cfg.server = { ...cfg.server, host: "127.0.0.1", port: 0 };
|
|
157
157
|
cfg.openrouter.baseUrl = `${mock.url}/api/v1`;
|
|
158
158
|
cfg.openrouter.apiKey = "sk-mock";
|
|
159
159
|
cfg.ledger.path = join(home, "router.db");
|
|
@@ -28,7 +28,7 @@ const home = mkdtempSync(join(tmpdir(), "verify-plan-persist-"));
|
|
|
28
28
|
const mock = await startMockOpenRouter("test/fixtures/openrouter-models.json");
|
|
29
29
|
|
|
30
30
|
const cfg = loadConfig({});
|
|
31
|
-
cfg.server = { host: "127.0.0.1", port: 0 };
|
|
31
|
+
cfg.server = { ...cfg.server, host: "127.0.0.1", port: 0 };
|
|
32
32
|
cfg.openrouter.baseUrl = `${mock.url}/api/v1`;
|
|
33
33
|
cfg.openrouter.apiKey = "sk-mock";
|
|
34
34
|
cfg.ledger.path = join(home, "router.db");
|
|
@@ -100,8 +100,11 @@ for (let n = 1; n <= TURNS; n++) {
|
|
|
100
100
|
// same bytes. A dropped or altered edit rewrites the cached prefix.
|
|
101
101
|
for (const [i, content] of prev) {
|
|
102
102
|
const now = shrunk.get(i);
|
|
103
|
-
if (now === undefined)
|
|
104
|
-
|
|
103
|
+
if (now === undefined) {
|
|
104
|
+
fail(`turn ${n}: edit at message ${i} was DROPPED (bytes re-inflated mid-prefix)`, { turn: n, i });
|
|
105
|
+
} else if (now !== content) {
|
|
106
|
+
fail(`turn ${n}: edit at message ${i} changed bytes`, { was: content.slice(0, 90), now: now.slice(0, 90) });
|
|
107
|
+
}
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
const added = [...shrunk.keys()].filter((i) => !prev.has(i));
|
|
@@ -122,6 +125,6 @@ console.log(`\nfloorRatio ${floorRatio}`);
|
|
|
122
125
|
console.log(`PASS stability: no edit was ever dropped or rewritten across ${TURNS} turns`);
|
|
123
126
|
console.log(`plan changes: ${changes} over ${planningTurns} compacting turns (${((changes / planningTurns) * 100).toFixed(0)}% of turns invalidate cache)`);
|
|
124
127
|
|
|
125
|
-
app.stop(
|
|
128
|
+
await app.stop();
|
|
126
129
|
await mock.stop();
|
|
127
130
|
process.exit(0);
|