auto-model-router 0.4.5 → 0.4.6

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.5",
10
+ "version": "0.4.6",
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.5",
17
+ "version": "0.4.6",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -371,6 +371,107 @@ providers:
371
371
  default_model: auto
372
372
  ```
373
373
 
374
+ **Native features (Hermes plugin API).** The provider plugin above only
375
+ registers the model provider; Hermes never calls `register(ctx)` on
376
+ provider plugins, so the features that need hooks live in a second,
377
+ standalone plugin:
378
+
379
+ ```bash
380
+ cp -r hermes-plugin/native "$HERMES_HOME/plugins/auto-model-router"
381
+ hermes plugins enable auto-model-router
382
+ ```
383
+
384
+ It adds, through Hermes middleware and hooks:
385
+
386
+ - **Session identity** — `X-Omp-Session` and `X-Omp-Subagent` on every router
387
+ request (a session that reported a parent session is a subagent), so
388
+ per-session reports, `/router why`, feedback and the router's
389
+ `server.subagentProfile` work as in omp. `X-Omp-Harness` is `hermes` (or
390
+ `OMP_HARNESS_ID`).
391
+ - **Tool-result digest** — large `read_file`, `search_files` and `terminal`
392
+ results go to `/v1/router/digest` and the model gets the digest (see
393
+ [`digest`](#digest--cheap-model-digest-of-large-tool-results); Hermes tool
394
+ names are mapped by `digest.toolAliases`). Off unless `digest.enabled`.
395
+ - **`/router`** — `report [days] [--all]`, `summary`, `status`, `why`,
396
+ `good`/`bad [note]`, `pin <model|off>`, `tier <tier|off> [turns]`, as text.
397
+
398
+ Point Hermes's side jobs at the cheap profile so they cost what omp's do:
399
+
400
+ ```yaml
401
+ # $HERMES_HOME/config.yaml
402
+ auxiliary:
403
+ vision: { provider: auto-model-router, model: auto-cheap }
404
+ compression: { provider: auto-model-router, model: auto-cheap }
405
+ ```
406
+
407
+ Not available in Hermes: a per-turn routing toast (its plugin API has no
408
+ user-visible notice channel; use `/router why`), the automatic daily summary
409
+ (`/router summary` on demand), and the harness-side model switch.
410
+
411
+ ### Codex CLI
412
+
413
+ Codex talks to custom providers over the chat-completions wire. Run the
414
+ router (`auto-model-router serve --port 8788`) and add a provider:
415
+
416
+ ```toml
417
+ # ~/.codex/config.toml
418
+ model = "auto"
419
+ model_provider = "auto-model-router"
420
+
421
+ [model_providers.auto-model-router]
422
+ name = "auto-model-router"
423
+ base_url = "http://127.0.0.1:8788/v1"
424
+ wire_api = "chat"
425
+ http_headers = { "X-Omp-Harness" = "codex" }
426
+ ```
427
+
428
+ The router drops the OpenAI-platform-only parameters Codex sends (`store`,
429
+ `prompt_cache_key`, `service_tier`) before dispatch. No session id or hooks:
430
+ reports are per harness, and there is no toast, digest or `/router`.
431
+
432
+ ### Aider
433
+
434
+ ```bash
435
+ export OPENAI_API_BASE=http://127.0.0.1:8788/v1
436
+ export OPENAI_API_KEY=local
437
+ aider --model openai/auto
438
+ ```
439
+
440
+ Aider sends no tool calls, so every turn classifies on its text alone. No
441
+ session id or hooks.
442
+
443
+ ### Cline, Roo Code, Kilo Code
444
+
445
+ Choose the *OpenAI Compatible* provider in the extension's settings, set the
446
+ base URL to `http://127.0.0.1:8788/v1`, any API key, and the model id `auto`
447
+ (or `auto-cheap` / `auto-max`). Where the extension offers custom headers,
448
+ add `X-Omp-Harness` with the harness name. Their tool names
449
+ (`read_file`, `search_files`, `execute_command`, `list_files`) are already
450
+ in `digest.toolAliases`, but with no hook to intercept tool results the
451
+ digest applies only through summarising compaction
452
+ (`compaction.digestToolResults`), which runs inside the router.
453
+
454
+ ### OpenCode
455
+
456
+ ```json
457
+ // ~/.config/opencode/opencode.json
458
+ {
459
+ "provider": {
460
+ "auto-model-router": {
461
+ "npm": "@ai-sdk/openai-compatible",
462
+ "name": "auto-model-router",
463
+ "options": { "baseURL": "http://127.0.0.1:8788/v1", "apiKey": "local", "headers": { "X-Omp-Harness": "opencode" } },
464
+ "models": { "auto": { "name": "auto" }, "auto-cheap": { "name": "auto-cheap" }, "auto-max": { "name": "auto-max" } }
465
+ }
466
+ },
467
+ "model": "auto-model-router/auto"
468
+ }
469
+ ```
470
+
471
+ OpenCode's tool names (`read`, `grep`, `glob`, `bash`, `webfetch`) match the
472
+ router's canonical list. Its plugin API has tool and message hooks, so a
473
+ native port (session identity, digest) is the next candidate after Hermes.
474
+
374
475
  ### The OpenRouter key
375
476
 
376
477
  **omp does not need to be authenticated to OpenRouter.** On a routed turn omp
@@ -818,6 +919,7 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
818
919
  | `enabled` | `false` | Master switch; the extension polls it every minute. |
