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.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- 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
|
+
}
|