auto-model-router 0.2.23 → 0.2.25

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 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 `bunx tsc --noEmit` and `bun test` (406+ tests) before declaring done.
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 port the embedded router should serve on, in precedence order:
84
- * an explicit `AUTO_MODEL_ROUTER_PORT`, else the configured `server.port`, else
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
- * A STABLE port is what keeps omp's model resolution honest. omp resolves
88
- * `modelRoles.default` from `models.yml` during startup BEFORE extensions
89
- * load, so before this session can bind and rewrite that file. With an
90
- * ephemeral port the block names the PREVIOUS session's port, which is dead
91
- * once that session exits, and every main-agent turn fails with "Unable to
92
- * connect" while utility calls (resolved later, from the live registration)
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 !== "") {
@@ -106,6 +99,27 @@ export function resolveEmbedPort(envPort: string | undefined, configuredPort = 0
106
99
  return 0;
107
100
  }
108
101
 
102
+ /**
103
+ * The port omp's `models.yml` currently advertises for our provider, or null
104
+ * when the block is absent or unparseable.
105
+ *
106
+ * This is the port omp resolves `modelRoles.default` against DURING STARTUP,
107
+ * before this extension loads. If it disagrees with the port we end up serving,
108
+ * this session's main-model handle points somewhere we are not listening, and
109
+ * only a restart can rebuild it — there is no API to re-resolve an already
110
+ * built handle. Detecting the mismatch is what turns a baffling
111
+ * "Unable to connect" on every real turn into a message that names the cause.
112
+ */
113
+ export function modelsYmlPort(text: string): number | null {
114
+ const block = /^\s*auto-model-router:\s*$/m.exec(text);
115
+ if (block === null) return null;
116
+ const rest = text.slice(block.index);
117
+ const url = /baseUrl:\s*http:\/\/[^\s:]+:(\d+)/.exec(rest);
118
+ if (url === null) return null;
119
+ const port = Number.parseInt(url[1] ?? "", 10);
120
+ return Number.isInteger(port) ? port : null;
121
+ }
122
+
109
123
  /**
110
124
  * Absolute path of the shared embed port file under a router home directory.
111
125
  */
@@ -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,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.
@@ -93,11 +104,14 @@ export default function (pi: ExtensionAPI): void {
93
104
  ? join(homedir(), homeRaw.slice(1))
94
105
  : homeRaw;
95
106
  const portFile = embedPortPath(home);
96
- // Load first WITHOUT a port override so `server.port` from config.yml is
97
- // visible, then let it (or the env var) decide the bind port.
98
- const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1" } } });
99
- const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT, cfg.server.port);
100
- cfg.server.port = requestedPort;
107
+ // Each interactive session binds its OWN router on an ephemeral port, so
108
+ // sessions stay independent: no shared process to contend over, and no
109
+ // session left broken because another one exited. `server.port` from
110
+ // config.yml is deliberately NOT used here — that port belongs to the
111
+ // standalone `serve` daemon, which may legitimately be running alongside.
112
+ // Set AUTO_MODEL_ROUTER_PORT to pin a fixed port on purpose.
113
+ const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
114
+ const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
101
115
 
102
116
  let app: StartedServer | null = null;
103
117
 
@@ -130,15 +144,15 @@ export default function (pi: ExtensionAPI): void {
130
144
  return;
131
145
  }
132
146
 
133
- // Main interactive session. The port is deterministic (see
134
- // resolveEmbedPort), which matters because omp resolves
135
- // `modelRoles.default` from models.yml BEFORE this extension loads: the
136
- // URL that block names must be one this session will actually serve.
147
+ // Main interactive session: bind this session's OWN router, then register
148
+ // the provider against the exact bound port. Registration happens only
149
+ // here never at factory load, where a stale shared port would be
150
+ // captured into omp's model registry and defeat the correct bound URL.
137
151
  if (app) return;
138
152
 
139
- // Another live session already serving this port? Share it rather than
140
- // fighting over the socket subagents already share one router, and the
141
- // ledger and DB are shared regardless.
153
+ // A fixed port was asked for (env var) and a live router already answers
154
+ // there: share it rather than failing to bind. Ephemeral ports the
155
+ // default never take this path, so sessions stay independent.
142
156
  if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
143
157
  writeEmbedPort(portFile, requestedPort);
144
158
  syncModelsYml(cfg, requestedPort);
@@ -147,10 +161,9 @@ export default function (pi: ExtensionAPI): void {
147
161
  return;
148
162
  }
149
163
 
150
- // Bind the desired port; if something that is NOT our router holds it,
151
- // fall back to an ephemeral port rather than leaving the session with no
152
- // provider at all. models.yml is rewritten either way, so headless runs
153
- // and subagents still resolve.
164
+ // Bind. If a FIXED port was requested and something else holds it, fall
165
+ // back to an ephemeral one rather than leaving this session with no
166
+ // provider at all. An ephemeral request that fails is a real error.
154
167
  let started: StartedServer;
155
168
  try {
156
169
  started = startServer(cfg);
@@ -163,7 +176,7 @@ export default function (pi: ExtensionAPI): void {
163
176
  if (actualPort === undefined) return;
164
177
  app = started;
165
178
 
166
- // Publish the shared port; subagents and the toast read it from here.
179
+ // Publish the port; subagents and the toast read it from here.
167
180
  writeEmbedPort(portFile, actualPort);
168
181
 
169
182
  // Keep models.yml pointing at this port. Headless runs (`-p`) and
@@ -171,13 +184,32 @@ export default function (pi: ExtensionAPI): void {
171
184
  // — extension registration does not reach them — so without this they
172
185
  // fail with "Model not found" when no interactive session is live
173
186
  // (the print-mode gap the external benchmark hit).
187
+ const advertised = modelsYmlPort(readModelsYml());
174
188
  const syncAction = syncModelsYml(cfg, actualPort);
175
- if (syncAction !== null) pi.setLabel(`auto-model-router embed (models.yml ${syncAction})`);
189
+
190
+ // Register BEFORE any await: everything omp resolves after this point
191
+ // picks up the live URL, so the registration must not sit behind I/O.
176
192
  registerRouterProvider(pi, actualPort, cfg, sessionId);
193
+ pi.setLabel(`auto-model-router embed :${actualPort}${syncAction === null ? "" : ` (models.yml ${syncAction})`}`);
177
194
 
178
195
  pi.on("session_shutdown", () => {
179
196
  void app?.stop().catch(() => {});
180
197
  app = null;
181
198
  });
199
+
200
+ // Diagnostic, not a guess. "provider error: Unable to connect" on real
201
+ // turns while utility calls keep working means omp dialled an address
202
+ // this router does not serve — and that text is Bun's fetch error, which
203
+ // names no URL. Log the facts that distinguish the cases: the port bound,
204
+ // the port models.yml advertised BEFORE the sync above, and whether our
205
+ // own socket answers.
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}`,
212
+ );
213
+ if (!selfProbeOk) pi.setLabel(`auto-model-router: BOUND :${actualPort} BUT NOT ANSWERING`);
182
214
  });
183
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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",
@@ -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",
@@ -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?: Partial<RouterConfig> }): RouterConfig {
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.
@@ -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({
@@ -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 {
@@ -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
- const MAX_CONCURRENT_TURNS = 8;
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;
@@ -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
 
@@ -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
 
@@ -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: {
@@ -40,6 +40,10 @@ const bridge = createContextBridge({
40
40
  log,
41
41
  maxStalenessMs: 900_000,
42
42
  maxBlockChars: 24_000,
43
+ memoryLimit: 4,
44
+ docsLimit: 0,
45
+ sessionLimit: 6,
46
+ briefChars: 12_000,
43
47
  recordTurns: true,
44
48
  maxQueue: 64,
45
49
  });
@@ -254,7 +254,7 @@ export async function startMockOpenRouter(fixturePath: string, port = 0): Promis
254
254
 
255
255
  return {
256
256
  url: `http://127.0.0.1:${server.port}`,
257
- port: server.port,
257
+ port: server.port ?? 0,
258
258
  control,
259
259
  requests,
260
260
  stop: async () => {
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");
@@ -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
- cfg1.server.port = portA;
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 the deterministic port", false, String(err));
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 deterministic port", first.server.port === portA, { bound: first.server.port, wanted: portA });
64
- check("router answers /health there (so a peer session reuses it)", await probeEmbed(portA), { port: portA });
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 }) });
@@ -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) fail(`turn ${n}: edit at message ${i} was DROPPED (bytes re-inflated)`, { turn: n, i });
104
- if (now !== content) fail(`turn ${n}: edit at message ${i} changed bytes`, { was: content.slice(0, 90), now: now.slice(0, 90) });
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(true);
128
+ await app.stop();
126
129
  await mock.stop();
127
130
  process.exit(0);
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["src", "test", "omp-extension", "tools"]
4
+ }