819
920
  | `minBytes` / `maxBytes` | `12000` / `400000` | Result size window that gets digested. |
820
921
  | `tools` | `read, grep, glob, bash, web_fetch, webfetch, ls, find` | Eligible tool names (lower-case). |
922
+ | `toolAliases` | Hermes, Cline/Roo/Kilo, Codex and OpenCode spellings (`read_file` → `read`, `search_files` → `grep`, `terminal`/`execute_command`/`shell` → `bash`, …) | Harness tool names mapped onto the canonical `tools` list, so one list serves every harness. |
821
923
  | `fromTier` | `moderate` | Digest only when the session's current model is at or above this tier. |
822
924
  | `tier` / `model` | `simple` / unset | Where the digest model is picked from, or a pinned slug. |
823
925
  | `maxOutputTokens` | `700` | Digest length cap. |
@@ -968,6 +1070,19 @@ there); and the switch happens at prompt boundaries, never mid-turn.
968
1070
 
969
1071
  ## Multiple coding harnesses, one router
970
1072
 
1073
+ What each harness gets today. "Config only" means the OpenAI-compatible wire
1074
+ plus a harness header; the rest needs the harness's own hook API.
1075
+
1076
+ | Harness | Wire | Harness id | Session id | Subagent flag | Toast | `/router` | Digest | Daily summary | Model switch |
1077
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
1078
+ | omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
1079
+ | Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
1080
+ | Codex CLI | config only | yes | no | no | no | no | compaction only | no | no |
1081
+ | Aider | config only | yes | no | no | no | no | no tools | no | no |
1082
+ | Cline / Roo / Kilo | config only | if headers supported | no | no | no | no | compaction only | no | no |
1083
+ | OpenCode | config only | yes | no | no | no | no | compaction only | no | no |
1084
+ | Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
1085
+
971
1086
  A single embedded router can serve several omp sessions without them stepping
972
1087
  on each other:
973
1088
 
@@ -105,5 +105,8 @@ profile = ProviderProfile(
105
105
  fallback_models=("auto", "auto-cheap", "auto-max"),
106
106
  display_name="auto-model-router",
107
107
  description="Per-turn cost/complexity-aware model routing",
108
+ # The harness id the router records on every row (per-harness budgets and
109
+ # reports). The native plugin adds the per-session headers on top.
110
+ default_headers={"X-Omp-Harness": os.environ.get("OMP_HARNESS_ID", "hermes")},
108
111
  )
109
112
  register_provider(profile)
