auto-model-router 0.1.0

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.
Files changed (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * omp extension: toast the routed model on every turn.
3
+ *
4
+ * auto-model-router itself is a headless HTTP server — it cannot render anything in
5
+ * omp's TUI. This extension bridges that gap: it polls the router's decision
6
+ * ledger (`GET /v1/router/decisions`) and raises a TUI toast via
7
+ * `ctx.ui.notify(...)` whenever a new model is chosen for a turn.
8
+ *
9
+ * Install by adding this file's absolute path to omp's `extensions:` list:
10
+ *
11
+ * # ~/.omp/agent/config.yml
12
+ * extensions:
13
+ * - /path/to/auto-model-router/omp-extension/router-embed.ts
14
+ * - /path/to/auto-model-router/omp-extension/router-toast.ts
15
+ *
16
+ * Because the embedded router binds a random OS-assigned port and writes it to
17
+ * the port file, the toast resolves the base URL fresh on EVERY poll: the port
18
+ * file first, then `AUTO_MODEL_ROUTER_URL`, then `AUTO_MODEL_ROUTER_PORT`, then the router's
19
+ * own config.yml. Reading the port file each tick means the toast always polls
20
+ * the port the router actually bound, even though it changes every session.
21
+ *
22
+ * When the router is configured with `server.apiKey`, set AUTO_MODEL_ROUTER_API_KEY so
23
+ * the poll authenticates.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { join } from "node:path";
29
+
30
+ import { parse as parseYaml } from "yaml";
31
+
32
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
33
+
34
+ import { embedPortPath, readEmbedPort } from "./embed-logic.ts";
35
+ import { newestId, resolveRouterUrl, selectToasts, type ToastDecision } from "./toast-logic.ts";
36
+
37
+ /** Raw router config.yml, or null when there is none to read. */
38
+ function readRouterConfig(): string | null {
39
+ const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
40
+ const home =
41
+ raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
42
+ const path = join(home, "config.yml");
43
+ if (!existsSync(path)) return null;
44
+ try {
45
+ return readFileSync(path, "utf8");
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Absolute path of the shared embed port file (main session writes it). */
52
+ function embedPortFile(): string {
53
+ const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
54
+ const home =
55
+ raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
56
+ return embedPortPath(home);
57
+ }
58
+
59
+ const ROUTER_API_KEY = process.env.AUTO_MODEL_ROUTER_API_KEY;
60
+ // This harness's id, matching the X-Omp-Harness header the router records.
61
+ // Empty ⇒ toast every harness (single-harness default).
62
+ const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
63
+ const POLL_MS = 2_000;
64
+
65
+ export default function (pi: ExtensionAPI): void {
66
+ pi.setLabel("auto-model-router toast");
67
+
68
+ // The newest ledger entry already toasted. Ledger is `created_at_ms DESC`.
69
+ let lastSeenId: string | null = null;
70
+
71
+ pi.on("session_start", (_event, ctx) => {
72
+ // Headless/print/subagent sessions have no UI to toast into; skip the
73
+ // poll loop entirely rather than waking every 2s to do nothing.
74
+ if (!ctx.hasUI) return;
75
+
76
+ // The poll request's own deadline (3s) exceeds the poll period (2s), so
77
+ // a slow router could let a second tick start while the first is still in
78
+ // flight — both read the same lastSeenId and raise duplicate toasts. An
79
+ // in-flight flag makes each tick a no-op while the previous is outstanding.
80
+ let polling = false;
81
+
82
+ const timer = ctx.setInterval(async () => {
83
+ if (polling) return;
84
+ polling = true;
85
+ try {
86
+ // The embedded router binds a free OS-assigned port, so the URL
87
+ // is resolved fresh each tick from the port file the embed
88
+ // extension writes at session_start.
89
+ const embedPort = readEmbedPort(embedPortFile());
90
+ const routerUrl = resolveRouterUrl(
91
+ process.env.AUTO_MODEL_ROUTER_URL,
92
+ readRouterConfig(),
93
+ parseYaml,
94
+ process.env.AUTO_MODEL_ROUTER_PORT,
95
+ embedPort,
96
+ );
97
+
98
+ let res: Response;
99
+ try {
100
+ res = await fetch(`${routerUrl}/v1/router/decisions?limit=20`, {
101
+ headers: ROUTER_API_KEY === undefined ? {} : { authorization: `Bearer ${ROUTER_API_KEY}` },
102
+ signal: AbortSignal.timeout(3_000),
103
+ });
104
+ } catch {
105
+ return; // router down; nothing to toast, retry next tick
106
+ }
107
+ if (!res.ok) return;
108
+
109
+ let body: { entries?: ToastDecision[] };
110
+ try {
111
+ body = (await res.json()) as { entries?: ToastDecision[] };
112
+ } catch {
113
+ return;
114
+ }
115
+ const entries = body.entries;
116
+ if (!Array.isArray(entries) || entries.length === 0) return;
117
+
118
+ for (const t of selectToasts(entries, lastSeenId, HARNESS_ID)) {
119
+ ctx.ui.notify(t.text, "info");
120
+ }
121
+ lastSeenId = newestId(entries) ?? lastSeenId;
122
+ } catch {
123
+ // isolated by ctx.setInterval; nothing to escalate
124
+ } finally {
125
+ polling = false;
126
+ }
127
+ }, POLL_MS);
128
+ pi.on("session_shutdown", () => ctx.clearTimer(timer));
129
+ });
130
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Pure toast selection logic, extracted from the omp extension so it can be
3
+ * unit-tested without omp's runtime.
4
+ *
5
+ * The router ledger is ordered `created_at_ms DESC` (newest first). The toast
6
+ * must:
7
+ * - only toast entries newer than the last one already seen;
8
+ * - skip `wasted` entries (escalation book-keeping — the abandoned probe
9
+ * attempt that was superseded and never served the client);
10
+ * - surface the attempt that actually served, oldest→newest.
11
+ */
12
+
13
+ /**
14
+ * The router's default listen port. Only used when nothing else identifies the
15
+ * router, and deliberately NOT the last word: hardcoding it means polling
16
+ * whatever else owns that port once the router has been moved, in which case
17
+ * toasts simply never appear and nothing explains why.
18
+ */
19
+ export const DEFAULT_ROUTER_URL = "http://127.0.0.1:8788";
20
+
21
+ /**
22
+ * Resolves the router base URL from, in precedence order:
23
+ * 1. an explicit `AUTO_MODEL_ROUTER_URL` override;
24
+ * 2. the embedded router's port file (`embedPort`, when it holds a valid
25
+ * port) — the embed extension binds a free OS-assigned port and writes it
26
+ * to the port file, so the toast must poll that actual address;
27
+ * 3. an explicit `AUTO_MODEL_ROUTER_PORT` (`envPort`);
28
+ * 4. the `server` block of the router's own config;
29
+ * 5. `DEFAULT_ROUTER_URL`.
30
+ *
31
+ * `configText` is the raw config.yml contents, or null when absent/unreadable.
32
+ * `parseYaml` is injected so this stays dependency-free and testable.
33
+ */
34
+ export function resolveRouterUrl(
35
+ envUrl: string | undefined,
36
+ configText: string | null,
37
+ parseYaml: (text: string) => unknown,
38
+ envPort?: string,
39
+ embedPort?: number | null,
40
+ ): string {
41
+ if (envUrl !== undefined && envUrl !== "") return envUrl;
42
+
43
+ let port: number | undefined;
44
+ if (embedPort !== undefined && embedPort !== null) {
45
+ port = embedPort;
46
+ } else if (envPort !== undefined && envPort !== "") {
47
+ const parsedPort = Number.parseInt(envPort, 10);
48
+ if (Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65_535) port = parsedPort;
49
+ }
50
+ const envWins = port !== undefined;
51
+
52
+ if (configText === null) return envWins ? `http://127.0.0.1:${port}` : DEFAULT_ROUTER_URL;
53
+
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = parseYaml(configText);
57
+ } catch {
58
+ return envWins ? `http://127.0.0.1:${port}` : DEFAULT_ROUTER_URL;
59
+ }
60
+ if (typeof parsed !== "object" || parsed === null) return envWins ? `http://127.0.0.1:${port}` : DEFAULT_ROUTER_URL;
61
+ const server: unknown = (parsed as Record<string, unknown>).server;
62
+ if (typeof server !== "object" || server === null) return envWins ? `http://127.0.0.1:${port}` : DEFAULT_ROUTER_URL;
63
+
64
+ const rec = server as Record<string, unknown>;
65
+ const resolvedPort = envWins
66
+ ? port!
67
+ : typeof rec.port === "number" && Number.isInteger(rec.port) && rec.port > 0
68
+ ? rec.port
69
+ : 8788;
70
+ const rawHost = typeof rec.host === "string" && rec.host !== "" ? rec.host : "127.0.0.1";
71
+ // A wildcard listen address is not a connectable target.
72
+ const host = rawHost === "0.0.0.0" || rawHost === "::" ? "127.0.0.1" : rawHost;
73
+ return `http://${host}:${resolvedPort}`;
74
+ }
75
+
76
+ /** Subset of a ledger entry (see src/cost/types.ts LedgerEntry). */
77
+ export interface ToastDecision {
78
+ id: string;
79
+ slug: string;
80
+ servedSlug: string | null;
81
+ tier: string;
82
+ reportedUsd: number | null;
83
+ wasted: boolean;
84
+ /** Harness id from the request header; empty for the default harness. */
85
+ harnessId: string;
86
+ }
87
+
88
+ export interface ToastMessage {
89
+ model: string;
90
+ tier: string;
91
+ costUsd: number | null;
92
+ /** Human-readable line for `ctx.ui.notify`. */
93
+ text: string;
94
+ }
95
+
96
+ export function toToastText(d: ToastDecision): string {
97
+ const model = d.servedSlug ?? d.slug;
98
+ const cost = d.reportedUsd === null ? "" : ` \u00b7 $${d.reportedUsd.toFixed(5)}`;
99
+ return `${model} [${d.tier}]${cost}`;
100
+ }
101
+
102
+ /**
103
+ * Given the newest-first ledger window and the last-toasted entry id, return
104
+ * the new toasts to raise, oldest→newest. Pass `lastSeenId === null` on the
105
+ * first tick to toast nothing (avoids a burst on startup).
106
+ *
107
+ * When `harnessId` is non-empty, only entries from that harness are toasted,
108
+ * so multiple harnesses sharing one router don't spam each other's toasts.
109
+ */
110
+ export function selectToasts(
111
+ entries: ToastDecision[],
112
+ lastSeenId: string | null,
113
+ harnessId = "",
114
+ ): ToastMessage[] {
115
+ if (lastSeenId === null) return [];
116
+ // `entries` is newest-first. Entries strictly newer than lastSeenId are the
117
+ // contiguous prefix before it. `-1` (id rolled out of the window) → treat
118
+ // every entry as new.
119
+ const idx = entries.findIndex((e) => e.id === lastSeenId);
120
+ const newer = idx === -1 ? entries : entries.slice(0, idx);
121
+ const out: ToastMessage[] = [];
122
+ for (let i = newer.length - 1; i >= 0; i--) {
123
+ const d = newer[i];
124
+ if (d === undefined) continue;
125
+ if (d.wasted) continue;
126
+ if (harnessId !== "" && d.harnessId !== harnessId) continue;
127
+ out.push({ model: d.servedSlug ?? d.slug, tier: d.tier, costUsd: d.reportedUsd, text: toToastText(d) });
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** The newest entry id in a newest-first list, for advancing `lastSeenId`. */
133
+ export function newestId(entries: ToastDecision[]): string | null {
134
+ const first = entries[0];
135
+ return first === undefined ? null : first.id;
136
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "auto-model-router",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
+ "type": "module",
7
+ "bin": {
8
+ "auto-model-router": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "bun test",
13
+ "smoke": "bun run tools/smoke.ts"
14
+ },
15
+ "dependencies": {
16
+ "yaml": "^2.7.0",
17
+ "zod": "^4.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/bun": "latest",
21
+ "typescript": "^5.7.0"
22
+ },
23
+ "engines": {
24
+ "bun": ">=1.2.0"
25
+ },
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/drewappling/auto-model-router.git"
30
+ },
31
+ "homepage": "https://github.com/drewappling/auto-model-router",
32
+ "bugs": {
33
+ "url": "https://github.com/drewappling/auto-model-router/issues"
34
+ },
35
+ "omp": {
36
+ "extensions": [
37
+ "./omp-extension/router-embed.ts",
38
+ "./omp-extension/router-toast.ts",
39
+ "./omp-extension/router-configure.ts"
40
+ ]
41
+ },
42
+ "keywords": [
43
+ "pi-package"
44
+ ],
45
+ "pi": {
46
+ "extensions": [
47
+ "./omp-extension/router-embed.ts",
48
+ "./omp-extension/router-toast.ts",
49
+ "./omp-extension/router-configure.ts"
50
+ ]
51
+ },
52
+ "peerDependencies": {
53
+ "@oh-my-pi/pi-coding-agent": "*",
54
+ "@earendil-works/pi-coding-agent": "*"
55
+ }
56
+ }