auto-model-router 0.4.8 → 0.4.10

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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.4.8",
10
+ "version": "0.4.10",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.4.8",
17
+ "version": "0.4.10",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -430,12 +430,14 @@ wire_api = "responses"
430
430
  http_headers = { "X-Omp-Harness" = "codex" }
431
431
  ```
432
432
 
433
- Verified live with codex 0.153: the captured request is
434
- `test/fixtures/harness/codex-responses.json`. Stateless only — Codex sends
435
- `store: false` and the full input each turn; `previous_response_id` is
436
- rejected. Reasoning summaries and encrypted reasoning are not produced. No
437
- session id or hooks: reports are per harness, and there is no toast, digest
438
- or `/router`.
433
+ Verified live with codex 0.153, text and tool-call turns: the captured
434
+ request is `test/fixtures/harness/codex-responses.json`. Stateless only —
435
+ Codex sends `store: false` and the full input each turn; `previous_response_id`
436
+ is rejected. Reasoning summaries and encrypted reasoning are not produced.
437
+ Codex's thread id (sent in the body) becomes the session id and its agent
438
+ name marks subagents, so per-session reports, feedback over the HTTP API and
439
+ the subagent profile work without a plugin. No hooks: there is no toast,
440
+ digest or `/router`.
439
441
 
440
442
  ### Aider
441
443
 
@@ -447,21 +449,97 @@ aider --model openai/auto
447
449
 
448
450
  Verified live with aider 0.86 (captured request:
449
451
  `test/fixtures/harness/aider.json`). Aider sends no tool calls, so every turn
450
- classifies on its text alone, and no custom headers, so its rows carry no
451
- harness id unless you set one in a model settings file
452
- (`extra_params: { extra_headers: { X-Omp-Harness: aider } }`). No session
453
- id or hooks.
452
+ classifies on its text alone. It sends no custom headers by default; a model
453
+ settings file in the project adds the harness id (verified live):
454
454
 
455
- ### Cline, Roo Code, Kilo Code
455
+ ```yaml
456
+ # .aider.model.settings.yml
457
+ - name: openai/auto
458
+ extra_params:
459
+ extra_headers:
460
+ X-Omp-Harness: aider
461
+ ```
462
+
463
+ No session id or hooks.
464
+
465
+ ### Cline CLI
466
+
467
+ ```bash
468
+ cline auth -p openai -b http://127.0.0.1:8788/v1 -k local -m auto
469
+ cline -P openai -m auto "your task"
470
+ ```
471
+
472
+ Verified live with cline 3.0 (captured request:
473
+ `test/fixtures/harness/cline-cli.json`). The CLI sends native tool calls
474
+ (`read_files`, `search_codebase`, `run_commands`, `fetch_web_content`, …),
475
+ all in `digest.toolAliases`, and no custom headers, so its rows carry no
476
+ harness id. No session id or hooks.
477
+
478
+ ### Kilo Code CLI
479
+
480
+ Kilo's CLI is built on OpenCode, so its config is OpenCode's with a different
481
+ file name:
482
+
483
+ ```json
484
+ // kilo.json in the project (or ~/.config/kilo/kilo.json)
485
+ {
486
+ "provider": {
487
+ "auto-model-router": {
488
+ "npm": "@ai-sdk/openai-compatible",
489
+ "name": "auto-model-router",
490
+ "options": { "baseURL": "http://127.0.0.1:8788/v1", "apiKey": "local", "headers": { "X-Omp-Harness": "kilo" } },
491
+ "models": { "auto": { "name": "auto" }, "auto-cheap": { "name": "auto-cheap" } }
492
+ }
493
+ },
494
+ "model": "auto-model-router/auto"
495
+ }
496
+ ```
497
+
498
+ Verified live with kilo 7.5 (captured request:
499
+ `test/fixtures/harness/kilo.json`); tool names match OpenCode's. The
500
+ OpenCode plugin was not picked up from `.kilo/plugin`, `.opencode/plugin` or
501
+ the config's `plugin` list in this test, so Kilo is config-only for now.
502
+
503
+ ### Roo Code (VS Code)
504
+
505
+ Roo's welcome screen has *Import Settings*; a profile file skips the form:
506
+
507
+ ```json
508
+ {
509
+ "providerProfiles": {
510
+ "currentApiConfigName": "auto-model-router",
511
+ "apiConfigs": {
512
+ "auto-model-router": {
513
+ "apiProvider": "openai",
514
+ "openAiBaseUrl": "http://127.0.0.1:8788/v1",
515
+ "openAiApiKey": "local",
516
+ "openAiModelId": "auto",
517
+ "openAiHeaders": { "X-Omp-Harness": "roo" },
518
+ "openAiCustomModelInfo": { "maxTokens": 8192, "contextWindow": 400000, "supportsImages": true, "supportsPromptCache": true, "inputPrice": 0, "outputPrice": 0 },
519
+ "id": "amr-0001"
520
+ }
521
+ }
522
+ }
523
+ }
524
+ ```
525
+
526
+ Verified live with Roo Code 3.54 (captured request:
527
+ `test/fixtures/harness/roo.json`, including a tool round trip): native tool
528
+ calls (`read_file`, `search_files`, `list_files`, `apply_diff`, …), all in
529
+ `digest.toolAliases`, and the harness header through `openAiHeaders`. Two
530
+ cautions: 3.54 announces itself as the last Roo Code release, and its
531
+ Architect mode loops on a model that never calls `attempt_completion`, so
532
+ start in Code mode or pin a stronger profile (`auto-max`) for it. No session
533
+ id or hooks: the digest applies only through summarising compaction.
534
+
535
+ ### Cline (VS Code)
456
536
 
457
537
  Choose the *OpenAI Compatible* provider in the extension's settings, set the
458
538
  base URL to `http://127.0.0.1:8788/v1`, any API key, and the model id `auto`
