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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +152 -0
- package/hermes-plugin/__init__.py +3 -0
- package/hermes-plugin/native/__init__.py +348 -0
- package/hermes-plugin/native/__pycache__/__init__.cpython-311.pyc +0 -0
- package/hermes-plugin/native/plugin.yaml +5 -0
- package/omp-extension/digest-logic.ts +9 -2
- package/omp-extension/pi-coding-agent.d.ts +6 -0
- package/omp-extension/router-switch.ts +100 -0
- package/omp-extension/switch-logic.ts +81 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +8 -0
- package/src/config/defaults.ts +20 -0
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +28 -0
- package/src/server/advise.ts +82 -0
- package/src/server/digest.ts +7 -1
- package/src/server/http.ts +28 -4
- package/src/wire/openai/request.ts +7 -0
- package/test/config-wizard.test.ts +2 -1
- package/test/failover.test.ts +2 -1
- package/test/harness-requests.test.ts +137 -0
- package/test/harness-switch.test.ts +59 -0
- package/test/turn.test.ts +2 -1
|
@@ -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.
|
|
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.
|
|
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")
|
|
Binary file
|
|
@@ -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
|
-
|
|
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 {
|