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.
@@ -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.7",
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.7",
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 talks to custom providers over the chat-completions wire. Run the
414
- router (`auto-model-router serve --port 8788`) and add a provider:
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
- wire_api = "chat"
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
- 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`.
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
- Aider sends no tool calls, so every turn classifies on its text alone. No
441
- session id or hooks.
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
- 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.
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 | config only | yes | no | no | no | no | compaction only | no | no |
1081
- | Aider | config only | yes | no | no | no | no | no tools | no | no |
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 only | yes | no | no | no | no | compaction only | no | no |
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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
- return pruneStmt.run(nowMs - retentionDays * DAY_MS).changes;
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);
@@ -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
  }
@@ -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
- const handleChatCompletions = async (req: Request): Promise<Response> => {
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 = parseChatRequest(await req.json(), req.headers);
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 handleChatCompletions(req);
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 {