auto-model-router 0.2.22 → 0.2.23
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/skills/agentdox/SKILL.md +3 -2
- package/README.md +87 -0
- package/docs/routing-benchmark-findings.md +81 -40
- package/omp-extension/embed-logic.ts +18 -6
- package/omp-extension/router-embed.ts +36 -12
- package/package.json +1 -1
- package/test/embed-logic.test.ts +23 -1
- package/tools/verify-embed-port.ts +91 -0
|
@@ -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
|
|
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
|
|
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. **
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
2. **Tier escalation works.** A turn classified `hard` reliably selects a
|
|
17
|
+
costlier model — the 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
|
-
> **
|
|
50
|
-
>
|
|
51
|
-
>
|
|
52
|
-
> `
|
|
53
|
-
>
|
|
54
|
-
>
|
|
55
|
-
> path
|
|
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
|
|
64
|
-
|
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
94
|
-
capability either way.
|
|
99
|
+
### Tier escalation works, and is close to deterministic
|
|
95
100
|
|
|
96
|
-
|
|
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,17 +80,29 @@ export function deriveAgentdoxScope(cwd: string): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Resolves the
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
|
|
@@ -86,8 +86,6 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
|
|
|
86
86
|
export default function (pi: ExtensionAPI): void {
|
|
87
87
|
pi.setLabel("auto-model-router embed");
|
|
88
88
|
|
|
89
|
-
const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
|
|
90
|
-
|
|
91
89
|
// Shared port file, written only by the main session's router.
|
|
92
90
|
const homeRaw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
93
91
|
const home =
|
|
@@ -95,11 +93,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
95
93
|
? join(homedir(), homeRaw.slice(1))
|
|
96
94
|
: homeRaw;
|
|
97
95
|
const portFile = embedPortPath(home);
|
|
98
|
-
|
|
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;
|
|
99
101
|
|
|
100
102
|
let app: StartedServer | null = null;
|
|
101
103
|
|
|
102
|
-
pi.on("session_start", (_event, ctx) => {
|
|
104
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
103
105
|
// The omp UI session id tags every request so the toast can scope its
|
|
104
106
|
// notifications to that exact session (see router-toast.ts).
|
|
105
107
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
@@ -108,7 +110,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
108
110
|
// shared router: one process, one ledger, one place to inspect.
|
|
109
111
|
// The main writes the port file before spawning subagents.
|
|
110
112
|
const shared = readEmbedPort(portFile);
|
|
111
|
-
if (shared !== null && probeEmbed(shared)) {
|
|
113
|
+
if (shared !== null && (await probeEmbed(shared))) {
|
|
112
114
|
registerRouterProvider(pi, shared, cfg, sessionId);
|
|
113
115
|
return;
|
|
114
116
|
}
|
|
@@ -128,12 +130,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
128
130
|
return;
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
// Main interactive session
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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.
|
|
135
137
|
if (app) return;
|
|
136
|
-
|
|
138
|
+
|
|
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.
|
|
142
|
+
if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
|
|
143
|
+
writeEmbedPort(portFile, requestedPort);
|
|
144
|
+
syncModelsYml(cfg, requestedPort);
|
|
145
|
+
registerRouterProvider(pi, requestedPort, cfg, sessionId);
|
|
146
|
+
pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
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.
|
|
154
|
+
let started: StartedServer;
|
|
155
|
+
try {
|
|
156
|
+
started = startServer(cfg);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (requestedPort === 0) throw err;
|
|
159
|
+
cfg.server.port = 0;
|
|
160
|
+
started = startServer(cfg);
|
|
161
|
+
}
|
|
137
162
|
const actualPort = started.server.port;
|
|
138
163
|
if (actualPort === undefined) return;
|
|
139
164
|
app = started;
|
|
@@ -145,8 +170,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
145
170
|
// subagent processes resolve models from models.yml in a FRESH registry
|
|
146
171
|
// — extension registration does not reach them — so without this they
|
|
147
172
|
// fail with "Model not found" when no interactive session is live
|
|
148
|
-
// (the print-mode gap the external benchmark hit).
|
|
149
|
-
// wins at runtime, so a live session always overrides a stale block.
|
|
173
|
+
// (the print-mode gap the external benchmark hit).
|
|
150
174
|
const syncAction = syncModelsYml(cfg, actualPort);
|
|
151
175
|
if (syncAction !== null) pi.setLabel(`auto-model-router embed (models.yml ${syncAction})`);
|
|
152
176
|
registerRouterProvider(pi, actualPort, cfg, sessionId);
|
package/package.json
CHANGED
package/test/embed-logic.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "../omp-extension/embed-logic.ts";
|
|
17
17
|
|
|
18
18
|
describe("resolveEmbedPort", () => {
|
|
19
|
-
test("returns 0 (let the OS assign a free port) when
|
|
19
|
+
test("returns 0 (let the OS assign a free port) when nothing is configured", () => {
|
|
20
20
|
expect(resolveEmbedPort(undefined)).toBe(0);
|
|
21
21
|
expect(resolveEmbedPort("")).toBe(0);
|
|
22
22
|
});
|
|
@@ -31,6 +31,28 @@ describe("resolveEmbedPort", () => {
|
|
|
31
31
|
expect(resolveEmbedPort("-1")).toBe(0);
|
|
32
32
|
expect(resolveEmbedPort("70000")).toBe(0);
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
// A stable port is what keeps omp's PRE-extension model resolution correct:
|
|
36
|
+
// it reads models.yml before this extension can bind and rewrite it, so an
|
|
37
|
+
// ephemeral port leaves that block naming the previous session's dead port.
|
|
38
|
+
test("uses the configured server.port when no env override is set", () => {
|
|
39
|
+
expect(resolveEmbedPort(undefined, 8788)).toBe(8788);
|
|
40
|
+
expect(resolveEmbedPort("", 8788)).toBe(8788);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the env var wins over the configured port", () => {
|
|
44
|
+
expect(resolveEmbedPort("8812", 8788)).toBe(8812);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("an explicit env 0 wins, so an ephemeral port stays requestable", () => {
|
|
48
|
+
expect(resolveEmbedPort("0", 8788)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("ignores a nonsense configured port rather than binding it", () => {
|
|
52
|
+
expect(resolveEmbedPort(undefined, 0)).toBe(0);
|
|
53
|
+
expect(resolveEmbedPort(undefined, -5)).toBe(0);
|
|
54
|
+
expect(resolveEmbedPort(undefined, 70_000)).toBe(0);
|
|
55
|
+
});
|
|
34
56
|
});
|
|
35
57
|
|
|
36
58
|
describe("embed port file", () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
cfg1.server.port = portA;
|
|
55
|
+
let first: StartedServer | null = null;
|
|
56
|
+
try {
|
|
57
|
+
first = startServer(cfg1);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
check("first session can bind the deterministic port", false, String(err));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
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 });
|
|
65
|
+
|
|
66
|
+
// 3. A non-router occupant must not be mistaken for our router.
|
|
67
|
+
const squatter = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("not a router", { status: 404 }) });
|
|
68
|
+
const squatted = squatter.port as number;
|
|
69
|
+
check("a non-router occupant fails the health probe", (await probeEmbed(squatted)) === false, { port: squatted });
|
|
70
|
+
|
|
71
|
+
// ...and binding over it must still leave the session with a usable router.
|
|
72
|
+
const cfgFallback = baseCfg();
|
|
73
|
+
cfgFallback.server.port = squatted;
|
|
74
|
+
let fell: StartedServer | null = null;
|
|
75
|
+
try {
|
|
76
|
+
fell = startServer(cfgFallback);
|
|
77
|
+
check("bind on an occupied port yields a different, usable port", fell.server.port !== squatted, { got: fell.server.port });
|
|
78
|
+
} catch {
|
|
79
|
+
cfgFallback.server.port = 0;
|
|
80
|
+
fell = startServer(cfgFallback);
|
|
81
|
+
check("falls back to an ephemeral port when the desired one is taken", fell.server.port !== undefined && fell.server.port !== squatted, {
|
|
82
|
+
got: fell.server.port,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (fell !== null) await fell.stop();
|
|
86
|
+
squatter.stop(true);
|
|
87
|
+
await first.stop();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(failures === 0 ? "\nAll checks passed." : `\n${failures} check(s) FAILED.`);
|
|
91
|
+
process.exit(failures === 0 ? 0 : 1);
|