auto-model-router 0.4.6 → 0.4.8

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.6",
10
+ "version": "0.4.8",
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.6",
17
+ "version": "0.4.8",
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,17 @@ 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: the captured request is
434
+ `test/fixtures/harness/codex-responses.json`. Stateless only Codex sends
435
+ `store: false` and the full input each turn; `previous_response_id` is
436
+ rejected. Reasoning summaries and encrypted reasoning are not produced. No
437
+ session id or hooks: reports are per harness, and there is no toast, digest
438
+ or `/router`.
431
439
 
432
440
  ### Aider
433
441
 
@@ -437,8 +445,12 @@ export OPENAI_API_KEY=local
437
445
  aider --model openai/auto
438
446
  ```
439
447
 
440
- Aider sends no tool calls, so every turn classifies on its text alone. No
441
- session id or hooks.
448
+ Verified live with aider 0.86 (captured request:
449
+ `test/fixtures/harness/aider.json`). Aider sends no tool calls, so every turn
450
+ classifies on its text alone, and no custom headers, so its rows carry no
451
+ harness id unless you set one in a model settings file
452
+ (`extra_params: { extra_headers: { X-Omp-Harness: aider } }`). No session
453
+ id or hooks.
442
454
 
443
455
  ### Cline, Roo Code, Kilo Code
444
456
 
@@ -468,9 +480,28 @@ digest applies only through summarising compaction
468
480
  }
469
481
  ```
470
482
 
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.
483
+ Verified live with opencode 1.18 (captured request:
484
+ `test/fixtures/harness/opencode.json`). OpenCode's AI SDK validates every
485
+ SSE frame, which is why the router's final summary frame is shaped as a
486
+ chunk with no choices. Its tool names (`read`, `grep`, `glob`, `bash`,
487
+ `webfetch`) match the router's canonical list.
488
+
489
+ **Native features (OpenCode plugin API).** Copy
490
+ `opencode-plugin/auto-model-router.ts` to `~/.config/opencode/plugin/` (or a
491
+ project's `.opencode/plugin/`); OpenCode loads it on start. It adds:
492
+
493
+ - **Session identity** — `X-Omp-Session`, `X-Omp-Harness` (`opencode`, or
494
+ `OMP_HARNESS_ID`) and `X-Omp-Subagent` for sessions with a parent, through
495
+ the `chat.headers` hook.
496
+ - **Routing toast** — when a session goes idle, its last routed turn's
497
+ provider, model, tier and cost appear as a TUI toast.
498
+ - **Tool-result digest** — large `read`, `grep`, `glob`, `bash` and
499
+ `webfetch` results go to `/v1/router/digest` through `tool.execute.after`
500
+ and the model gets the digest. Off unless `digest.enabled`.
501
+
502
+ No `/router` command (OpenCode commands are markdown files, not plugin
503
+ hooks): use `auto-model-router report` on the terminal, or the router's
504
+ HTTP endpoints.
474
505
 
475
506
  ### The OpenRouter key
476
507
 
@@ -1077,10 +1108,10 @@ plus a harness header; the rest needs the harness's own hook API.
1077
1108
  | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
1078
1109
  | omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
1079
1110
  | 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 |
1111
+ | Codex CLI | Responses API wire | yes | no | no | no | no | compaction only | no | no |
1112
+ | Aider | config only | via model settings | no | no | no | no | no tools | no | no |
1082
1113
  | 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 |
1114
+ | OpenCode | config + plugin | yes | yes (plugin) | yes (plugin) | yes (plugin) | no | yes (plugin) | no | no |
1084
1115
  | Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
1085
1116
 
1086
1117
  A single embedded router can serve several omp sessions without them stepping
@@ -273,9 +273,11 @@ def _why_text(e: Dict[str, Any]) -> str:
273
273
  pt, ct = usage.get("promptTokens", 0) or 0, usage.get("cachedTokens", 0) or 0
274
274
  cache = f"{round(100 * ct / pt)}%" if pt else "n/a"
275
275
  cost = e.get("reportedUsd")
276
+ if cost is None:
277
+ cost = e.get("predictedUsd") or 0
276
278
  lines = [
277
279
  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",
280
+ f"cost ${float(cost):.5f} · cache hit {cache} · latency {e.get('latencyMs')}ms",
279
281
  ]
280
282
  for r in e.get("reasons") or []:
281
283
  lines.append(f" - {r}")
@@ -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.6",
3
+ "version": "0.4.8",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -121,6 +121,14 @@ interface RecentDigest {
121
121
  atMs: number;
122
122
  ledgerId: string;
123
123
  rerun: boolean;
124
+ /**
125
+ * Whether the call that PRODUCED this digest has been seen. A tool_result
126
+ * digest is made before the next request, and that request's last
127
+ * assistant message carries the producing call; it must not count as a
128
+ * re-run. A compaction digest covers a call already in history, so its
129
+ * origin counts as seen from the start.
130
+ */
131
+ originSeen: boolean;
124
132
  }
125
133
  const RERUN_WINDOW_MS = 2 * 3_600_000;
126
134
  const RECENT_PER_SESSION = 50;
@@ -261,7 +269,7 @@ export function createDigester(deps: DigesterDeps): Digester {
261
269
  if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
262
270
  if (req.ompSessionId !== "") {
263
271
  const list = recent.get(req.ompSessionId) ?? [];
264
- list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false });
272
+ list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false, originSeen: source === "compaction" });
265
273
  recent.set(req.ompSessionId, list.slice(-RECENT_PER_SESSION));
266
274
  }
267
275
  return {
@@ -284,6 +292,11 @@ export function createDigester(deps: DigesterDeps): Digester {
284
292
  if (arg === null) continue;
285
293
  for (const d of list) {
286
294
  if (d.rerun || d.tool !== tool || d.arg !== arg || nowMs - d.atMs > RERUN_WINDOW_MS) continue;
295
+ if (!d.originSeen) {
296
+ // The producing call, arriving in the next request's history.
297
+ d.originSeen = true;
298
+ continue;
299
+ }
287
300
  d.rerun = true;
288
301
  marked++;
289
302
  try {
@@ -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 {