459
- (or `auto-cheap` / `auto-max`). Where the extension offers custom headers,
460
- add `X-Omp-Harness` with the harness name. Their tool names
461
- (`read_file`, `search_files`, `execute_command`, `list_files`) are already
462
- in `digest.toolAliases`, but with no hook to intercept tool results the
463
- digest applies only through summarising compaction
464
- (`compaction.digestToolResults`), which runs inside the router.
539
+ (or `auto-cheap` / `auto-max`); add `X-Omp-Harness` under custom headers if
540
+ offered. Not verified live here (the CLI above was); its tool names are in
541
+ `digest.toolAliases`, and the digest applies only through summarising
542
+ compaction.
465
543
 
466
544
  ### OpenCode
467
545
 
@@ -1101,6 +1179,25 @@ there); and the switch happens at prompt boundaries, never mid-turn.
1101
1179
 
1102
1180
  ## Multiple coding harnesses, one router
1103
1181
 
1182
+ **One router process for everything.** omp's embed extension binds a private
1183
+ router on an ephemeral port per session by default, while Hermes, Codex,
1184
+ OpenCode and Aider talk to a standalone router on port 8788. Those are two
1185
+ processes over two homes, and per-process state (pins, the digest re-run
1186
+ memory) and reports stay apart. To share one router:
1187
+
1188
+ 1. Run it once, before the harnesses start: `auto-model-router serve --port 8788`
1189
+ (against the default home, `~/.auto-model-router`).
1190
+ 2. Set `AUTO_MODEL_ROUTER_PORT=8788` in omp's environment. The embed then
1191
+ attaches to the router already answering on that port instead of binding
1192
+ its own (it still binds 8788 itself if nothing is there, which the others
1193
+ then reuse).
1194
+ 3. Point Hermes, Codex, OpenCode and Aider at `http://127.0.0.1:8788/v1` as
1195
+ in their recipes. Hermes's provider plugin only spawns a router when
1196
+ nothing listens on 8788, so it joins the shared one too.
1197
+
1198
+ Each harness keeps its own `X-Omp-Harness` id, so budgets and reports stay
1199
+ per harness while the ledger, catalog and conversation state are shared.
1200
+
1104
1201
  What each harness gets today. "Config only" means the OpenAI-compatible wire
1105
1202
  plus a harness header; the rest needs the harness's own hook API.
1106
1203
 
@@ -1108,9 +1205,12 @@ plus a harness header; the rest needs the harness's own hook API.
1108
1205
  | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
1109
1206
  | omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
1110
1207
  | Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
