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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +115 -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/package.json +1 -1
- package/src/config/defaults.ts +14 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +8 -0
- package/src/server/digest.ts +7 -1
- package/src/server/http.ts +8 -4
- package/src/wire/openai/request.ts +7 -0
- package/test/config-wizard.test.ts +1 -1
- package/test/failover.test.ts +1 -1
- package/test/harness-requests.test.ts +137 -0
- package/test/turn.test.ts +1 -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. |
|
|
@@ -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")
|
|
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
|
}
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -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.
|
package/src/config/schema.ts
CHANGED
|
@@ -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(),
|
package/src/config/types.ts
CHANGED
|
@@ -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. */
|
package/src/server/digest.ts
CHANGED
|
@@ -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
|
|
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" };
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
|
|
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];
|
package/test/failover.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,
|
|
@@ -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,
|