auto-model-router 0.4.7 → 0.4.9
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 +74 -14
- package/hermes-plugin/native/__pycache__/__init__.cpython-311.pyc +0 -0
- package/hermes-plugin/native/selftest.py +103 -0
- package/opencode-plugin/auto-model-router.ts +151 -0
- package/opencode-plugin/opencode-plugin.d.ts +39 -0
- package/package.json +1 -1
- package/src/cost/ledger.ts +6 -1
- package/src/cost/summary.ts +3 -1
- package/src/server/http.ts +22 -7
- package/src/util/sse.ts +1 -1
- package/src/wire/openai/responses.ts +392 -0
- package/src/wire/openai/sink.ts +19 -2
- package/src/wire/types.ts +1 -1
- package/test/fixtures/harness/aider.json +47 -0
- package/test/fixtures/harness/codex-responses.json +507 -0
- package/test/fixtures/harness/opencode.json +338 -0
- package/test/harness-requests.test.ts +58 -24
- package/test/hermes-plugin.test.ts +32 -0
- package/test/summary.test.ts +1 -1
- package/test/tokens.test.ts +2 -0
- package/test/wire-responses.test.ts +188 -0
- package/test/wire-sink.test.ts +5 -0
- package/tools/capture-proxy.ts +79 -0
- package/tsconfig.all.json +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.9",
|
|
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.9",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -410,8 +410,12 @@ user-visible notice channel; use `/router why`), the automatic daily summary
|
|
|
410
410
|
|
|
411
411
|
### Codex CLI
|
|
412
412
|
|
|
413
|
-
Codex
|
|
414
|
-
|
|
413
|
+
Codex (0.150 and later) speaks only the Responses API, which the router
|
|
414
|
+
serves at `POST /v1/responses`: the body is translated to the chat shape
|
|
415
|
+
the router routes on (`instructions` → system, `input` items → messages,
|
|
416
|
+
function calls and outputs → tool calls and tool messages) and the upstream
|
|
417
|
+
stream is rendered back as Responses events. Run the router
|
|
418
|
+
(`auto-model-router serve --port 8788`) and add a provider:
|
|
415
419
|
|
|
416
420
|
```toml
|
|
417
421
|
# ~/.codex/config.toml
|
|
@@ -421,13 +425,19 @@ model_provider = "auto-model-router"
|
|
|
421
425
|
[model_providers.auto-model-router]
|
|
422
426
|
name = "auto-model-router"
|
|
423
427
|
base_url = "http://127.0.0.1:8788/v1"
|
|
424
|
-
|
|
428
|
+
env_key = "AUTO_MODEL_ROUTER_API_KEY" # any value; the router is keyless
|
|
429
|
+
wire_api = "responses"
|
|
425
430
|
http_headers = { "X-Omp-Harness" = "codex" }
|
|
426
431
|
```
|
|
427
432
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
433
|
+
Verified live with codex 0.153, text and tool-call turns: the captured
|
|
434
|
+
request is `test/fixtures/harness/codex-responses.json`. Stateless only —
|
|
435
|
+
Codex sends `store: false` and the full input each turn; `previous_response_id`
|
|
436
|
+
is rejected. Reasoning summaries and encrypted reasoning are not produced.
|
|
437
|
+
Codex's thread id (sent in the body) becomes the session id and its agent
|
|
438
|
+
name marks subagents, so per-session reports, feedback over the HTTP API and
|
|
439
|
+
the subagent profile work without a plugin. No hooks: there is no toast,
|
|
440
|
+
digest or `/router`.
|
|
431
441
|
|
|
432
442
|
### Aider
|
|
433
443
|
|
|
@@ -437,8 +447,20 @@ export OPENAI_API_KEY=local
|
|
|
437
447
|
aider --model openai/auto
|
|
438
448
|
```
|
|
439
449
|
|
|
440
|
-
|
|
441
|
-
|
|
450
|
+
Verified live with aider 0.86 (captured request:
|
|
451
|
+
`test/fixtures/harness/aider.json`). Aider sends no tool calls, so every turn
|
|
452
|
+
classifies on its text alone. It sends no custom headers by default; a model
|
|
453
|
+
settings file in the project adds the harness id (verified live):
|
|
454
|
+
|
|
455
|
+
```yaml
|
|
456
|
+
# .aider.model.settings.yml
|
|
457
|
+
- name: openai/auto
|
|
458
|
+
extra_params:
|
|
459
|
+
extra_headers:
|
|
460
|
+
X-Omp-Harness: aider
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
No session id or hooks.
|
|
442
464
|
|
|
443
465
|
### Cline, Roo Code, Kilo Code
|
|
444
466
|
|
|
@@ -468,9 +490,28 @@ digest applies only through summarising compaction
|
|
|
468
490
|
}
|
|
469
491
|
```
|
|
470
492
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
493
|
+
Verified live with opencode 1.18 (captured request:
|
|
494
|
+
`test/fixtures/harness/opencode.json`). OpenCode's AI SDK validates every
|
|
495
|
+
SSE frame, which is why the router's final summary frame is shaped as a
|
|
496
|
+
chunk with no choices. Its tool names (`read`, `grep`, `glob`, `bash`,
|
|
497
|
+
`webfetch`) match the router's canonical list.
|
|
498
|
+
|
|
499
|
+
**Native features (OpenCode plugin API).** Copy
|
|
500
|
+
`opencode-plugin/auto-model-router.ts` to `~/.config/opencode/plugin/` (or a
|
|
501
|
+
project's `.opencode/plugin/`); OpenCode loads it on start. It adds:
|
|
502
|
+
|
|
503
|
+
- **Session identity** — `X-Omp-Session`, `X-Omp-Harness` (`opencode`, or
|
|
504
|
+
`OMP_HARNESS_ID`) and `X-Omp-Subagent` for sessions with a parent, through
|
|
505
|
+
the `chat.headers` hook.
|
|
506
|
+
- **Routing toast** — when a session goes idle, its last routed turn's
|
|
507
|
+
provider, model, tier and cost appear as a TUI toast.
|
|
508
|
+
- **Tool-result digest** — large `read`, `grep`, `glob`, `bash` and
|
|
509
|
+
`webfetch` results go to `/v1/router/digest` through `tool.execute.after`
|
|
510
|
+
and the model gets the digest. Off unless `digest.enabled`.
|
|
511
|
+
|
|
512
|
+
No `/router` command (OpenCode commands are markdown files, not plugin
|
|
513
|
+
hooks): use `auto-model-router report` on the terminal, or the router's
|
|
514
|
+
HTTP endpoints.
|
|
474
515
|
|
|
475
516
|
### The OpenRouter key
|
|
476
517
|
|
|
@@ -1070,6 +1111,25 @@ there); and the switch happens at prompt boundaries, never mid-turn.
|
|
|
1070
1111
|
|
|
1071
1112
|
## Multiple coding harnesses, one router
|
|
1072
1113
|
|
|
1114
|
+
**One router process for everything.** omp's embed extension binds a private
|
|
1115
|
+
router on an ephemeral port per session by default, while Hermes, Codex,
|
|
1116
|
+
OpenCode and Aider talk to a standalone router on port 8788. Those are two
|
|
1117
|
+
processes over two homes, and per-process state (pins, the digest re-run
|
|
1118
|
+
memory) and reports stay apart. To share one router:
|
|
1119
|
+
|
|
1120
|
+
1. Run it once, before the harnesses start: `auto-model-router serve --port 8788`
|
|
1121
|
+
(against the default home, `~/.auto-model-router`).
|
|
1122
|
+
2. Set `AUTO_MODEL_ROUTER_PORT=8788` in omp's environment. The embed then
|
|
1123
|
+
attaches to the router already answering on that port instead of binding
|
|
1124
|
+
its own (it still binds 8788 itself if nothing is there, which the others
|
|
1125
|
+
then reuse).
|
|
1126
|
+
3. Point Hermes, Codex, OpenCode and Aider at `http://127.0.0.1:8788/v1` as
|
|
1127
|
+
in their recipes. Hermes's provider plugin only spawns a router when
|
|
1128
|
+
nothing listens on 8788, so it joins the shared one too.
|
|
1129
|
+
|
|
1130
|
+
Each harness keeps its own `X-Omp-Harness` id, so budgets and reports stay
|
|
1131
|
+
per harness while the ledger, catalog and conversation state are shared.
|
|
1132
|
+
|
|
1073
1133
|
What each harness gets today. "Config only" means the OpenAI-compatible wire
|
|
1074
1134
|
plus a harness header; the rest needs the harness's own hook API.
|
|
1075
1135
|
|
|
@@ -1077,10 +1137,10 @@ plus a harness header; the rest needs the harness's own hook API.
|
|
|
1077
1137
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
1078
1138
|
| omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
|
|
1079
1139
|
| Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
|
|
1080
|
-
| Codex CLI |
|
|
1081
|
-
| Aider | config only |
|
|
1140
|
+
| Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
|
|
1141
|
+
| Aider | config only | via model settings | no | no | no | no | no tools | no | no |
|
|
1082
1142
|
| Cline / Roo / Kilo | config only | if headers supported | no | no | no | no | compaction only | no | no |
|
|
1083
|
-
| OpenCode | config
|
|
1143
|
+
| OpenCode | config + plugin | yes | yes (plugin) | yes (plugin) | yes (plugin) | no | yes (plugin) | no | no |
|
|
1084
1144
|
| Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
|
|
1085
1145
|
|
|
1086
1146
|
A single embedded router can serve several omp sessions without them stepping
|
|
Binary file
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Self-test for the Hermes native plugin's pure functions.
|
|
2
|
+
|
|
3
|
+
Run by test/hermes-plugin.test.ts when a Python interpreter is on PATH (the
|
|
4
|
+
Bun suite cannot import Python), and directly:
|
|
5
|
+
|
|
6
|
+
python hermes-plugin/native/selftest.py
|
|
7
|
+
|
|
8
|
+
Exercises identity headers, the digest gate and replacement, and the /router
|
|
9
|
+
command rendering against fake transports. Exits non-zero on the first
|
|
10
|
+
failure with the assertion that failed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
+
spec = importlib.util.spec_from_file_location("amr_native", os.path.join(HERE, "__init__.py"))
|
|
22
|
+
m = importlib.util.module_from_spec(spec)
|
|
23
|
+
assert spec.loader is not None
|
|
24
|
+
spec.loader.exec_module(m)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_identity_headers() -> None:
|
|
28
|
+
m.on_pre_llm_call(session_id="s1", parent_session_id="")
|
|
29
|
+
m.on_pre_llm_call(session_id="s2", parent_session_id="s1")
|
|
30
|
+
r = m.on_llm_request(request={"model": "auto", "extra_headers": {"A": "b"}}, provider="auto-model-router", session_id="s2")
|
|
31
|
+
assert r["request"]["extra_headers"] == {"A": "b", "X-Omp-Harness": m.HARNESS_ID, "X-Omp-Session": "s2", "X-Omp-Subagent": "1"}, r
|
|
32
|
+
main = m.on_llm_request(request={"model": "auto"}, provider="auto-model-router", session_id="s1")
|
|
33
|
+
assert "X-Omp-Subagent" not in main["request"]["extra_headers"], main
|
|
34
|
+
assert m.on_llm_request(request={}, provider="anthropic", session_id="s1") is None
|
|
35
|
+
assert m._CURRENT["session_id"] == "s1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_digest() -> None:
|
|
39
|
+
m.POLICY.value = {"enabled": True, "minBytes": 10, "maxBytes": 100000, "tools": ["read", "grep", "bash"], "toolAliases": {"read_file": "read", "terminal": "bash"}, "fromTier": "moderate"}
|
|
40
|
+
m.POLICY._at = 10**12
|
|
41
|
+
calls = []
|
|
42
|
+
|
|
43
|
+
def fake_post(path, payload, timeout=0):
|
|
44
|
+
calls.append((path, payload["toolName"], len(payload["content"])))
|
|
45
|
+
return {"digested": True, "text": "[digest] short"}
|
|
46
|
+
|
|
47
|
+
big = json.dumps({"content": "x" * 500, "path": "a.ts"})
|
|
48
|
+
out = m.maybe_digest(big, "read_file", {"path": "a.ts"}, "s1", post=fake_post)
|
|
49
|
+
assert json.loads(out)["content"] == "[digest] short", out
|
|
50
|
+
assert json.loads(out)["path"] == "a.ts"
|
|
51
|
+
assert calls == [("/v1/router/digest", "read_file", 500)], calls
|
|
52
|
+
err = json.dumps({"error": "nope", "content": "x" * 500})
|
|
53
|
+
assert m.maybe_digest(err, "read_file", {}, "s1", post=fake_post) == err
|
|
54
|
+
assert m.maybe_digest(json.dumps({"content": "x" * 500}), "write_file", {}, "s1", post=fake_post).startswith('{"content": "xxx')
|
|
55
|
+
plain = "plain text " * 100
|
|
56
|
+
assert m.maybe_digest(plain, "read_file", {}, "s1", post=fake_post) == plain
|
|
57
|
+
assert len(calls) == 1
|
|
58
|
+
assert m.canonical_tool(m.POLICY.value, "TERMINAL") == "bash"
|
|
59
|
+
assert m.largest_string_field({"a": "x" * 90, "b": "y" * 5}) == "a"
|
|
60
|
+
assert m.largest_string_field({"a": "x" * 50, "b": "y" * 50}) is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_router_command() -> None:
|
|
64
|
+
def fake_get(path, timeout=5.0, text=False):
|
|
65
|
+
if path.startswith("/v1/router/report"):
|
|
66
|
+
return "REPORT " + path
|
|
67
|
+
if path.startswith("/v1/router/summary"):
|
|
68
|
+
return "SUMMARY " + path
|
|
69
|
+
if path.startswith("/health"):
|
|
70
|
+
return {"status": "ok", "apiKeyConfigured": True, "apiKeySource": "env", "catalog": {"models": 3}, "ollama": None, "softFailures": {"spikes": [{"slug": "a/b", "recentRate": 0.5, "recentDispatches": 8, "baselineRate": 0.05}]}}
|
|
71
|
+
if path.startswith("/v1/router/decisions"):
|
|
72
|
+
return {"entries": [{"slug": "a/b", "tier": "hard", "classificationSource": "heuristic", "confidence": 0.7, "reportedUsd": 0.0000097, "usage": {"promptTokens": 100, "cachedTokens": 50}, "latencyMs": 900, "reasons": ["r1"]}]}
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
def fake_post(path, payload, timeout=5.0):
|
|
76
|
+
if path == "/v1/router/feedback":
|
|
77
|
+
return {"slug": "a/b", "tier": "hard"}
|
|
78
|
+
return {"override": {"slug": payload.get("slug"), "tier": payload.get("tier"), "turnsLeft": payload.get("turns")}}
|
|
79
|
+
|
|
80
|
+
assert m.router_command("report 30 --all", get=fake_get, post=fake_post) == "REPORT /v1/router/report?days=30&format=text"
|
|
81
|
+
assert m.router_command("report", get=fake_get, post=fake_post).endswith(f"days=7&format=text&harness={m.HARNESS_ID}")
|
|
82
|
+
assert m.router_command("summary", get=fake_get, post=fake_post).startswith("SUMMARY /v1/router/summary?format=text&harness=")
|
|
83
|
+
status = m.router_command("status", get=fake_get, post=fake_post)
|
|
84
|
+
assert "soft failures SPIKING (1)" in status and "a/b: 50% of 8" in status, status
|
|
85
|
+
why = m.router_command("why", get=fake_get, post=fake_post)
|
|
86
|
+
assert "last turn: a/b [hard]" in why and "$0.00001" in why and "cache hit 50%" in why, why
|
|
87
|
+
assert m.router_command("good nice", get=fake_get, post=fake_post) == "recorded good for a/b [hard]"
|
|
88
|
+
assert m.router_command("pin a/b", get=fake_get, post=fake_post) == "pin: a/b"
|
|
89
|
+
assert m.router_command("tier hard 5", get=fake_get, post=fake_post) == "tier: hard for 5 turns"
|
|
90
|
+
assert m.router_command("tier off", get=fake_get, post=fake_post) == "tier: cleared"
|
|
91
|
+
assert m.router_command("bogus", get=fake_get, post=fake_post) == m.USAGE
|
|
92
|
+
|
|
93
|
+
def down(path, timeout=5.0, text=False):
|
|
94
|
+
raise OSError("connection refused")
|
|
95
|
+
|
|
96
|
+
assert m.router_command("status", get=down, post=fake_post).startswith("router unreachable at")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
for name, fn in [(n, f) for n, f in globals().items() if n.startswith("test_")]:
|
|
101
|
+
fn()
|
|
102
|
+
print(f"ok {name}")
|
|
103
|
+
sys.exit(0)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode plugin: auto-model-router native features.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode reaches the router through an OpenAI-compatible provider block
|
|
5
|
+
* (see README, "OpenCode"). This plugin adds what the provider block cannot:
|
|
6
|
+
*
|
|
7
|
+
* - **Session identity** — `chat.headers` adds `X-Omp-Session`,
|
|
8
|
+
* `X-Omp-Harness` and, for a session with a parent (a subagent),
|
|
9
|
+
* `X-Omp-Subagent`, so per-session reports, feedback and the router's
|
|
10
|
+
* subagent profile work as in omp.
|
|
11
|
+
* - **Routing toast** — when a session goes idle, the model, tier and cost of
|
|
12
|
+
* its last routed turn are shown as a TUI toast (nothing in `run` mode).
|
|
13
|
+
* - **Tool-result digest** — `tool.execute.after` sends a large read, grep,
|
|
14
|
+
* glob, bash or webfetch result to the router's `/v1/router/digest` and
|
|
15
|
+
* hands the model the digest instead. The router decides (policy, session
|
|
16
|
+
* tier, cost guard). Off unless `digest.enabled` is set in the router config.
|
|
17
|
+
*
|
|
18
|
+
* Install: copy this file to `~/.config/opencode/plugin/auto-model-router.ts`
|
|
19
|
+
* (or `.opencode/plugin/` in a project). OpenCode loads it on start. The
|
|
20
|
+
* router URL is `AUTO_MODEL_ROUTER_URL`, else port `AUTO_MODEL_ROUTER_PORT`
|
|
21
|
+
* (default 8788) on localhost.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
25
|
+
|
|
26
|
+
const PROVIDER_ID = "auto-model-router";
|
|
27
|
+
const BASE_URL = process.env.AUTO_MODEL_ROUTER_URL ?? `http://127.0.0.1:${process.env.AUTO_MODEL_ROUTER_PORT ?? "8788"}`;
|
|
28
|
+
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "opencode";
|
|
29
|
+
const POLICY_TTL_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
interface DigestPolicy {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
minBytes: number;
|
|
34
|
+
maxBytes: number;
|
|
35
|
+
tools: string[];
|
|
36
|
+
toolAliases: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
const DISABLED: DigestPolicy = { enabled: false, minBytes: 0, maxBytes: 0, tools: [], toolAliases: {} };
|
|
39
|
+
|
|
40
|
+
function parsePolicy(json: unknown): DigestPolicy {
|
|
41
|
+
if (json === null || typeof json !== "object") return DISABLED;
|
|
42
|
+
const p = json as Record<string, unknown>;
|
|
43
|
+
if (p.enabled !== true) return DISABLED;
|
|
44
|
+
return {
|
|
45
|
+
enabled: true,
|
|
46
|
+
minBytes: typeof p.minBytes === "number" ? p.minBytes : 12_000,
|
|
47
|
+
maxBytes: typeof p.maxBytes === "number" ? p.maxBytes : 400_000,
|
|
48
|
+
tools: Array.isArray(p.tools) ? p.tools.filter((t): t is string => typeof t === "string").map((t) => t.toLowerCase()) : [],
|
|
49
|
+
toolAliases: p.toolAliases !== null && typeof p.toolAliases === "object" ? Object.fromEntries(Object.entries(p.toolAliases as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === "string")) : {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a tool result is worth sending: policy on, eligible tool, size in the window. */
|
|
54
|
+
export function shouldDigest(policy: DigestPolicy, tool: string, text: string): boolean {
|
|
55
|
+
if (!policy.enabled) return false;
|
|
56
|
+
const lower = tool.toLowerCase();
|
|
57
|
+
if (!policy.tools.includes(policy.toolAliases[lower] ?? lower)) return false;
|
|
58
|
+
const bytes = Buffer.byteLength(text);
|
|
59
|
+
return bytes >= policy.minBytes && bytes <= policy.maxBytes;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One toast line for the last routed turn of a session. */
|
|
63
|
+
export function toastLine(e: { slug?: string; servedSlug?: string; tier?: string; reportedUsd?: number | null; predictedUsd?: number }): string {
|
|
64
|
+
const slug = e.servedSlug ?? e.slug ?? "?";
|
|
65
|
+
const provider = slug.startsWith("ollama/") ? "ollama" : "openrouter";
|
|
66
|
+
const usd = e.reportedUsd ?? e.predictedUsd ?? 0;
|
|
67
|
+
return `${provider} · ${slug.replace(/^ollama\//, "")} [${e.tier ?? "?"}] · $${usd.toFixed(5)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const AutoModelRouter: Plugin = async ({ client }) => {
|
|
71
|
+
let policy = DISABLED;
|
|
72
|
+
let policyAtMs = 0;
|
|
73
|
+
const parents = new Map<string, string | null>();
|
|
74
|
+
const lastToasted = new Map<string, string>();
|
|
75
|
+
|
|
76
|
+
async function refreshPolicy(): Promise<DigestPolicy> {
|
|
77
|
+
if (Date.now() - policyAtMs < POLICY_TTL_MS) return policy;
|
|
78
|
+
policyAtMs = Date.now();
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`${BASE_URL}/v1/router/digest/policy`, { signal: AbortSignal.timeout(2_000) });
|
|
81
|
+
policy = res.ok ? parsePolicy(await res.json()) : DISABLED;
|
|
82
|
+
} catch {
|
|
83
|
+
policy = DISABLED;
|
|
84
|
+
}
|
|
85
|
+
return policy;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function parentOf(sessionID: string): Promise<string | null> {
|
|
89
|
+
const known = parents.get(sessionID);
|
|
90
|
+
if (known !== undefined) return known;
|
|
91
|
+
let parent: string | null = null;
|
|
92
|
+
try {
|
|
93
|
+
const res = await client.session.get({ path: { id: sessionID } });
|
|
94
|
+
parent = res.data?.parentID ?? null;
|
|
95
|
+
} catch {
|
|
96
|
+
parent = null;
|
|
97
|
+
}
|
|
98
|
+
parents.set(sessionID, parent);
|
|
99
|
+
return parent;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function toast(sessionID: string): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${BASE_URL}/v1/router/decisions?limit=1&session=${encodeURIComponent(sessionID)}`, { signal: AbortSignal.timeout(2_000) });
|
|
105
|
+
if (!res.ok) return;
|
|
106
|
+
const body = (await res.json()) as { entries?: { id: string; slug?: string; servedSlug?: string; tier?: string; reportedUsd?: number | null; predictedUsd?: number }[] };
|
|
107
|
+
const entry = body.entries?.[0];
|
|
108
|
+
if (entry === undefined || lastToasted.get(sessionID) === entry.id) return;
|
|
109
|
+
lastToasted.set(sessionID, entry.id);
|
|
110
|
+
await client.tui.showToast({ body: { title: "auto-model-router", message: toastLine(entry), variant: "info", duration: 4_000 } });
|
|
111
|
+
} catch {
|
|
112
|
+
// No TUI (run mode) or router down: nothing to show.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"chat.headers": async (input, output) => {
|
|
118
|
+
if (input.model.providerID !== PROVIDER_ID) return;
|
|
119
|
+
output.headers["X-Omp-Session"] = input.sessionID;
|
|
120
|
+
output.headers["X-Omp-Harness"] = HARNESS_ID;
|
|
121
|
+
if ((await parentOf(input.sessionID)) !== null) output.headers["X-Omp-Subagent"] = "1";
|
|
122
|
+
},
|
|
123
|
+
event: async ({ event }) => {
|
|
124
|
+
if (event.type === "session.created") {
|
|
125
|
+
const info = (event as { properties: { info: { id: string; parentID?: string } } }).properties.info;
|
|
126
|
+
parents.set(info.id, info.parentID ?? null);
|
|
127
|
+
} else if (event.type === "session.idle") {
|
|
128
|
+
await toast((event as { properties: { sessionID: string } }).properties.sessionID);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"tool.execute.after": async (input, output) => {
|
|
132
|
+
const p = await refreshPolicy();
|
|
133
|
+
if (typeof output.output !== "string" || !shouldDigest(p, input.tool, output.output)) return;
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(`${BASE_URL}/v1/router/digest`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "content-type": "application/json" },
|
|
138
|
+
body: JSON.stringify({ ompSessionId: input.sessionID, harnessId: HARNESS_ID, toolName: input.tool, input: input.args ?? {}, content: output.output, query: "" }),
|
|
139
|
+
signal: AbortSignal.timeout(30_000),
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) return;
|
|
142
|
+
const r = (await res.json()) as { digested: boolean; text?: string };
|
|
143
|
+
if (r.digested && typeof r.text === "string") output.output = r.text;
|
|
144
|
+
} catch {
|
|
145
|
+
// Router unreachable or slow: the raw result stands.
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export default AutoModelRouter;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOCAL ambient stub for OpenCode's plugin API — not the vendor's types.
|
|
3
|
+
*
|
|
4
|
+
* `@opencode-ai/plugin` is resolved by OpenCode when it loads the plugin; it
|
|
5
|
+
* is not a dependency of this repo. This declares only the surface
|
|
6
|
+
* auto-model-router.ts uses, so the plugin is type-checked with the rest of
|
|
7
|
+
* the tree (tsconfig.all.json). The real types live in the package OpenCode
|
|
8
|
+
* installs under ~/.config/opencode/node_modules/@opencode-ai/plugin; treat a
|
|
9
|
+
* change here as a claim about that API that only a live session confirms.
|
|
10
|
+
*/
|
|
11
|
+
declare module "@opencode-ai/plugin" {
|
|
12
|
+
export interface SessionInfo {
|
|
13
|
+
id: string;
|
|
14
|
+
parentID?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface OpencodeClient {
|
|
17
|
+
session: {
|
|
18
|
+
get(options: { path: { id: string } }): Promise<{ data?: SessionInfo }>;
|
|
19
|
+
};
|
|
20
|
+
tui: {
|
|
21
|
+
showToast(options: { body: { title?: string; message: string; variant: "info" | "success" | "warning" | "error"; duration?: number } }): Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface PluginInput {
|
|
25
|
+
client: OpencodeClient;
|
|
26
|
+
directory: string;
|
|
27
|
+
worktree: string;
|
|
28
|
+
}
|
|
29
|
+
export type Event =
|
|
30
|
+
| { type: "session.created"; properties: { info: SessionInfo } }
|
|
31
|
+
| { type: "session.idle"; properties: { sessionID: string } }
|
|
32
|
+
| { type: string; properties?: unknown };
|
|
33
|
+
export interface Hooks {
|
|
34
|
+
event?: (input: { event: Event }) => Promise<void>;
|
|
35
|
+
"chat.headers"?: (input: { sessionID: string; agent: string; model: { providerID?: string; id?: string } }, output: { headers: Record<string, string> }) => Promise<void>;
|
|
36
|
+
"tool.execute.after"?: (input: { tool: string; sessionID: string; callID: string; args: unknown }, output: { title: string; output: string; metadata: unknown }) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export type Plugin = (input: PluginInput) => Promise<Hooks>;
|
|
39
|
+
}
|
package/package.json
CHANGED
package/src/cost/ledger.ts
CHANGED
|
@@ -376,6 +376,9 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
376
376
|
const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
|
|
377
377
|
const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
|
|
378
378
|
const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
|
|
379
|
+
// Ollama meter samples (one per usage poll) only matter for the current
|
|
380
|
+
// billing cycle's calibration; they age out with the ledger rows.
|
|
381
|
+
const pruneMeterStmt = db.query("DELETE FROM ollama_meter_samples WHERE at_ms < ?");
|
|
379
382
|
const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
|
|
380
383
|
const providerSpendStmt = db.query(
|
|
381
384
|
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
|
|
@@ -627,7 +630,9 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
627
630
|
},
|
|
628
631
|
prune(retentionDays: number, nowMs = Date.now()): number {
|
|
629
632
|
if (retentionDays <= 0) return 0;
|
|
630
|
-
|
|
633
|
+
const cutoff = nowMs - retentionDays * DAY_MS;
|
|
634
|
+
pruneMeterStmt.run(cutoff);
|
|
635
|
+
return pruneStmt.run(cutoff).changes;
|
|
631
636
|
},
|
|
632
637
|
markWasted(id: string): void {
|
|
633
638
|
wasteStmt.run(id);
|
package/src/cost/summary.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface SummaryWindow {
|
|
|
28
28
|
modelSwitches: number;
|
|
29
29
|
digests: number;
|
|
30
30
|
digestSpendUsd: number;
|
|
31
|
+
digestReruns: number;
|
|
31
32
|
subagentSpendUsd: number;
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -81,6 +82,7 @@ function windowOf(r: UsageReport): SummaryWindow {
|
|
|
81
82
|
modelSwitches: t.modelSwitches,
|
|
82
83
|
digests: t.digests,
|
|
83
84
|
digestSpendUsd: t.digestSpendUsd,
|
|
85
|
+
digestReruns: t.digestReruns,
|
|
84
86
|
subagentSpendUsd: t.subagentSpendUsd,
|
|
85
87
|
};
|
|
86
88
|
}
|
|
@@ -172,7 +174,7 @@ export function renderDailySummary(s: DailySummary): string {
|
|
|
172
174
|
out.push(b.savedShare >= 0 ? `saved ${pct(b.savedShare)} vs ${b.slug} (${usd(b.usd)} at list)` : `cost ${pct(-b.savedShare)} MORE than ${b.slug} (${usd(b.usd)} at list)`);
|
|
173
175
|
}
|
|
174
176
|
const extras: string[] = [];
|
|
175
|
-
if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)}`);
|
|
177
|
+
if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)} (re-run rate ${pct(c.digestReruns / c.digests)})`);
|
|
176
178
|
if (c.subagentSpendUsd > 0) extras.push(`subagents ${usd(c.subagentSpendUsd)}`);
|
|
177
179
|
if (extras.length > 0) out.push(extras.join(" · "));
|
|
178
180
|
}
|
package/src/server/http.ts
CHANGED
|
@@ -25,8 +25,9 @@ import { openDb } from "../util/sqlite.ts";
|
|
|
25
25
|
import { WireErrorException, renderErrorEnvelope } from "../wire/openai/errors.ts";
|
|
26
26
|
import { renderModelList } from "../wire/openai/models.ts";
|
|
27
27
|
import { parseChatRequest } from "../wire/openai/request.ts";
|
|
28
|
+
import { createResponsesBufferedSink, createResponsesStreamingSink, parseResponsesRequest } from "../wire/openai/responses.ts";
|
|
28
29
|
import { createBufferedSink, createStreamingSink } from "../wire/openai/sink.ts";
|
|
29
|
-
import type { NormRequest, WireError } from "../wire/types.ts";
|
|
30
|
+
import type { NormRequest, ResponseSink, WireError } from "../wire/types.ts";
|
|
30
31
|
import { runTurn } from "./turn.ts";
|
|
31
32
|
|
|
32
33
|
export interface StartedServer {
|
|
@@ -321,10 +322,19 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
321
322
|
inFlightTurns--;
|
|
322
323
|
};
|
|
323
324
|
|
|
324
|
-
|
|
325
|
+
/** A wire: how a request body becomes a NormRequest and how the turn is rendered back. */
|
|
326
|
+
interface Wire {
|
|
327
|
+
parse(body: unknown, headers: Headers): NormRequest;
|
|
328
|
+
streaming(model: string): { sink: ResponseSink; response: Response };
|
|
329
|
+
buffered(model: string): { sink: ResponseSink; response: Promise<Response> };
|
|
330
|
+
}
|
|
331
|
+
const CHAT_WIRE: Wire = { parse: parseChatRequest, streaming: createStreamingSink, buffered: createBufferedSink };
|
|
332
|
+
const RESPONSES_WIRE: Wire = { parse: parseResponsesRequest, streaming: createResponsesStreamingSink, buffered: createResponsesBufferedSink };
|
|
333
|
+
|
|
334
|
+
const handleTurn = async (req: Request, wire: Wire): Promise<Response> => {
|
|
325
335
|
let normReq: NormRequest;
|
|
326
336
|
try {
|
|
327
|
-
normReq =
|
|
337
|
+
normReq = wire.parse(await req.json(), req.headers);
|
|
328
338
|
} catch (err) {
|
|
329
339
|
// The slot was acquired before parsing; a rejected body never reaches
|
|
330
340
|
// runTurn's `finally`, so it must be released here or every malformed
|
|
@@ -338,9 +348,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
338
348
|
});
|
|
339
349
|
}
|
|
340
350
|
|
|
341
|
-
const { sink, response } = normReq.stream
|
|
342
|
-
? createStreamingSink(normReq.requestedModel)
|
|
343
|
-
: createBufferedSink(normReq.requestedModel);
|
|
351
|
+
const { sink, response } = normReq.stream ? wire.streaming(normReq.requestedModel) : wire.buffered(normReq.requestedModel);
|
|
344
352
|
|
|
345
353
|
// The client signal aborts the upstream dispatch on disconnect. runTurn is
|
|
346
354
|
// expected to render its own failures into the sink; this catch is the last
|
|
@@ -399,7 +407,14 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
399
407
|
if (!acquireTurn()) {
|
|
400
408
|
return wireErrorResponse({ status: 429, code: "too_many_requests", message: "too many concurrent turns" });
|
|
401
409
|
}
|
|
402
|
-
return await
|
|
410
|
+
return await handleTurn(req, CHAT_WIRE);
|
|
411
|
+
}
|
|
412
|
+
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
|
413
|
+
// The Responses API wire (Codex CLI). Same turn, different rendering.
|
|
414
|
+
if (!acquireTurn()) {
|
|
415
|
+
return wireErrorResponse({ status: 429, code: "too_many_requests", message: "too many concurrent turns" });
|
|
416
|
+
}
|
|
417
|
+
return await handleTurn(req, RESPONSES_WIRE);
|
|
403
418
|
}
|
|
404
419
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
405
420
|
return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
|
package/src/util/sse.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* frame is emitted per token on the hot path.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const encoder = new TextEncoder();
|
|
7
|
+
export const encoder = new TextEncoder();
|
|
8
8
|
|
|
9
9
|
/** One `data:` frame carrying a JSON payload, terminated by a blank line. */
|
|
10
10
|
export function sseDataFrame(value: unknown): string {
|