auto-model-router 0.2.22 → 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.
@@ -43,7 +43,8 @@ Deterministic, so the same folder always resolves to the same slug:
43
43
  stop and ask for a distinguishing slug.
44
44
  - **Near match** (`my-app` vs `my-app-v2`) — a stop sign: ask, rather than fork a second
45
45
  namespace for one project.
46
- 4. `project_ensure {slug, name}` — `name` is the readable form of the folder.
46
+ 4. `project_ensure {slug}` — `name` is optional and only used if the project is new; it
47
+ defaults to the slug, so pass one only when the readable form differs.
47
48
  5. **Make `.env.agentdox` un-committable *before* writing it.** Run `git check-ignore -v
48
49
  .env.agentdox`. If it is not ignored, **add `.env.agentdox` to `.gitignore`** (create that
49
50
  file if there is none) — do not merely check and move on. Patterns like `.env` and
@@ -136,7 +137,7 @@ await fetch("http://localhost:3003/memory", { method: "POST", headers: H,
136
137
 
137
138
  | Step | MCP | REST |
138
139
  | --- | --- | --- |
139
- | Ensure the project | `project_ensure {slug, name}` | `POST /projects {slug,name}` (idempotent) |
140
+ | Ensure the project | `project_ensure {slug}` | `POST /projects {slug}` (idempotent) |
140
141
  | Read the brief | `context_brief {scope}` | `GET /context/brief?scope=<scope>` |
141
142
 
142
143
  The brief is the cumulative on-ramp: overview, repo layout, code style, build/test,
package/README.md CHANGED
@@ -32,6 +32,93 @@ This router exists for the things a prompt classifier structurally cannot do:
32
32
  | **Closed-loop trust** | Per-model escalation and error rates from *your* traffic demote cheap-but-flaky models automatically. |
33
33
  | **Explainability** | Every decision — candidates, rejections, forecasts, reasons — is persisted and replayable via `auto-model-router explain`. |
34
34
 
35
+ ## Measured against Claude Opus 5
36
+
37
+ Five benchmark runs, 88 graded task runs, 2026-08-29. Each task is a real omp
38
+ session working in a pristine git workspace from a written spec. Hidden tests are
39
+ copied in only *after* the agent exits, so they cannot be read or edited by it;
40
+ every task is verified to fail an untouched workspace and to pass a reference
41
+ solution. Both arms are metered from omp's own event stream, run under an
42
+ identical tool surface, and are checked per turn against their expected provider.
43
+ The router arm routes freely — nothing pinned. The baseline is `claude-opus-5`
44
+ on Anthropic first-party.
45
+
46
+ ### Core suite — 10 coding tasks × 3 trials
47
+
48
+ | | auto-model-router | Claude Opus 5 |
49
+ | --- | --- | --- |
50
+ | Tasks solved | **30 / 30** | 30 / 30 |
51
+ | Total cost | **$0.63** | $16.61 |
52
+ | Cost per solved task | **$0.0209** | $0.5538 |
53
+ | Turns to finish | **278** | 303 |
54
+ | Tool calls | **265** | 337 |
55
+ | Wall clock | **2 057 s** | 3 185 s |
56
+ | Median time to first token | 5 776 ms | **1 490 ms** |
57
+
58
+ **26.5× cheaper at identical correctness** — and in fewer turns, fewer tool
59
+ calls, and 19 minutes less wall clock. The saving is not bought by grinding out
60
+ extra turns. The one regression is time to first token: a routed turn pays for
61
+ classification and dispatch before anything streams back.
62
+
63
+ Per task the ratio ranges from 9× to 264×. The widest gaps are tasks where the
64
+ single-model baseline entered long tool loops — `semver` and `queue-order` cost
65
+ it $2.99 each across three trials against a $1.32 median, 36% of its entire bill.
66
+
67
+ ### Difficulty ladder — 7 rungs, run twice
68
+
69
+ A second suite of deliberately escalating difficulty, ending in npm semver range
70
+ semantics and a minimal diff with a specified tie-break.
71
+
72
+ | | auto-model-router | Claude Opus 5 |
73
+ | --- | --- | --- |
74
+ | Run 1 | 5 / 7 · $0.30 | 5 / 7 · $6.25 |
75
+ | Run 2 | 5 / 7 · $0.46 | **6 / 7** · $6.60 |
76
+
77
+ At the top of the ladder the engines separate: they fail different rungs, and on
78
+ the second run the single-model baseline finished one more. Both arms timed out
79
+ on the semver rung at the 10-minute cap.
80
+
81
+ ### What it routed to
82
+
83
+ Across 464 routed turns in all five runs:
84
+
85
+ | Model | Turns | Input price | Role |
86
+ | --- | --- | --- | --- |
87
+ | `z-ai/glm-5.3-flash` | 389 (84%) | $0.07 / MTok | default |
88
+ | `google/gemini-3.7-flash` | 56 (12%) | $0.75 / MTok | escalation target |
89
+ | `x-ai/grok-4.6` | 18 (4%) | $2.00 / MTok | escalation target |
90
+
91
+ **Tier escalation converts to a costlier model roughly one-for-one**: on the
92
+ ladder, the count of turns classified `hard` matched the count served by
93
+ something other than the default (6/6, 4/4, 3/3, 5/5, 7/7, 1/1 across rungs and
94
+ runs). The escalation *target* is chosen live from trust and latency history, so
95
+ it differs between runs on the same catalog — run 1 stepped up to
96
+ `gemini-3.7-flash`, run 2 to `grok-4.6`.
97
+
98
+ Escalation stays inside the cheaper half of the catalog. A model priced above a
99
+ tier's `maxInputPerMtok` is excluded before ranking, and at `hard` the
100
+ `(quality/100)^qualityExponent ÷ expected cost` score favours cheaper models that
101
+ score nearly as well. If your workload needs a frontier model on hard turns,
102
+ raise the tier price ceiling and `qualityExponent` — measured thresholds are in
103
+ [`docs/routing-benchmark-findings.md`](docs/routing-benchmark-findings.md).
104
+
105
+ ### Scope
106
+
107
+ These are small, self-contained tasks of one to three files, solved in under 25
108
+ turns. On the core suite both engines solved everything, so it measures cost at
109
+ equal correctness rather than capability; the ladder is where capability
110
+ separates. The cost multiple varied between 14× and 32× across runs depending on
111
+ which task the baseline stalled on — treat "well over an order of magnitude" as
112
+ the claim, not a specific figure.
113
+
114
+ For sustained work on a large codebase the economics differ: cost there is
115
+ dominated by the conversation being resent each turn rather than by per-token
116
+ price. Replaying a week of real omp traffic (6 918 billed turns, 410:1
117
+ input-to-output) against a single-model baseline gives **≈15×**.
118
+
119
+ Harness, tasks and raw per-turn data:
120
+ [`docs/routing-benchmark-findings.md`](docs/routing-benchmark-findings.md).
121
+
35
122
  ## Architecture
36
123
 
37
124
  ```mermaid
@@ -13,15 +13,18 @@ of my own earlier conclusions were wrong and are corrected in place.
13
13
 
14
14
  1. **The router is 20–32× cheaper at equal solve rate.** On 10 easy tasks both
15
15
  arms solved 20/20; on a 7-rung difficulty ladder both solved 5/7.
16
- 2. **It never escalates.** Across 86 routed turns on the ladder — including the
17
- rungs it *failed* there were zero escalation signals, zero retry
18
- dispatches, and only two models ever served: `glm-5.3-flash` and
19
- `gemini-3.7-flash`.
20
- 3. **Root cause is two independent gates, and both must move.** The price
21
- ceiling hard-excludes expensive models below `hard`; and the ranking
22
- arithmetic makes quality unable to outweigh price at any sane exponent.
23
- 4. **Escalation does work once both gates are opened** — verified end to end,
24
- with `claude-opus-5` serving 3 turns of a task the cheap-only config failed.
16
+ 2. **Tier escalation works.** A turn classified `hard` reliably selects a
17
+ costlier modelthe hard-turn count matches the non-flash turn count ~1:1
18
+ across two independent ladder runs (to `gemini-3.7-flash` in one, to
19
+ `grok-4.6` in the other). *An earlier version of this document claimed the
20
+ opposite; that was my error, corrected in §3.*
21
+ 3. **What never happens is reaching `claude-opus-5`.** Two gates: the price
22
+ ceiling excludes it below `hard`, and at `hard` it is outranked by cheaper
23
+ high-quality models. Whether that is a defect depends on whether
24
+ `grok-4.6`-class is "good enough" this benchmark cannot say.
25
+ 4. **Mid-stream escalation signals never fired** — `escalation_signal` was empty
26
+ on every one of ~190 routed ladder turns. That is a different mechanism from
27
+ tier selection and may simply mean nothing malformed occurred.
25
28
  5. **The standalone `serve` process died 4 times inside one normally-completing
26
29
  run.** The crash is real and mid-run; the *trigger* is unidentified — my first
27
30
  explanation (client disconnect mid-stream) does not reproduce. Details in §7.
@@ -33,6 +36,11 @@ of my own earlier conclusions were wrong and are corrected in place.
33
36
  - **Harness**: omp in `-p --mode=json` print mode. Every turn's tokens,
34
37
  duration, TTFT and tool calls are read off omp's own event stream, so both
35
38
  arms are measured by the same instrument.
39
+ - **Provider guard**: each arm declares its expected omp provider and every
40
+ completion turn is checked against it — `anthropic` + `claude-opus-5` for the
41
+ baseline (first-party, never OpenRouter), `benchrouter` for the router arm.
42
+ Zero violations across the re-runs. Added after an early pilot silently
43
+ resolved `--model auto` to OpenRouter's own `auto` meta-router.
36
44
  - **Router arm**: the standalone `auto-model-router serve` endpoint registered
37
45
  as a plain OpenAI-compatible provider — the documented non-omp path (README
38
46
  § "Standalone alternative"). Nothing pinned, nothing stubbed; the router
@@ -46,22 +54,29 @@ of my own earlier conclusions were wrong and are corrected in place.
46
54
  verified to fail an untouched workspace and to pass a reference solution
47
55
  before any run (`bench/validate-ladder.ts`).
48
56
 
49
- > **A note on print mode.** The embedded extension only registers the
50
- > `auto-model-router` provider inside an interactive session `ctx.hasUI` is
51
- > false in `-p`, so it takes the subagent path and looks for an existing
52
- > `embed.port`. In print mode with no interactive session running, the provider
53
- > is absent from the model registry entirely and `--model auto-model-router/auto`
54
- > fails with "Model not found". Worth documenting, or worth having the subagent
55
- > path fall back to binding its own server.
57
+ > **Print mode resolved.** During the benchmark the embedded extension only
58
+ > registered the provider inside a session with a UI, so `omp -p` could not see
59
+ > `auto-model-router/auto` at all; that is why the harness drives the standalone
60
+ > `serve` endpoint instead. Fixed upstream in **0.2.20** (`fix(embed): resolve the
61
+ > router in print mode and subagents`) verified: `omp -p --model
62
+ > auto-model-router/auto` now resolves natively with no standalone server. The
63
+ > harness keeps the `serve` path because it gives the benchmark its own isolated
64
+ > ledger, not because the embedded path is broken.
56
65
 
57
66
  ---
58
67
 
59
68
  ## 2. Cost result
60
69
 
61
- | Suite | Router | Opus 5 | Ratio |
62
- |---|---|---|---|
63
- | 10 easy tasks × 2 trials | 20/20 · $0.3577 | 20/20 · $11.4040 | 31.9× |
64
- | 7-rung ladder × 1 trial | 5/7 · $0.3026 | 5/7 · $6.2506 | 20.7× |
70
+ | Suite | Run | Router | Opus 5 | Ratio |
71
+ |---|---|---|---|---|
72
+ | 10 easy tasks | trials 1+2 | 20/20 · $0.3577 | 20/20 · $11.4040 | 31.9× |
73
+ | 10 easy tasks | re-run | 10/10 · $0.2695 | 10/10 · $5.2092 | 19.3× |
74
+ | 7-rung ladder | first | 5/7 · $0.3026 | 5/7 · $6.2506 | 20.7× |
75
+ | 7-rung ladder | re-run | 5/7 · $0.4619 | **6/7** · $6.5967 | 14.3× |
76
+
77
+ Across four independent runs the ratio lands between **14× and 32×**. On the
78
+ ladder re-run Opus 5 edged the router on correctness for the first time (6/7 vs
79
+ 5/7), which is the outcome the ladder was built to be able to detect.
65
80
 
66
81
  Turn counts are comparable (184 vs 217 on the easy suite; 86 vs 91 on the
67
82
  ladder), so the saving is not bought with extra turns. Median TTFT is the
@@ -74,30 +89,56 @@ magnitude" is defensible; a precise figure is not.
74
89
 
75
90
  ---
76
91
 
77
- ## 3. The router never escalates
78
-
79
- The ladder was built specifically to force an escalation decision: seven rungs
80
- ending in npm semver range semantics and a minimal-diff with a specified
81
- tie-break.
92
+ ## 3. Escalation corrected
82
93
 
83
- | Rung | Task | Router | Opus 5 | Escalated? |
84
- |---|---|---|---|---|
85
- | 1 | in-range | PASS | PASS | no |
86
- | 2 | round-half-even | PASS | PASS | no |
87
- | 3 | csv-document | PASS | PASS | no |
88
- | 4 | sliding-limiter | PASS | **FAIL** | no |
89
- | 5 | savepoints | **FAIL** | PASS | **no** |
90
- | 6 | semver-ranges | timeout | timeout | no |
91
- | 7 | minimal-diff | PASS | PASS | no |
94
+ **I got this wrong first time.** I reported that the router "never escalates",
95
+ having looked at the served-model list, seen only `glm-5.3-flash` and
96
+ `gemini-3.7-flash`, and mentally filed both as "flash models, therefore no
97
+ escalation". I never checked whether the counts lined up with the tier. They do.
92
98
 
93
- Rung 6 timed out on both arms at the 10-minute cap; it is not evidence about
94
- capability either way.
99
+ ### Tier escalation works, and is close to deterministic
95
100
 
96
- **The router assigned its `hard` tier on six of the seven rungs**, so the
97
- classifier is not blind — the tier simply never converts into a more capable
98
- model. On rung 5 it failed while continuing to dispatch to `glm-5.3-flash`.
101
+ Hard-tier turns versus turns served by something other than the cheap default:
99
102
 
100
- ---
103
+ | Rung | ladder-1 hard / non-flash | v2-ladder hard / non-flash |
104
+ |---|---|---|
105
+ | L1 in-range | 0 / 0 | 0 / 0 |
106
+ | L2 round-half-even | 6 / 6 | 0 / 0 |
107
+ | L3 csv-document | 4 / 4 | 0 / 0 |
108
+ | L4 sliding-limiter | 3 / 6 | 5 / 5 |
109
+ | L5 savepoints | 3 / 3 | 7 / 7 |
110
+ | L6 semver-ranges | 2 / 3 | 0 / 0 |
111
+ | L7 minimal-diff | 3 / 4 | 1 / 1 |
112
+
113
+ The escalation *target* differs by run, which is itself worth knowing:
114
+ `ladder-1` stepped up to `gemini-3.7-flash` ($0.75/MTok, ~10× the default);
115
+ `v2-ladder` stepped up to `grok-4.6` ($2.00/MTok, ~28×). Same config, same
116
+ catalog — the choice moves with trust and latency history, which are live inputs
117
+ to the ranking. Escalation behaviour is therefore **not reproducible run to run**,
118
+ even though it is reliable *within* a run.
119
+
120
+ ### What genuinely never happens
121
+
122
+ `anthropic/claude-opus-5` was never selected in any run. §4 explains why, and
123
+ that analysis stands: the price ceiling excludes it below `hard`, and at `hard`
124
+ it loses the quality-per-dollar ranking to cheaper models scoring nearly as well.
125
+
126
+ Whether that is a *problem* is not something this benchmark answers. If
127
+ `grok-4.6` is good enough for the hard turns, the router is behaving correctly
128
+ and cheaply. The one weak signal available: rung 5 failed in `ladder-1` when it
129
+ escalated only as far as `gemini-3.7-flash` (3 turns) and passed in `v2-ladder`
130
+ when it escalated to `grok-4.6` (7 turns). Rung 4 pushes the other way — it
131
+ passed in `ladder-1` without much escalation and failed in `v2-ladder` *with*
132
+ `grok-4.6` on 5 turns. With n=1 per cell these cancel out; nothing is
133
+ established.
134
+
135
+ ### Mid-stream escalation is a separate mechanism, and it never fired
136
+
137
+ `escalation_signal` — the abort-and-redispatch-upward path for malformed tool
138
+ calls, empty completions and repeated calls — was empty on every routed ladder
139
+ turn, as was `attempt > 0`. That is consistent with "no turn ever came back
140
+ malformed" rather than evidence of a defect; the ladder gave it nothing to react
141
+ to.
101
142
 
102
143
  ## 4. Root cause: two gates, both hard
103
144
 
@@ -80,20 +80,53 @@ export function deriveAgentdoxScope(cwd: string): string {
80
80
  }
81
81
 
82
82
  /**
83
- * Resolves the desired bind port: an explicit `AUTO_MODEL_ROUTER_PORT` when set and
84
- * valid, else `0` so Bun assigns a free ephemeral port (the "random port" that
85
- * lets multiple local omp sessions coexist without colliding). Returning 0
86
- * means "let the OS pick"; the caller must read the actual port back off the
87
- * started server.
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).
86
+ *
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.
88
99
  */
89
- export function resolveEmbedPort(envPort: string | undefined): number {
100
+ export function resolveEmbedPort(envPort: string | undefined, configuredPort = 0): number {
90
101
  if (envPort !== undefined && envPort !== "") {
91
102
  const port = Number.parseInt(envPort, 10);
92
103
  if (Number.isInteger(port) && port >= 0 && port <= 65_535) return port;
93
104
  }
105
+ if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65_535) return configuredPort;
94
106
  return 0;
95
107
  }
96
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
+
97
130
  /**
98
131
  * Absolute path of the shared embed port file under a router home directory.
99
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.
@@ -86,8 +97,6 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
86
97
  export default function (pi: ExtensionAPI): void {
87
98
  pi.setLabel("auto-model-router embed");
88
99
 
89
- const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
90
-
91
100
  // Shared port file, written only by the main session's router.
92
101
  const homeRaw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
93
102
  const home =
@@ -95,11 +104,15 @@ export default function (pi: ExtensionAPI): void {
95
104
  ? join(homedir(), homeRaw.slice(1))
96
105
  : homeRaw;
97
106
  const portFile = embedPortPath(home);
98
- const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
107
+ // Load first WITHOUT a port override so `server.port` from config.yml is
108
+ // visible, then let it (or the env var) decide the bind port.
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;
99
112
 
100
113
  let app: StartedServer | null = null;
101
114
 
102
- pi.on("session_start", (_event, ctx) => {
115
+ pi.on("session_start", async (_event, ctx) => {
103
116
  // The omp UI session id tags every request so the toast can scope its
104
117
  // notifications to that exact session (see router-toast.ts).
105
118
  const sessionId = ctx.sessionManager.getSessionId();
@@ -108,7 +121,7 @@ export default function (pi: ExtensionAPI): void {
108
121
  // shared router: one process, one ledger, one place to inspect.
109
122
  // The main writes the port file before spawning subagents.
110
123
  const shared = readEmbedPort(portFile);
111
- if (shared !== null && probeEmbed(shared)) {
124
+ if (shared !== null && (await probeEmbed(shared))) {
112
125
  registerRouterProvider(pi, shared, cfg, sessionId);
113
126
  return;
114
127
  }
@@ -128,12 +141,35 @@ export default function (pi: ExtensionAPI): void {
128
141
  return;
129
142
  }
130
143
 
131
- // Main interactive session: bind the router once, then register the
132
- // provider against the exact bound port. Registration happens only
133
- // here never at factory load, where a stale shared port would be
134
- // captured into omp's model registry and defeat the correct bound URL.
144
+ // Main interactive session. The port is deterministic (see
145
+ // resolveEmbedPort), which matters because omp resolves
146
+ // `modelRoles.default` from models.yml BEFORE this extension loads: the
147
+ // URL that block names must be one this session will actually serve.
135
148
  if (app) return;
136
- const started = startServer(cfg);
149
+
150
+ // Another live session already serving this port? Share it rather than
151
+ // fighting over the socket — subagents already share one router, and the
152
+ // ledger and DB are shared regardless.
153
+ if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
154
+ writeEmbedPort(portFile, requestedPort);
155
+ syncModelsYml(cfg, requestedPort);
156
+ registerRouterProvider(pi, requestedPort, cfg, sessionId);
157
+ pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
158
+ return;
159
+ }
160
+
161
+ // Bind the desired port; if something that is NOT our router holds it,
162
+ // fall back to an ephemeral port rather than leaving the session with no
163
+ // provider at all. models.yml is rewritten either way, so headless runs
164
+ // and subagents still resolve.
165
+ let started: StartedServer;
166
+ try {
167
+ started = startServer(cfg);
168
+ } catch (err) {
169
+ if (requestedPort === 0) throw err;
170
+ cfg.server.port = 0;
171
+ started = startServer(cfg);
172
+ }
137
173
  const actualPort = started.server.port;
138
174
  if (actualPort === undefined) return;
139
175
  app = started;
@@ -141,14 +177,30 @@ export default function (pi: ExtensionAPI): void {
141
177
  // Publish the shared port; subagents and the toast read it from here.
142
178
  writeEmbedPort(portFile, actualPort);
143
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
+
144
197
  // Keep models.yml pointing at this port. Headless runs (`-p`) and
145
198
  // subagent processes resolve models from models.yml in a FRESH registry
146
199
  // — extension registration does not reach them — so without this they
147
200
  // fail with "Model not found" when no interactive session is live
148
- // (the print-mode gap the external benchmark hit). Registration still
149
- // wins at runtime, so a live session always overrides a stale block.
201
+ // (the print-mode gap the external benchmark hit).
150
202
  const syncAction = syncModelsYml(cfg, actualPort);
151
- 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})`);
152
204
  registerRouterProvider(pi, actualPort, cfg, sessionId);
153
205
 
154
206
  pi.on("session_shutdown", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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,
@@ -16,7 +17,7 @@ import {
16
17
  } from "../omp-extension/embed-logic.ts";
17
18
 
18
19
  describe("resolveEmbedPort", () => {
19
- test("returns 0 (let the OS assign a free port) when AUTO_MODEL_ROUTER_PORT is absent", () => {
20
+ test("returns 0 (let the OS assign a free port) when nothing is configured", () => {
20
21
  expect(resolveEmbedPort(undefined)).toBe(0);
21
22
  expect(resolveEmbedPort("")).toBe(0);
22
23
  });
@@ -31,6 +32,68 @@ describe("resolveEmbedPort", () => {
31
32
  expect(resolveEmbedPort("-1")).toBe(0);
32
33
  expect(resolveEmbedPort("70000")).toBe(0);
33
34
  });
35
+
36
+ // A stable port is what keeps omp's PRE-extension model resolution correct:
37
+ // it reads models.yml before this extension can bind and rewrite it, so an
38
+ // ephemeral port leaves that block naming the previous session's dead port.
39
+ test("uses the configured server.port when no env override is set", () => {
40
+ expect(resolveEmbedPort(undefined, 8788)).toBe(8788);
41
+ expect(resolveEmbedPort("", 8788)).toBe(8788);
42
+ });
43
+
44
+ test("the env var wins over the configured port", () => {
45
+ expect(resolveEmbedPort("8812", 8788)).toBe(8812);
46
+ });
47
+
48
+ test("an explicit env 0 wins, so an ephemeral port stays requestable", () => {
49
+ expect(resolveEmbedPort("0", 8788)).toBe(0);
50
+ });
51
+
52
+ test("ignores a nonsense configured port rather than binding it", () => {
53
+ expect(resolveEmbedPort(undefined, 0)).toBe(0);
54
+ expect(resolveEmbedPort(undefined, -5)).toBe(0);
55
+ expect(resolveEmbedPort(undefined, 70_000)).toBe(0);
56
+ });
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
+ });
34
97
  });
35
98
 
36
99
  describe("embed port file", () => {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Verifies the embed port contract that omp's model resolution depends on.
3
+ *
4
+ * omp resolves `modelRoles.default` from `models.yml` during startup — BEFORE
5
+ * extensions load, so before the router can bind and rewrite that file. With an
6
+ * ephemeral port the block therefore names the PREVIOUS session's port, which
7
+ * is dead once that session exits: every main-agent turn fails with "Unable to
8
+ * connect" while utility calls (resolved later, from the live registration)
9
+ * still work. This asserts the properties that make that impossible:
10
+ *
11
+ * 1. The bind port is deterministic across restarts (configured port wins).
12
+ * 2. A second session finds the first one healthy, so it can REUSE it.
13
+ * 3. A port held by something that is not our router is not mistaken for one,
14
+ * and binding falls back instead of leaving the session with no provider.
15
+ *
16
+ * Run: bun tools/verify-embed-port.ts
17
+ */
18
+ import { mkdtempSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ import { probeEmbed, resolveEmbedPort } from "../omp-extension/embed-logic.ts";
23
+ import { loadConfig } from "../src/config/load.ts";
24
+ import { startServer, type StartedServer } from "../src/server/http.ts";
25
+ import type { RouterConfig } from "../src/config/types.ts";
26
+
27
+ let failures = 0;
28
+ const check = (label: string, ok: boolean, detail?: unknown): void => {
29
+ console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok || detail === undefined ? "" : `\n ${JSON.stringify(detail)}`}`);
30
+ if (!ok) failures++;
31
+ };
32
+
33
+ const home = mkdtempSync(join(tmpdir(), "verify-embed-port-"));
34
+ function baseCfg(): RouterConfig {
35
+ const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1" } } });
36
+ cfg.ledger.path = join(home, "router.db");
37
+ cfg.logLevel = "error";
38
+ cfg.benchmarks.enabled = false;
39
+ cfg.context.enabled = false;
40
+ return cfg;
41
+ }
42
+
43
+ // 1. Determinism: two independent startups must choose the same port.
44
+ const cfg1 = baseCfg();
45
+ const configured = cfg1.server.port;
46
+ const portA = resolveEmbedPort(undefined, configured);
47
+ const portB = resolveEmbedPort(undefined, baseCfg().server.port);
48
+ check("bind port is deterministic across restarts", portA === portB && portA !== 0, { portA, portB });
49
+ check("deterministic port comes from config.yml server.port", portA === configured, { portA, configured });
50
+ check("an explicit env port still wins", resolveEmbedPort("8812", configured) === 8812);
51
+ check("an explicit env 0 still requests an ephemeral port", resolveEmbedPort("0", configured) === 0);
52
+
53
+ // 2. First session binds it; a second must see it healthy (=> reuse, no bind war).
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
+
61
+ let first: StartedServer | null = null;
62
+ try {
63
+ first = startServer(cfg1);
64
+ } catch (err) {
65
+ check("first session can bind its chosen port", false, String(err));
66
+ }
67
+
68
+ if (first !== null) {
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 });
71
+
72
+ // 3. A non-router occupant must not be mistaken for our router.
73
+ const squatter = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("not a router", { status: 404 }) });
74
+ const squatted = squatter.port as number;
75
+ check("a non-router occupant fails the health probe", (await probeEmbed(squatted)) === false, { port: squatted });
76
+
77
+ // ...and binding over it must still leave the session with a usable router.
78
+ const cfgFallback = baseCfg();
79
+ cfgFallback.server.port = squatted;
80
+ let fell: StartedServer | null = null;
81
+ try {
82
+ fell = startServer(cfgFallback);
83
+ check("bind on an occupied port yields a different, usable port", fell.server.port !== squatted, { got: fell.server.port });
84
+ } catch {
85
+ cfgFallback.server.port = 0;
86
+ fell = startServer(cfgFallback);
87
+ check("falls back to an ephemeral port when the desired one is taken", fell.server.port !== undefined && fell.server.port !== squatted, {
88
+ got: fell.server.port,
89
+ });
90
+ }
91
+ if (fell !== null) await fell.stop();
92
+ squatter.stop(true);
93
+ await first.stop();
94
+ }
95
+
96
+ console.log(failures === 0 ? "\nAll checks passed." : `\n${failures} check(s) FAILED.`);
97
+ process.exit(failures === 0 ? 0 : 1);