@@ -0,0 +1,348 @@
1
+ """Hermes standalone plugin: auto-model-router native features.
2
+
3
+ Companion to the ``model-providers/auto-model-router`` provider plugin (which
4
+ spawns the router and registers it as a provider). Hermes routes provider
5
+ plugins through its own discovery and never calls ``register(ctx)`` on them,
6
+ so the features that need the plugin API live here:
7
+
8
+ * **Session identity** — ``llm_request`` middleware adds the ``X-Omp-Session``,
9
+ ``X-Omp-Harness`` and ``X-Omp-Subagent`` headers to every router request,
10
+ so per-session reports, ``/router why``, feedback and the router's subagent
11
+ profile work the way they do in omp. A session is a subagent when
12
+ ``pre_llm_call`` reported a parent session for it.
13
+ * **Tool-result digest** — ``tool_execution`` middleware sends a large
14
+ ``read_file`` / ``search_files`` / ``terminal`` result to the router's
15
+ ``/v1/router/digest`` and hands the model the digest instead. The router
16
+ decides (policy, session tier, cost guard); this plugin only ships text that
17
+ passes the cheap client-side checks. Off unless ``digest.enabled`` is set in
18
+ the router config.
19
+ * **``/router``** — report, summary, status, why, good, bad, pin, tier, as
20
+ text, over the same HTTP endpoints omp's ``/router`` uses.
21
+
22
+ Install:
23
+
24
+ mkdir -p "$HERMES_HOME/plugins"
25
+ cp -r hermes-plugin/native "$HERMES_HOME/plugins/auto-model-router"
26
+ hermes plugins enable auto-model-router
27
+
28
+ The router URL is ``http://127.0.0.1:$AUTO_MODEL_ROUTER_PORT`` (default 8788),
29
+ the port the provider plugin spawns on.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ import threading
38
+ import time
39
+ import urllib.error
40
+ import urllib.request
41
+ from typing import Any, Callable, Dict, Optional
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ PORT = int(os.environ.get("AUTO_MODEL_ROUTER_PORT", "8788"))
46
+ BASE_URL = f"http://127.0.0.1:{PORT}"
47
+ # The harness id the router records on every row. Override to run several
48
+ # Hermes profiles against one router with separate budgets and reports.
49
+ HARNESS_ID = os.environ.get("OMP_HARNESS_ID", "hermes")
50
+ PROVIDER_NAME = "auto-model-router"
51
+ POLICY_TTL_S = 60.0
52
+ DIGEST_TIMEOUT_S = 30.0
53
+ # Hermes wraps every tool result in a JSON object; the digest replaces the
54
+ # largest string field (``content`` for read_file, ``output`` for terminal,
55
+ # the match text for search_files).
56
+ MIN_FIELD_SHARE = 0.8
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # HTTP helpers (stdlib only; plugins should not add dependencies)
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _get(path: str, timeout: float = 5.0, text: bool = False) -> Any:
65
+ req = urllib.request.Request(BASE_URL + path, headers={"Accept": "text/plain" if text else "application/json"})
66
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
67
+ body = resp.read().decode("utf-8")
68
+ return body if text else json.loads(body)
69
+
70
+
71
+ def _post(path: str, payload: Dict[str, Any], timeout: float = 5.0) -> Any:
72
+ data = json.dumps(payload).encode("utf-8")
73
+ req = urllib.request.Request(BASE_URL + path, data=data, headers={"Content-Type": "application/json"}, method="POST")
74
+ try:
75
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
76
+ return json.loads(resp.read().decode("utf-8"))
77
+ except urllib.error.HTTPError as exc:
78
+ try:
79
+ err = json.loads(exc.read().decode("utf-8"))
80
+ message = (err.get("error") or {}).get("message") or str(exc)
81
+ except Exception:
82
+ message = str(exc)
83
+ raise RuntimeError(message) from exc
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Session identity
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ class _Sessions:
92
+ """Which sessions are subagents (they reported a parent session)."""
93
+
94
+ def __init__(self) -> None:
95
+ self._lock = threading.Lock()
96
+ self._parent: Dict[str, str] = {}
97
+
98
+ def note(self, session_id: str, parent_session_id: str) -> None:
99
+ if not session_id:
100
+ return
101
+ with self._lock:
102
+ if parent_session_id:
103
+ self._parent[session_id] = parent_session_id
104
+ else:
105
+ self._parent.pop(session_id, None)
106
+
107
+ def is_subagent(self, session_id: str) -> bool:
108
+ with self._lock:
109
+ return bool(session_id) and session_id in self._parent
110
+
111
+
112
+ SESSIONS = _Sessions()
113
+ # The session the user is driving; ``/router`` acts on it.
114
+ _CURRENT = {"session_id": ""}
115
+
116
+
117
+ def on_pre_llm_call(session_id: str = "", parent_session_id: str = "", **_: Any) -> None:
118
+ SESSIONS.note(session_id, parent_session_id)
119
+ if not parent_session_id and session_id:
120
+ _CURRENT["session_id"] = session_id
121
+ return None
122
+
123
+
124
+ def identity_headers(session_id: str) -> Dict[str, str]:
125
+ headers = {"X-Omp-Harness": HARNESS_ID}
126
+ if session_id:
127
+ headers["X-Omp-Session"] = session_id
128
+ if SESSIONS.is_subagent(session_id):
129
+ headers["X-Omp-Subagent"] = "1"
130
+ return headers
131
+
132
+
133
+ def on_llm_request(request: Dict[str, Any] = None, provider: str = "", session_id: str = "", **_: Any) -> Optional[Dict[str, Any]]:
134
+ """Attach the router's identity headers to requests bound for the router."""
135
+ if request is None or provider != PROVIDER_NAME:
136
+ return None
137
+ updated = dict(request)
138
+ extra = dict(updated.get("extra_headers") or {})
139
+ extra.update(identity_headers(session_id))
140
+ updated["extra_headers"] = extra
141
+ return {"request": updated, "source": "auto-model-router", "reason": "session identity headers"}
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Tool-result digest
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class _Policy:
150
+ def __init__(self) -> None:
151
+ self._at = 0.0
152
+ self.value: Dict[str, Any] = {"enabled": False}
153
+
154
+ def get(self) -> Dict[str, Any]:
155
+ now = time.monotonic()
156
+ if now - self._at >= POLICY_TTL_S:
157
+ self._at = now
158
+ try:
159
+ p = _get("/v1/router/digest/policy", timeout=2.0)
160
+ self.value = p if isinstance(p, dict) else {"enabled": False}
161
+ except Exception:
162
+ self.value = {"enabled": False}
163
+ return self.value
164
+
165
+
166
+ POLICY = _Policy()
167
+
168
+
169
+ def canonical_tool(policy: Dict[str, Any], tool_name: str) -> str:
170
+ lower = (tool_name or "").lower()
171
+ aliases = policy.get("toolAliases") or {}
172
+ return str(aliases.get(lower, lower)).lower()
173
+
174
+
175
+ def largest_string_field(result: Any) -> Optional[str]:
176
+ """The key holding most of a JSON tool result's bytes, or None."""
177
+ if not isinstance(result, dict):
178
+ return None
179
+ best, best_len, total = None, 0, 0
180
+ for k, v in result.items():
181
+ if isinstance(v, str):
182
+ n = len(v.encode("utf-8"))
183
+ total += n
184
+ if n > best_len:
185
+ best, best_len = k, n
186
+ if best is None or total == 0 or best_len < total * MIN_FIELD_SHARE:
187
+ return None
188
+ return best
189
+
190
+
191
+ def should_send(policy: Dict[str, Any], tool_name: str, text: str) -> bool:
192
+ if not policy.get("enabled"):
193
+ return False
194
+ tools = [str(t).lower() for t in (policy.get("tools") or [])]
195
+ if canonical_tool(policy, tool_name) not in tools:
196
+ return False
197
+ n = len(text.encode("utf-8"))
198
+ return int(policy.get("minBytes", 12000)) <= n <= int(policy.get("maxBytes", 400000))
199
+
200
+
201
+ def on_tool_execution(next_call: Callable[[Any], Any] = None, args: Any = None, tool_name: str = "", session_id: str = "", **_: Any) -> Any:
202
+ """Run the tool, then replace a large result with the router's digest."""
203
+ if next_call is None:
204
+ return None
205
+ result = next_call(args)
206
+ try:
207
+ return maybe_digest(result, tool_name, args if isinstance(args, dict) else {}, session_id)
208
+ except Exception as exc: # never lose a tool result to the digest path
209
+ logger.debug("auto-model-router digest skipped: %s", exc)
210
+ return result
211
+
212
+
213
+ def maybe_digest(result: Any, tool_name: str, args: Dict[str, Any], session_id: str, post: Callable[..., Any] = None) -> Any:
214
+ post = post or _post
215
+ policy = POLICY.get()
216
+ if not policy.get("enabled") or not isinstance(result, str):
217
+ return result
218
+ try:
219
+ parsed = json.loads(result)
220
+ except Exception:
221
+ return result
222
+ if isinstance(parsed, dict) and parsed.get("error"):
223
+ return result
224
+ field = largest_string_field(parsed)
225
+ text = parsed.get(field) if field else (parsed if isinstance(parsed, str) else None)
226
+ if not isinstance(text, str) or not should_send(policy, tool_name, text):
227
+ return result
228
+ r = post(
229
+ "/v1/router/digest",
230
+ {"ompSessionId": session_id, "harnessId": HARNESS_ID, "toolName": tool_name, "input": args, "content": text, "query": ""},
231
+ timeout=DIGEST_TIMEOUT_S,
232
+ )
233
+ if not isinstance(r, dict) or not r.get("digested") or not isinstance(r.get("text"), str):
234
+ return result
235
+ if field is None:
236
+ return r["text"]
237
+ parsed[field] = r["text"]
238
+ return json.dumps(parsed, ensure_ascii=False)
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # /router command
243
+ # ---------------------------------------------------------------------------
244
+
245
+ USAGE = "usage: /router report [days] [--all] | summary [--all] | status | why | good [note] | bad [note] | pin <model|off> | tier <tier|off> [turns]"
246
+
247
+
248
+ def _status_text(h: Dict[str, Any]) -> str:
249
+ lines = [f"auto-model-router at {BASE_URL}: {h.get('status', 'unknown')}"]
250
+ lines.append(f"openrouter: key {'configured (' + str(h.get('apiKeySource', '?')) + ')' if h.get('apiKeyConfigured') else 'MISSING'}")
251
+ c = h.get("catalog")
252
+ lines.append(f"catalog: {c.get('models', 0)} models" if isinstance(c, dict) else "catalog: not fetched yet")
253
+ o = h.get("ollama")
254
+ if isinstance(o, dict):
255
+ meter = o.get("meter") or {}
256
+ usage = f" · {meter.get('plan', 'plan')} ${meter.get('usedUsd', 0):.2f} of ${meter.get('creditsUsd', '?')}" if meter else ""
257
+ lines.append(f"ollama cloud: {o.get('models', 0)} models · {'available' if o.get('available') else 'COOLING DOWN'}{usage}")
258
+ else:
259
+ lines.append("ollama cloud: disabled")
260
+ sf = h.get("softFailures") or {}
261
+ spikes = sf.get("spikes") or []
262
+ if spikes:
263
+ lines.append(f"soft failures SPIKING ({len(spikes)}):")
264
+ for s in spikes:
265
+ lines.append(f" {s.get('slug')}: {round(100 * s.get('recentRate', 0))}% of {s.get('recentDispatches', 0)} failed in the last hour (7d baseline {round(100 * s.get('baselineRate', 0))}%)")
266
+ else:
267
+ lines.append("soft failures: no model spiking in the last hour")
268
+ return "\n".join(lines)
269
+
270
+
271
+ def _why_text(e: Dict[str, Any]) -> str:
272
+ usage = e.get("usage") or {}
273
+ pt, ct = usage.get("promptTokens", 0) or 0, usage.get("cachedTokens", 0) or 0
274
+ cache = f"{round(100 * ct / pt)}%" if pt else "n/a"
275
+ cost = e.get("reportedUsd")
276
+ lines = [
277
+ f"last turn: {e.get('servedSlug') or e.get('slug')} [{e.get('tier')}] · {e.get('classificationSource')} (confidence {e.get('confidence')})",
278
+ f"cost ${cost if cost is not None else e.get('predictedUsd')} · cache hit {cache} · latency {e.get('latencyMs')}ms",
279
+ ]
280
+ for r in e.get("reasons") or []:
281
+ lines.append(f" - {r}")
282
+ return "\n".join(lines)
283
+
284
+
285
+ def _parse_window(args: list) -> tuple:
286
+ days, scope = 7, HARNESS_ID
287
+ for a in args:
288
+ low = a.lower()
289
+ if low in ("--all", "all"):
290
+ scope = ""
291
+ elif low.rstrip("d").isdigit():
292
+ days = int(low.rstrip("d"))
293
+ return days, scope
294
+
295
+
296
+ def router_command(raw_args: str = "", get: Callable[..., Any] = None, post: Callable[..., Any] = None) -> str:
297
+ get, post = get or _get, post or _post
298
+ parts = (raw_args or "").split()
299
+ verb = parts[0].lower() if parts else ""
300
+ rest = parts[1:]
301
+ session = _CURRENT["session_id"]
302
+ try:
303
+ if verb == "report":
304
+ days, scope = _parse_window(rest)
305
+ q = f"?days={days}&format=text" + (f"&harness={scope}" if scope else "")
306
+ return get(f"/v1/router/report{q}", text=True)
307
+ if verb in ("summary", "daily"):
308
+ _, scope = _parse_window(rest)
309
+ q = "?format=text" + (f"&harness={scope}" if scope else "")
310
+ return get(f"/v1/router/summary{q}", text=True)
311
+ if verb in ("status", "health"):
312
+ return _status_text(get("/health"))
313
+ if verb in ("why", "explain"):
314
+ body = get(f"/v1/router/decisions?limit=1&session={session}")
315
+ entries = body.get("entries") or []
316
+ return _why_text(entries[0]) if entries else "no routed turn in this session yet"
317
+ if verb in ("good", "bad"):
318
+ r = post("/v1/router/feedback", {"ompSessionId": session, "verdict": verb, "note": " ".join(rest)})
319
+ return f"recorded {verb} for {r.get('slug')} [{r.get('tier')}]"
320
+ if verb == "pin":
321
+ if not rest:
322
+ return USAGE
323
+ r = post("/v1/router/override", {"ompSessionId": session, "slug": None if rest[0].lower() == "off" else rest[0]})
324
+ o = r.get("override") or {}
325
+ return f"pin: {o.get('slug') or 'cleared'}"
326
+ if verb == "tier":
327
+ if not rest:
328
+ return USAGE
329
+ turns = int(rest[1]) if len(rest) > 1 and rest[1].isdigit() else 10
330
+ r = post("/v1/router/override", {"ompSessionId": session, "tier": None if rest[0].lower() == "off" else rest[0], "turns": turns})
331
+ o = r.get("override") or {}
332
+ left = o.get("turnsLeft")
333
+ return f"tier: {o.get('tier') or 'cleared'}" + (f" for {left} turns" if o.get("tier") and left else "")
334
+ return USAGE
335
+ except Exception as exc:
336
+ return f"router unreachable at {BASE_URL}: {exc}"
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # registration
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ def register(ctx: Any) -> None:
345
+ ctx.register_hook("pre_llm_call", on_pre_llm_call)
346
+ ctx.register_middleware("llm_request", on_llm_request)
347
+ ctx.register_middleware("tool_execution", on_tool_execution)
348
+ ctx.register_command("router", router_command, description="auto-model-router: report, summary, status, why, feedback, pin, tier", args_hint="report|summary|status|why|good|bad|pin|tier")
@@ -0,0 +1,5 @@
1
+ name: auto-model-router
2
+ kind: standalone
3
+ version: 1.0.0
4
+ description: auto-model-router native features — session identity, tool-result digest, /router command
5
+ author: drewappling
@@ -8,10 +8,12 @@ export interface DigestPolicy {
8
8
  minBytes: number;
9
9
  maxBytes: number;
10
10
  tools: string[];
11
+ /** Harness tool name → canonical name in `tools`. */
12
+ toolAliases: Record<string, string>;
11
13
  fromTier: string;
12
14
  }
