auto-model-router 0.4.4 → 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.4",
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.4",
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. |
@@ -837,6 +939,14 @@ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
837
939
  | `baselines` | `anthropic/claude-opus-5`, `anthropic/claude-sonnet-5` | Models the report prices the window's traffic on as a single-model counterfactual. Unknown slugs are skipped. |
838
940
  | `dailySummary` | `true` | Post the daily summary (below) into the transcript at the first interactive omp session start of each day. Hot-reloads. |
839
941
 
942
+ ### `harnessSwitch` — harness-side model switch (experimental)
943
+
944
+ | Key | Default | Meaning |
945
+ | --- | --- | --- |
946
+ | `enabled` | `false` | Let the `router-switch` extension move omp's active model for mapped tiers. |
947
+ | `models` | `{}` | Tier → harness model as `provider/id` in omp's own registry, e.g. `hard: anthropic/claude-opus-4-8`. A tier serves itself and every tier above it up to the next mapped one; unmapped tiers stay on the router. |
948
+ | `minConfidence` | `0.6` | Advice below this heuristic confidence leaves the model where it is. |
949
+
840
950
  ### `ledger` — cost measurement
841
951
 
842
952
  | Key | Default | Meaning |
@@ -929,8 +1039,50 @@ router handles for you: no `models[]` fallback cascade, no `tool_choice`,
929
1039
  `reasoning_effort` instead of the `reasoning` object, and no `cache_control`
930
1040
  markers (they are stripped before dispatch).
931
1041
 
1042
+ ## Harness-side model switch (experimental)
1043
+
1044
+ Most engineers reach Claude through a subscription, not an API key, and a
1045
+ subscription model cannot be proxied: the router would have to translate to
1046
+ Anthropic's wire format and carry omp's OAuth token through a third-party
1047
+ process. The `router-switch` extension takes the other route. Before omp
1048
+ starts a turn on a user prompt it asks the router which tier the prompt is
1049
+ (`POST /v1/router/advise`, the heuristic classifier over the prompt text,
1050
+ nothing dispatched or recorded). When that tier is mapped in
1051
+ `harnessSwitch.models`, the extension moves omp's active model to the mapped
1052
+ harness model; when a later prompt is advised below every mapped tier, it
1053
+ moves back to the router model it left. A model the user picked by hand is
1054
+ never touched. Native turns bill the subscription and never reach the
1055
+ ledger; the router serves and accounts for the rest.
1056
+
1057
+ ```yaml
1058
+ # ~/.auto-model-router/config.yml
1059
+ harnessSwitch:
1060
+ enabled: true
1061
+ models:
1062
+ hard: anthropic/claude-opus-4-8
1063
+ ```
1064
+
1065
+ Install `omp-extension/router-switch.ts` beside the embed extension and
1066
+ restart omp. Known limits of the prototype: the advice sees only the prompt
1067
+ text, not the conversation, so a hard task that only becomes hard three tool
1068
+ calls in stays on the router (the router's own escalation still applies
1069
+ there); and the switch happens at prompt boundaries, never mid-turn.
1070
+
932
1071
  ## Multiple coding harnesses, one router
933
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
+
934
1086
  A single embedded router can serve several omp sessions without them stepping
935
1087
  on each other:
936
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
  }
@@ -109,6 +109,12 @@ declare module "@oh-my-pi/pi-coding-agent" {
109
109
  /** Interval whose errors omp isolates, and whose handle `clearTimer` cancels. */
110
110
  setInterval(handler: () => void | Promise<void>, ms: number): unknown;
111
111
  clearTimer(timer: unknown): void;
112
+ /** The active model, when one is set. (Real type: `Model`.) */
113
+ model: { provider: string; id: string } | undefined;
114
+ /** Looks a model up in omp's registry by provider and id. */
115
+ modelRegistry: { find(provider: string, modelId: string): unknown };
116
+ /** Sets the session's active model; false when omp has no key for it. */
117
+ setModel(model: unknown): Promise<boolean>;
112
118
  }
113
119
 
114
120
  export interface CommandDefinition {