1111
- | Codex CLI | Responses API wire | yes | no | no | no | no | compaction only | no | no |
1208
+ | Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
1112
1209
  | Aider | config only | via model settings | no | no | no | no | no tools | no | no |
1113
- | Cline / Roo / Kilo | config only | if headers supported | no | no | no | no | compaction only | no | no |
1210
+ | Cline CLI | config only | no | no | no | no | no | compaction only | no | no |
1211
+ | Kilo Code CLI | config only | yes | no | no | no | no | compaction only | no | no |
1212
+ | Roo Code (VS Code) | config only | yes | no | no | no | no | compaction only | no | no |
1213
+ | Cline (VS Code) | config only, unverified | if headers supported | no | no | no | no | compaction only | no | no |
1114
1214
  | OpenCode | config + plugin | yes | yes (plugin) | yes (plugin) | yes (plugin) | no | yes (plugin) | no | no |
1115
1215
  | Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
1116
1216
 
@@ -0,0 +1,103 @@
1
+ """Self-test for the Hermes native plugin's pure functions.
2
+
3
+ Run by test/hermes-plugin.test.ts when a Python interpreter is on PATH (the
4
+ Bun suite cannot import Python), and directly:
5
+
6
+ python hermes-plugin/native/selftest.py
7
+
8
+ Exercises identity headers, the digest gate and replacement, and the /router
9
+ command rendering against fake transports. Exits non-zero on the first
10
+ failure with the assertion that failed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import json
17
+ import os
18
+ import sys
19
+
20
+ HERE = os.path.dirname(os.path.abspath(__file__))
21
+ spec = importlib.util.spec_from_file_location("amr_native", os.path.join(HERE, "__init__.py"))
22
+ m = importlib.util.module_from_spec(spec)
23
+ assert spec.loader is not None
24
+ spec.loader.exec_module(m)
25
+
26
+
27
+ def test_identity_headers() -> None:
28
+ m.on_pre_llm_call(session_id="s1", parent_session_id="")
29
+ m.on_pre_llm_call(session_id="s2", parent_session_id="s1")
30
+ r = m.on_llm_request(request={"model": "auto", "extra_headers": {"A": "b"}}, provider="auto-model-router", session_id="s2")
31
+ assert r["request"]["extra_headers"] == {"A": "b", "X-Omp-Harness": m.HARNESS_ID, "X-Omp-Session": "s2", "X-Omp-Subagent": "1"}, r
32
+ main = m.on_llm_request(request={"model": "auto"}, provider="auto-model-router", session_id="s1")
33
+ assert "X-Omp-Subagent" not in main["request"]["extra_headers"], main
34
+ assert m.on_llm_request(request={}, provider="anthropic", session_id="s1") is None
35
+ assert m._CURRENT["session_id"] == "s1"
36
+
37
+
38
+ def test_digest() -> None:
39
+ m.POLICY.value = {"enabled": True, "minBytes": 10, "maxBytes": 100000, "tools": ["read", "grep", "bash"], "toolAliases": {"read_file": "read", "terminal": "bash"}, "fromTier": "moderate"}
40
+ m.POLICY._at = 10**12
41
+ calls = []
42
+
43
+ def fake_post(path, payload, timeout=0):
44
+ calls.append((path, payload["toolName"], len(payload["content"])))
45
+ return {"digested": True, "text": "[digest] short"}
46
+
47
+ big = json.dumps({"content": "x" * 500, "path": "a.ts"})
48
+ out = m.maybe_digest(big, "read_file", {"path": "a.ts"}, "s1", post=fake_post)
49
+ assert json.loads(out)["content"] == "[digest] short", out
50
+ assert json.loads(out)["path"] == "a.ts"
51
+ assert calls == [("/v1/router/digest", "read_file", 500)], calls
52
+ err = json.dumps({"error": "nope", "content": "x" * 500})
53
+ assert m.maybe_digest(err, "read_file", {}, "s1", post=fake_post) == err
54
+ assert m.maybe_digest(json.dumps({"content": "x" * 500}), "write_file", {}, "s1", post=fake_post).startswith('{"content": "xxx')
55
+ plain = "plain text " * 100
56
+ assert m.maybe_digest(plain, "read_file", {}, "s1", post=fake_post) == plain
57
+ assert len(calls) == 1
58
+ assert m.canonical_tool(m.POLICY.value, "TERMINAL") == "bash"
59
+ assert m.largest_string_field({"a": "x" * 90, "b": "y" * 5}) == "a"
60
+ assert m.largest_string_field({"a": "x" * 50, "b": "y" * 50}) is None
61
+
62
+
63
+ def test_router_command() -> None:
64
+ def fake_get(path, timeout=5.0, text=False):
65
+ if path.startswith("/v1/router/report"):
66
+ return "REPORT " + path
67
+ if path.startswith("/v1/router/summary"):
68
+ return "SUMMARY " + path
69
+ if path.startswith("/health"):
70
+ return {"status": "ok", "apiKeyConfigured": True, "apiKeySource": "env", "catalog": {"models": 3}, "ollama": None, "softFailures": {"spikes": [{"slug": "a/b", "recentRate": 0.5, "recentDispatches": 8, "baselineRate": 0.05}]}}
71
+ if path.startswith("/v1/router/decisions"):
72
+ return {"entries": [{"slug": "a/b", "tier": "hard", "classificationSource": "heuristic", "confidence": 0.7, "reportedUsd": 0.0000097, "usage": {"promptTokens": 100, "cachedTokens": 50}, "latencyMs": 900, "reasons": ["r1"]}]}
73
+ return {}
74
+
75
+ def fake_post(path, payload, timeout=5.0):
76
+ if path == "/v1/router/feedback":
77
+ return {"slug": "a/b", "tier": "hard"}
78
+ return {"override": {"slug": payload.get("slug"), "tier": payload.get("tier"), "turnsLeft": payload.get("turns")}}
79
+
80
+ assert m.router_command("report 30 --all", get=fake_get, post=fake_post) == "REPORT /v1/router/report?days=30&format=text"
81
+ assert m.router_command("report", get=fake_get, post=fake_post).endswith(f"days=7&format=text&harness={m.HARNESS_ID}")
82
+ assert m.router_command("summary", get=fake_get, post=fake_post).startswith("SUMMARY /v1/router/summary?format=text&harness=")
83
+ status = m.router_command("status", get=fake_get, post=fake_post)
84
+ assert "soft failures SPIKING (1)" in status and "a/b: 50% of 8" in status, status
85
+ why = m.router_command("why", get=fake_get, post=fake_post)
86
+ assert "last turn: a/b [hard]" in why and "$0.00001" in why and "cache hit 50%" in why, why
87
+ assert m.router_command("good nice", get=fake_get, post=fake_post) == "recorded good for a/b [hard]"
88
+ assert m.router_command("pin a/b", get=fake_get, post=fake_post) == "pin: a/b"
89
+ assert m.router_command("tier hard 5", get=fake_get, post=fake_post) == "tier: hard for 5 turns"
90
+ assert m.router_command("tier off", get=fake_get, post=fake_post) == "tier: cleared"
91
+ assert m.router_command("bogus", get=fake_get, post=fake_post) == m.USAGE
92
+
93
+ def down(path, timeout=5.0, text=False):
94
+ raise OSError("connection refused")
95
+
96
+ assert m.router_command("status", get=down, post=fake_post).startswith("router unreachable at")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ for name, fn in [(n, f) for n, f in globals().items() if n.startswith("test_")]:
101
+ fn()
102
+ print(f"ok {name}")
103
+ sys.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -322,6 +322,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
322
322
  shell: "bash",
323
323
  web_extract: "web_fetch",
324
324
  fetch_url: "web_fetch",
325
+ // Cline CLI 3.x
326
+ read_files: "read",
327
+ search_codebase: "grep",
328
+ run_commands: "bash",
329
+ fetch_web_content: "web_fetch",
325
330
  },
326
331
  },
327
332
  report: {
@@ -376,6 +376,9 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
376
376
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
377
377
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
378
378
  const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
379
+ // Ollama meter samples (one per usage poll) only matter for the current
380
+ // billing cycle's calibration; they age out with the ledger rows.
381
+ const pruneMeterStmt = db.query("DELETE FROM ollama_meter_samples WHERE at_ms < ?");
379
382
  const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
380
383
  const providerSpendStmt = db.query(
381
384
  "SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
@@ -627,7 +630,9 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
627
630
  },
628
631
  prune(retentionDays: number, nowMs = Date.now()): number {
629
632
  if (retentionDays <= 0) return 0;
630
- return pruneStmt.run(nowMs - retentionDays * DAY_MS).changes;
633
+ const cutoff = nowMs - retentionDays * DAY_MS;
634
+ pruneMeterStmt.run(cutoff);
635
+ return pruneStmt.run(cutoff).changes;
631
636
  },
632
637
  markWasted(id: string): void {
633
638
  wasteStmt.run(id);
@@ -28,6 +28,7 @@ export interface SummaryWindow {
28
28
  modelSwitches: number;
29
29
  digests: number;
30
30
  digestSpendUsd: number;
31
+ digestReruns: number;
31
32
  subagentSpendUsd: number;
32
33
  }
33
34
 
@@ -81,6 +82,7 @@ function windowOf(r: UsageReport): SummaryWindow {
81
82
  modelSwitches: t.modelSwitches,
82
83
  digests: t.digests,
83
84
  digestSpendUsd: t.digestSpendUsd,
85
+ digestReruns: t.digestReruns,
84
86
  subagentSpendUsd: t.subagentSpendUsd,
85
87
  };
86
88
  }
@@ -172,7 +174,7 @@ export function renderDailySummary(s: DailySummary): string {
172
174
  out.push(b.savedShare >= 0 ? `saved ${pct(b.savedShare)} vs ${b.slug} (${usd(b.usd)} at list)` : `cost ${pct(-b.savedShare)} MORE than ${b.slug} (${usd(b.usd)} at list)`);
173
175
  }
174
176
  const extras: string[] = [];
175
- if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)}`);
177
+ if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)} (re-run rate ${pct(c.digestReruns / c.digests)})`);
176
178
  if (c.subagentSpendUsd > 0) extras.push(`subagents ${usd(c.subagentSpendUsd)}`);
177
179
  if (extras.length > 0) out.push(extras.join(" · "));
178
180
  }
@@ -123,8 +123,35 @@ export function responsesToChatBody(body: unknown): Rec {
123
123
  return out;
124
124
  }
125
125
 
126
+ /**
127
+ * Codex carries its identity in the body, not in headers: the thread id under
128
+ * `client_metadata` (also the `prompt_cache_key`), and the agent name inside
129
+ * the JSON-encoded `x-codex-turn-metadata` (`/root` is the main agent). When
130
+ * the caller sent no X-Omp-Session / X-Omp-Subagent header, they are derived
131
+ * from those, so Codex gets per-session reports, feedback and the subagent
132
+ * profile without a plugin.
133
+ */
134
+ export function identityHeadersFromBody(body: unknown, headers: Headers): Headers {
135
+ if (!isRec(body)) return headers;
136
+ const h = new Headers(headers);
137
+ const cm = isRec(body.client_metadata) ? body.client_metadata : {};
138
+ if ((h.get("x-omp-session") ?? "").trim() === "") {
139
+ const id = [cm.thread_id, cm.session_id, body.prompt_cache_key].find((v): v is string => typeof v === "string" && v !== "");
140
+ if (id !== undefined) h.set("x-omp-session", id);
141
+ }
142
+ if ((h.get("x-omp-subagent") ?? "").trim() === "" && typeof cm["x-codex-turn-metadata"] === "string") {
143
+ try {
144
+ const meta: unknown = JSON.parse(cm["x-codex-turn-metadata"]);
145
+ if (isRec(meta) && typeof meta.agent_name === "string" && meta.agent_name !== "" && meta.agent_name !== "/root") h.set("x-omp-subagent", "1");
146
+ } catch {
147
+ // Not JSON: no subagent signal.
148
+ }
149
+ }
150
+ return h;
151
+ }
152
+
126
153
  export function parseResponsesRequest(body: unknown, headers: Headers): NormRequest {
127
- const norm = parseChatRequest(responsesToChatBody(body), headers);
154
+ const norm = parseChatRequest(responsesToChatBody(body), identityHeadersFromBody(body, headers));
128
155
  return { ...norm, protocol: "openai-responses" };
129
156
  }
130
157