13
15
 
14
- export const DISABLED_POLICY: DigestPolicy = { enabled: false, minBytes: 0, maxBytes: 0, tools: [], fromTier: "hard" };
16
+ export const DISABLED_POLICY: DigestPolicy = { enabled: false, minBytes: 0, maxBytes: 0, tools: [], toolAliases: {}, fromTier: "hard" };
15
17
 
16
18
  /** The text of a tool result's content parts; images are left alone (and block digesting). */
17
19
  export function textOf(content: ReadonlyArray<{ type: string; text?: string }>): { text: string; hasImage: boolean } {
@@ -27,7 +29,8 @@ export function textOf(content: ReadonlyArray<{ type: string; text?: string }>):
27
29
  /** Client-side gate: cheap checks before anything is sent to the router. */
28
30
  export function shouldSend(policy: DigestPolicy, toolName: string, isError: boolean, text: string, hasImage: boolean): boolean {
29
31
  if (!policy.enabled || isError || hasImage) return false;
30
- if (!policy.tools.includes(toolName.toLowerCase())) return false;
32
+ const lower = toolName.toLowerCase();
33
+ if (!policy.tools.includes(policy.toolAliases[lower] ?? lower)) return false;
31
34
  const bytes = Buffer.byteLength(text);
32
35
  return bytes >= policy.minBytes && bytes <= policy.maxBytes;
33
36
  }
@@ -42,6 +45,10 @@ export function parsePolicy(json: unknown): DigestPolicy {
42
45
  minBytes: typeof p.minBytes === "number" ? p.minBytes : 12_000,
43
46
  maxBytes: typeof p.maxBytes === "number" ? p.maxBytes : 400_000,
44
47
  tools: Array.isArray(p.tools) ? p.tools.filter((t): t is string => typeof t === "string").map((t) => t.toLowerCase()) : [],
48
+ toolAliases:
49
+ p.toolAliases !== null && typeof p.toolAliases === "object"
50
+ ? Object.fromEntries(Object.entries(p.toolAliases as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === "string").map(([k, v]) => [k.toLowerCase(), v.toLowerCase()]))
51
+ : {},
45
52
  fromTier: typeof p.fromTier === "string" ? p.fromTier : "hard",
46
53
  };
47
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -309,6 +309,20 @@ export const DEFAULT_CONFIG: RouterConfig = {
309
309
  maxOutputTokens: 700,
310
310
  maxCostUsd: 0.02,
311
311
  timeoutMs: 25_000,
312
+ // Hermes, Cline/Roo/Kilo, Codex and OpenCode spellings of the same tools.
313
+ toolAliases: {
314
+ read_file: "read",
315
+ search_files: "grep",
316
+ list_files: "ls",
317
+ list_dir: "ls",
318
+ list: "ls",
319
+ terminal: "bash",
320
+ execute_command: "bash",
321
+ execute_code: "bash",
322
+ shell: "bash",
323
+ web_extract: "web_fetch",
324
+ fetch_url: "web_fetch",
325
+ },
312
326
  },
313
327
  report: {
314
328
  // The frontier pair most omp users would otherwise run on.
@@ -296,6 +296,7 @@ export const configInputSchema = z.strictObject({
296
296
  maxOutputTokens: z.number().int().positive().optional(),
297
297
  maxCostUsd: z.number().nonnegative().optional(),
298
298
  timeoutMs: z.number().int().positive().optional(),
299
+ toolAliases: z.record(z.string(), z.string()).optional(),
299
300
  })
300
301
  .optional(),
301
302
  ledger: ledger.optional(),
@@ -587,6 +587,14 @@ export interface DigestConfig {
587
587
  /** Skip when the digest itself would cost more than this, USD. */
588
588
  maxCostUsd: number;
589
589
  timeoutMs: number;
590
+ /**
591
+ * Harness tool names → the canonical names `tools` lists (read, grep,
592
+ * glob, bash, ls, web_fetch, …). Hermes calls its reader `read_file`,
593
+ * Cline `execute_command`, OpenCode `webfetch`; the alias table lets one
594
+ * `tools` list serve every harness. Lower-case keys; unknown names pass
595
+ * through unchanged.
596
+ */
597
+ toolAliases: Record<string, string>;
590
598
  }
591
599
 
592
600
  /** Usage-report options. */
@@ -67,10 +67,16 @@ const DIGEST_SYSTEM = `You condense tool output for a coding agent that is mid-t
67
67
  const tierIdx = (t: string): number => TIER_ORDER.indexOf(t as Tier);
68
68
 
69
69
  /** Whether a session's current model is expensive enough for a digest to pay off. */
70
+ /** The canonical tool name a harness-specific one maps to (`digest.toolAliases`); lower-cased. */
71
+ export function canonicalTool(cfg: Pick<DigestConfig, "toolAliases">, toolName: string): string {
72
+ const lower = toolName.toLowerCase();
73
+ return cfg.toolAliases[lower] ?? lower;
74
+ }
75
+
70
76
  export function digestApplies(cfg: DigestConfig, toolName: string, bytes: number, isError: boolean, currentTier: string | null): { ok: true } | { ok: false; reason: string } {
71
77
  if (!cfg.enabled) return { ok: false, reason: "digest disabled" };
72
78
  if (isError) return { ok: false, reason: "error results are never digested" };
73
- if (!cfg.tools.includes(toolName.toLowerCase())) return { ok: false, reason: `tool ${toolName} not in digest.tools` };
79
+ if (!cfg.tools.includes(canonicalTool(cfg, toolName))) return { ok: false, reason: `tool ${toolName} not in digest.tools` };
74
80
  if (bytes < cfg.minBytes) return { ok: false, reason: `${bytes} bytes < minBytes ${cfg.minBytes}` };
75
81
  if (bytes > cfg.maxBytes) return { ok: false, reason: `${bytes} bytes > maxBytes ${cfg.maxBytes}` };
76
82
  if (currentTier === null) return { ok: false, reason: "no routed turn in this session yet" };
@@ -9,8 +9,8 @@ import { createSessionOverrides } from "./overrides.ts";
9
9
  import { createDigester } from "./digest.ts";
10
10
  import { advise } from "./advise.ts";
11
11
  import { TIER_ORDER, type Tier } from "../router/types.ts";
12
- import { baselinePrices, buildUsageReport } from "../cost/report.ts";
13
- import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
12
+ import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
13
+ import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
14
14
  import type { Ledger, ModelTrust } from "../cost/types.ts";
15
15
  import { createRouter } from "../router/index.ts";
16
16
  import { createConversationStore } from "../router/state.ts";
@@ -414,7 +414,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
414
414
  const parsedDays = rawDays === null ? 7 : Number.parseInt(rawDays, 10);
415
415
  const windowDays = Number.isInteger(parsedDays) ? Math.min(Math.max(parsedDays, 1), 365) : 7;
416
416
  const harnessId = url.searchParams.get("harness") ?? "";
417
- return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
417
+ const report = buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) });
418
+ // ?format=text: the rendered report for harnesses without a renderer of their own (the Hermes plugin).
419
+ if (url.searchParams.get("format") === "text") return new Response(renderUsageReport(report), { headers: { "content-type": "text/plain; charset=utf-8" } });
420
+ return json(report);
418
421
  }
419
422
  if (req.method === "GET" && url.pathname === "/v1/router/advise/policy") {
420
423
  const h = cfg.harnessSwitch;
@@ -456,6 +459,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
456
459
  });
457
460
  if (auto && !summaryHasNews(summary)) return json({ due: false, reason: "nothing to report", summary: null });
458
461
  if (auto) markSummaryShown(kv, harnessId);
462
+ if (url.searchParams.get("format") === "text") return new Response(renderDailySummary(summary), { headers: { "content-type": "text/plain; charset=utf-8" } });
459
463
  return json({ due: true, summary });
460
464
  }
461
465
  if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
@@ -500,7 +504,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
500
504
  }
501
505
  if (req.method === "GET" && url.pathname === "/v1/router/digest/policy") {
502
506
  const d = cfg.digest;
503
- return json({ enabled: d.enabled, minBytes: d.minBytes, maxBytes: d.maxBytes, tools: d.tools, fromTier: d.fromTier });
507
+ return json({ enabled: d.enabled, minBytes: d.minBytes, maxBytes: d.maxBytes, tools: d.tools, toolAliases: d.toolAliases, fromTier: d.fromTier });
504
508
  }
505
509
  if (req.method === "POST" && url.pathname === "/v1/router/digest") {
506
510
  const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
@@ -213,6 +213,9 @@ function applyCompaction(messages: Record<string, unknown>[], edits: readonly Co
213
213
  }
214
214
  }
215
215
 
216
+ /** Request parameters that exist only on OpenAI's own platform; dropped before dispatch. */
217
+ export const OPENAI_ONLY_PARAMS: readonly string[] = ["store", "prompt_cache_key", "safety_identifier", "service_tier", "metadata", "web_search_options"];
218
+
216
219
  function renderUpstreamBody(
217
220
  original: Record<string, unknown>,
218
221
  m: UpstreamMutations,
@@ -227,6 +230,10 @@ function renderUpstreamBody(
227
230
  body.stream = true;
228
231
  // OpenRouter returns usage unconditionally and the parameter is deprecated.
229
232
  delete body.stream_options;
233
+ // OpenAI-platform-only parameters other harnesses send (Codex, Aider,
234
+ // Cline): storage, cache keys, tiers and abuse ids mean nothing upstream
235
+ // and some providers reject unknown fields.
236
+ for (const key of OPENAI_ONLY_PARAMS) delete body[key];
230
237
 
231
238
  if (m.maxTokens !== undefined) {
232
239
  // Respect whichever max-token spelling the client used.
@@ -142,7 +142,7 @@ describe("validateField", () => {
142
142
 
143
143
  describe("WIZARD_SECTIONS coverage", () => {
144
144
  /** Leaves that are edited as whole records/arrays rather than fields. */
145
- const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "profiles"]);
145
+ const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles"]);
146
146
 
147
147
  function leaves(obj: unknown, prefix = ""): string[] {
148
148
  if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return [prefix];
@@ -78,7 +78,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [], dailySummary: false },
80
80
  harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
81
- digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
81
+ digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000, toolAliases: {} },
82
82
  profiles: [],
83
83
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
84
84
  adaptiveTierFloors: true,
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import { canonicalTool, digestApplies } from "../src/server/digest.ts";
5
+ import { OPENAI_ONLY_PARAMS, parseChatRequest } from "../src/wire/openai/request.ts";
6
+ import type { UpstreamMutations } from "../src/wire/types.ts";
7
+ import { parsePolicy, shouldSend } from "../omp-extension/digest-logic.ts";
8
+
9
+ /**
10
+ * Request shapes the config-only harnesses send to an OpenAI-compatible
11
+ * endpoint, as each harness documents them: the router must parse them,
12
+ * keep their tool calls and headers, and drop the OpenAI-platform-only
13
+ * parameters before dispatch. These are representative bodies, not captured
14
+ * traffic; a harness release that changes its shape belongs here as a new case.
15
+ */
16
+
17
+ const MUT: UpstreamMutations = { slug: "x/y", fallbacks: [], sessionId: "s", cacheBreakpointMessageIndices: [], reasoning: undefined, maxTokens: undefined, stripAssistantReasoning: false };
18
+
19
+ const TOOL = (name: string) => ({ type: "function", function: { name, description: name, parameters: { type: "object", properties: { path: { type: "string" } } } } });
20
+
21
+ const HARNESSES: Record<string, { headers: Record<string, string>; body: Record<string, unknown>; toolCall?: string }> = {
22
+ codex: {
23
+ headers: { "X-Omp-Harness": "codex" },
24
+ body: {
25
+ model: "auto",
26
+ messages: [
27
+ { role: "developer", content: "You are Codex." },
28
+ { role: "user", content: "fix the failing test" },
29
+ { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "shell", arguments: '{"command":["cat","x.ts"]}' } }] },
30
+ { role: "tool", tool_call_id: "call_1", content: "export const x = 1;" },
31
+ ],
32
+ tools: [TOOL("shell"), TOOL("apply_patch")],
33
+ stream: true,
34
+ store: false,
35
+ prompt_cache_key: "session-abc",
36
+ reasoning_effort: "medium",
37
+ parallel_tool_calls: false,
38
+ },
39
+ toolCall: "shell",
40
+ },
41
+ aider: {
42
+ headers: { "X-Omp-Harness": "aider" },
43
+ body: { model: "auto", messages: [{ role: "system", content: "Act as an expert software developer." }, { role: "user", content: "add a retry helper" }], stream: true, temperature: 0, extra_body: {} },
44
+ },
45
+ cline: {
46
+ headers: { "X-Omp-Harness": "cline" },
47
+ body: {
48
+ model: "auto",
49
+ messages: [
50
+ { role: "system", content: "You are Cline." },
51
+ { role: "user", content: [{ type: "text", text: "<task>rename the helper</task>" }] },
52
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read_file", arguments: '{"path":"src/a.ts"}' } }] },
53
+ { role: "tool", tool_call_id: "c1", content: [{ type: "text", text: "line 1\nline 2" }] },
54
+ ],
55
+ tools: [TOOL("read_file"), TOOL("execute_command"), TOOL("search_files")],
56
+ stream: true,
57
+ stream_options: { include_usage: true },
58
+ temperature: 0,
59
+ },
60
+ toolCall: "read_file",
61
+ },
62
+ opencode: {
63
+ headers: { "X-Omp-Harness": "opencode" },
64
+ body: {
65
+ model: "auto",
66
+ messages: [{ role: "system", content: "opencode" }, { role: "user", content: "list the tests" }],
67
+ tools: [TOOL("read"), TOOL("bash"), TOOL("glob"), TOOL("webfetch")],
68
+ stream: true,
69
+ stream_options: { include_usage: true },
70
+ max_tokens: 8192,
71
+ service_tier: "auto",
72
+ },
73
+ },
74
+ hermes: {
75
+ headers: { "X-Omp-Harness": "hermes", "X-Omp-Session": "hermes-session-1", "X-Omp-Subagent": "1" },
76
+ body: {
77
+ model: "auto",
78
+ messages: [
79
+ { role: "system", content: "You are Hermes." },
80
+ { role: "user", content: "summarise the repo" },
81
+ { role: "assistant", content: null, tool_calls: [{ id: "h1", type: "function", function: { name: "read_file", arguments: '{"path":"README.md"}' } }] },
82
+ { role: "tool", tool_call_id: "h1", content: '{"content":"# repo","path":"README.md"}' },
83
+ ],
84
+ tools: [TOOL("read_file"), TOOL("terminal"), TOOL("search_files"), TOOL("delegate_task")],
85
+ stream: true,
86
+ max_tokens: 4096,
87
+ },
88
+ toolCall: "read_file",
89
+ },
90
+ };
91
+
92
+ describe("config-only harness request shapes", () => {
93
+ for (const [name, h] of Object.entries(HARNESSES)) {
94
+ test(`${name}: parses, keeps headers and tool calls, and drops OpenAI-only parameters`, () => {
95
+ const req = parseChatRequest(structuredClone(h.body), new Headers(h.headers));
96
+ expect(req.harnessId).toBe(name);
97
+ expect(req.requestedModel).toBe("auto");
98
+ expect(req.messages.length).toBe((h.body.messages as unknown[]).length);
99
+ if (h.toolCall !== undefined) {
100
+ const assistant = req.messages.find((m) => m.role === "assistant");
101
+ expect(assistant?.toolCalls[0]?.name).toBe(h.toolCall);
102
+ expect(req.messages.find((m) => m.role === "tool")?.toolCallId).toBeDefined();
103
+ }
104
+ const out = req.renderUpstreamBody(MUT);
105
+ for (const key of OPENAI_ONLY_PARAMS) expect(key in out).toBe(false);
106
+ expect("stream_options" in out).toBe(false);
107
+ expect(out.model).toBe("x/y");
108
+ expect(out.stream).toBe(true);
109
+ // Parameters every provider understands survive.
110
+ if ("temperature" in h.body) expect(out.temperature).toBe(h.body.temperature);
111
+ if ("tools" in h.body) expect((out.tools as unknown[]).length).toBe((h.body.tools as unknown[]).length);
112
+ });
113
+ }
114
+
115
+ test("hermes headers carry session and subagent identity", () => {
116
+ const req = parseChatRequest(structuredClone(HARNESSES.hermes!.body), new Headers(HARNESSES.hermes!.headers));
117
+ expect(req.ompSessionId).toBe("hermes-session-1");
118
+ expect(req.isSubagent).toBe(true);
119
+ });
120
+ });
121
+
122
+ describe("digest tool aliases across harnesses", () => {
123
+ const d = { ...DEFAULT_CONFIG.digest, enabled: true, minBytes: 10, maxBytes: 10_000 };
124
+ test("harness spellings map onto the canonical tools list on both sides", () => {
125
+ for (const [alias, canonical] of [["read_file", "read"], ["search_files", "grep"], ["terminal", "bash"], ["execute_command", "bash"], ["shell", "bash"], ["list_files", "ls"], ["web_extract", "web_fetch"], ["READ_FILE", "read"]] as const) {
126
+ expect(canonicalTool(d, alias)).toBe(canonical);
127
+ expect(digestApplies(d, alias, 500, false, "hard").ok).toBe(true);
128
+ }
129
+ expect(canonicalTool(d, "write_file")).toBe("write_file");
130
+ expect(digestApplies(d, "write_file", 500, false, "hard").ok).toBe(false);
131
+ // The policy the router publishes carries the aliases, and the client gate honours them.
132
+ const policy = parsePolicy({ enabled: true, minBytes: 10, maxBytes: 10_000, tools: d.tools, toolAliases: d.toolAliases, fromTier: "moderate" });
133
+ expect(shouldSend(policy, "search_files", false, "x".repeat(100), false)).toBe(true);
134
+ expect(shouldSend(policy, "delegate_task", false, "x".repeat(100), false)).toBe(false);
135
+ expect(parsePolicy({ enabled: true }).toolAliases).toEqual({});
136
+ });
137
+ });
package/test/turn.test.ts CHANGED
@@ -78,7 +78,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [], dailySummary: false },
80
80
  harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
81
- digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
81
+ digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000, toolAliases: {} },
82
82
  profiles: [],
83
83
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
84
84
  adaptiveTierFloors: true,