auto-model-router 0.7.0 → 0.7.1
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 +12 -1
- package/package.json +1 -1
- package/src/cli/connect.ts +115 -3
- package/src/index.ts +1 -1
- package/src/server/turn.ts +13 -5
- package/test/remote.test.ts +45 -0
|
@@ -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.7.
|
|
10
|
+
"version": "0.7.1",
|
|
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.7.
|
|
17
|
+
"version": "0.7.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1296,10 +1296,21 @@ adds the Codex provider and the Aider settings, and prints (or with `--profile`
|
|
|
1296
1296
|
the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
|
|
1297
1297
|
shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
|
|
1298
1298
|
|
|
1299
|
+
`connect` also writes omp's `models.yml` (a managed block, other providers untouched, the
|
|
1300
|
+
previous file backed up). That entry is what makes `auto-model-router/auto` resolvable at
|
|
1301
|
+
**startup**: omp builds the main model's handle before extensions load, so without it only
|
|
1302
|
+
the late-resolved roles (`smol`, `tiny`) reach the router and the main turns fall back to
|
|
1303
|
+
whatever else is authenticated. A local router deliberately gets no such entry — its port
|
|
1304
|
+
is ephemeral, so a persisted one names a dead socket next launch — but a remote's URL and
|
|
1305
|
+
key are stable. **The file then holds the member key: treat it as a secret.**
|
|
1306
|
+
|
|
1299
1307
|
In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
|
|
1300
1308
|
remote router serves every repo on the machine with that repo's shared context. The remote
|
|
1301
1309
|
decides what to do with it: a team edition that pins a scope on the member's group
|
|
1302
|
-
overrides it, and one that pins none follows the workspace.
|
|
1310
|
+
overrides it, and one that pins none follows the workspace. That header rides on the roles
|
|
1311
|
+
the extensions register; the **main** model's handle comes from `models.yml`, which is
|
|
1312
|
+
machine-wide, so it carries a scope only if you pass `--scope <slug>` to `connect` — right
|
|
1313
|
+
for a single-project machine, wrong for one with several repos.
|
|
1303
1314
|
|
|
1304
1315
|
## Multiple coding harnesses, one router
|
|
1305
1316
|
|
package/package.json
CHANGED
package/src/cli/connect.ts
CHANGED
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* `<router home>/remote.json` (the omp extensions then run in remote mode and
|
|
5
5
|
* never bind a local router), and configures every harness it finds:
|
|
6
6
|
*
|
|
7
|
-
* omp the four extensions are added to ~/.omp/agent/config.yml
|
|
7
|
+
* omp the four extensions are added to ~/.omp/agent/config.yml, and
|
|
8
|
+
* the remote is written into ~/.omp/agent/models.yml so
|
|
9
|
+
* `auto-model-router/auto` resolves at STARTUP — omp builds the
|
|
10
|
+
* main model's handle before extensions load, so without that
|
|
11
|
+
* entry only the late-resolved roles (smol, tiny) reach the
|
|
12
|
+
* router and the main turns fall back to another provider
|
|
8
13
|
* Hermes the provider plugin and the native plugin are copied into
|
|
9
14
|
* $HERMES_HOME/plugins and .env points them at the remote
|
|
10
15
|
* Codex ~/.codex/config.toml gains the auto-model-router provider
|
|
@@ -37,6 +42,10 @@ export interface ConnectOptions {
|
|
|
37
42
|
home: string;
|
|
38
43
|
/** Where this package lives (the extensions are referenced from here). */
|
|
39
44
|
packageDir: string;
|
|
45
|
+
/** Cost figures omp shows for the remote's virtual models, USD per million tokens. */
|
|
46
|
+
blend?: { inputPerMtok: number; outputPerMtok: number };
|
|
47
|
+
/** Adds `X-Agentdox-Scope` to omp's models.yml entry. Machine-wide: only for a single-project machine. */
|
|
48
|
+
agentdoxScope?: string;
|
|
40
49
|
platform: string;
|
|
41
50
|
pathHas: (bin: string) => boolean;
|
|
42
51
|
}
|
|
@@ -103,6 +112,90 @@ http_headers = { "X-Omp-Harness" = "codex" }
|
|
|
103
112
|
`;
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
/** The models a remote router advertises, and what omp should believe they cost. */
|
|
116
|
+
const REMOTE_MODEL_ROWS: readonly { id: string; name: string }[] = [
|
|
117
|
+
{ id: "auto", name: "Auto (auto-model-router)" },
|
|
118
|
+
{ id: "auto-cheap", name: "Auto Cheap (auto-model-router)" },
|
|
119
|
+
{ id: "auto-max", name: "Auto Max (auto-model-router)" },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const MODELS_YML_BEGIN = " # BEGIN auto-model-router (remote)";
|
|
123
|
+
const MODELS_YML_END = " # END auto-model-router (remote)";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* omp's `models.yml` entry for a remote router.
|
|
127
|
+
*
|
|
128
|
+
* A LOCAL router deliberately never writes this file: its port is ephemeral, so
|
|
129
|
+
* a persisted entry names a dead socket on the next launch. A remote router has
|
|
130
|
+
* neither problem — the URL and the key are stable — and the entry is what makes
|
|
131
|
+
* omp's main model resolvable at startup, before extensions load.
|
|
132
|
+
*
|
|
133
|
+
* `scope` adds `X-Agentdox-Scope` to every request through this provider. It is
|
|
134
|
+
* off by default on purpose: the file is machine-wide, so a scope here would
|
|
135
|
+
* label turns from every workspace with one project. The extensions still send
|
|
136
|
+
* the workspace's own scope on the roles that resolve after they load.
|
|
137
|
+
*/
|
|
138
|
+
export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string {
|
|
139
|
+
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
140
|
+
const cost = {
|
|
141
|
+
input: round(blend.inputPerMtok),
|
|
142
|
+
output: round(blend.outputPerMtok),
|
|
143
|
+
cacheRead: round(blend.inputPerMtok * 0.1),
|
|
144
|
+
cacheWrite: round(blend.inputPerMtok * 1.25),
|
|
145
|
+
};
|
|
146
|
+
const lines = [
|
|
147
|
+
MODELS_YML_BEGIN,
|
|
148
|
+
" # Managed by `auto-model-router connect`. Remove this block to stop routing omp through the remote.",
|
|
149
|
+
" auto-model-router:",
|
|
150
|
+
` baseUrl: ${url.replace(/\/+$/, "")}/v1`,
|
|
151
|
+
" api: openai-completions",
|
|
152
|
+
` apiKey: ${key}`,
|
|
153
|
+
];
|
|
154
|
+
if (scope !== "") lines.push(" headers:", ` X-Agentdox-Scope: ${scope}`);
|
|
155
|
+
lines.push(" models:");
|
|
156
|
+
for (const m of REMOTE_MODEL_ROWS) {
|
|
157
|
+
lines.push(
|
|
158
|
+
` - id: ${m.id}`,
|
|
159
|
+
` name: ${m.name}`,
|
|
160
|
+
" contextWindow: 200000",
|
|
161
|
+
" maxTokens: 32000",
|
|
162
|
+
" input: [text, image]",
|
|
163
|
+
` cost: { input: ${cost.input}, output: ${cost.output}, cacheRead: ${cost.cacheRead}, cacheWrite: ${cost.cacheWrite} }`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
lines.push(MODELS_YML_END);
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Merges the remote block into an existing `models.yml`, replacing a previous
|
|
172
|
+
* one and leaving every other provider alone. Returns the new file text.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeModelsYml(before: string, blockText: string): string {
|
|
175
|
+
const eol = before.includes("\r\n") ? "\r\n" : "\n";
|
|
176
|
+
const body = before.replace(/^\uFEFF/, "");
|
|
177
|
+
const block = blockText.split("\n").join(eol);
|
|
178
|
+
const begin = body.indexOf(MODELS_YML_BEGIN);
|
|
179
|
+
if (begin >= 0) {
|
|
180
|
+
const endIdx = body.indexOf(MODELS_YML_END, begin);
|
|
181
|
+
const end = endIdx < 0 ? body.length : endIdx + MODELS_YML_END.length;
|
|
182
|
+
return `${body.slice(0, begin)}${block}${body.slice(end)}`;
|
|
183
|
+
}
|
|
184
|
+
// A provider entry for the same id from an earlier local install would shadow
|
|
185
|
+
// ours; the caller reports it rather than editing a block it does not own.
|
|
186
|
+
if (body.trim() === "") return `providers:${eol}${block}${eol}`;
|
|
187
|
+
if (/^providers:\s*$/m.test(body)) {
|
|
188
|
+
return body.replace(/^providers:\s*$/m, (m) => `${m}${eol}${block}`);
|
|
189
|
+
}
|
|
190
|
+
return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** True when the file already defines our provider outside a block we manage. */
|
|
194
|
+
export function hasForeignRouterProvider(text: string): boolean {
|
|
195
|
+
if (text.includes(MODELS_YML_BEGIN)) return false;
|
|
196
|
+
return /^\s{2,}auto-model-router:\s*$/m.test(text);
|
|
197
|
+
}
|
|
198
|
+
|
|
106
199
|
export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
107
200
|
const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [] };
|
|
108
201
|
const write = (path: string, content: string): void => {
|
|
@@ -124,7 +217,22 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
124
217
|
const before = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
125
218
|
const after = addExtensions(before, ext);
|
|
126
219
|
if (after !== before) write(cfgPath, after);
|
|
127
|
-
|
|
220
|
+
// models.yml: what makes the MAIN model resolvable, since omp builds that
|
|
221
|
+
// handle at startup, before the extensions register anything.
|
|
222
|
+
const modelsPath = join(agentDir, "models.yml");
|
|
223
|
+
const modelsBefore = existsSync(modelsPath) ? readFileSync(modelsPath, "utf8") : "";
|
|
224
|
+
if (hasForeignRouterProvider(modelsBefore)) {
|
|
225
|
+
report.notes.push(`${modelsPath} already defines an auto-model-router provider by hand; left alone — remove it to let connect manage the remote entry`);
|
|
226
|
+
report.configured.push(`omp (${cfgPath}; extensions only)`);
|
|
227
|
+
} else {
|
|
228
|
+
const modelsAfter = mergeModelsYml(modelsBefore, renderRemoteModelsYml(o.url, o.key, o.blend ?? { inputPerMtok: 1.1, outputPerMtok: 4.4 }, o.agentdoxScope ?? ""));
|
|
229
|
+
if (modelsAfter !== modelsBefore) {
|
|
230
|
+
// Never overwrite another provider's work without a way back.
|
|
231
|
+
if (modelsBefore !== "" && !o.dryRun) writeFileSync(`${modelsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, modelsBefore, "utf8");
|
|
232
|
+
write(modelsPath, modelsAfter);
|
|
233
|
+
}
|
|
234
|
+
report.configured.push(`omp (${cfgPath} + ${modelsPath}; auto-model-router/auto is ready to pick)`);
|
|
235
|
+
}
|
|
128
236
|
} else report.skipped.push("omp (no ~/.omp/agent)");
|
|
129
237
|
|
|
130
238
|
// 3. Hermes
|
|
@@ -183,6 +291,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
183
291
|
report.notes.push(`environment appended to ${rc}; open a new shell or source it`);
|
|
184
292
|
}
|
|
185
293
|
} else report.notes.push("add the environment lines to your shell profile, or re-run with --profile");
|
|
294
|
+
report.notes.push("omp's models.yml now carries the member key; treat that file as a secret");
|
|
186
295
|
return report;
|
|
187
296
|
}
|
|
188
297
|
|
|
@@ -205,7 +314,10 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
205
314
|
}
|
|
206
315
|
// HOME wins when set (Git Bash, WSL, CI) so a caller can redirect every write; the OS profile otherwise.
|
|
207
316
|
const home = process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir();
|
|
208
|
-
|
|
317
|
+
// A single-project machine can label every request; a machine with several
|
|
318
|
+
// repos should leave it off and let the extensions send the workspace's own.
|
|
319
|
+
const agentdoxScope = flagString(args, "scope") ?? "";
|
|
320
|
+
const report = connectRemote({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home, packageDir, platform: process.platform, pathHas, agentdoxScope });
|
|
209
321
|
console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
|
|
210
322
|
for (const c of report.configured) console.log(` configured ${c}`);
|
|
211
323
|
for (const s of report.skipped) console.log(` skipped ${s}`);
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ Usage: auto-model-router <command> [options]
|
|
|
26
26
|
stats Show routed spend, per-model share, and escalation rates
|
|
27
27
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
28
28
|
export One row per day, harness and model as CSV (--json for rows)
|
|
29
|
-
connect Point this machine at a remote router (--url, --key; --profile persists the environment)
|
|
29
|
+
connect Point this machine at a remote router (--url, --key; --scope labels a single-project machine; --profile persists the environment)
|
|
30
30
|
models Show what each complexity tier would consider, and why
|
|
31
31
|
explain Route a saved request without dispatching it, and explain the decision
|
|
32
32
|
config Interactive wizard over the router's own config.yml
|
package/src/server/turn.ts
CHANGED
|
@@ -93,13 +93,21 @@ function lastUserText(req: NormRequest): string {
|
|
|
93
93
|
return "";
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Session title: the conversation's opening ask, truncated.
|
|
98
|
+
*
|
|
99
|
+
* Read through `userContent`, the same filter the recorded user message uses:
|
|
100
|
+
* a harness wraps the first message in reminders (omp opens with a
|
|
101
|
+
* `<system-reminder>` naming the date and cwd), and titling a session with that
|
|
102
|
+
* wrapper both reads as noise in agentdox and is re-injected into later context
|
|
103
|
+
* assembly, which lists session titles.
|
|
104
|
+
*/
|
|
97
105
|
function sessionTitle(req: NormRequest): string {
|
|
98
106
|
for (const m of req.messages) {
|
|
99
|
-
if (m.role
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
107
|
+
if (m.role !== "user") continue;
|
|
108
|
+
const t = userContent(m.text);
|
|
109
|
+
if (t === "") continue;
|
|
110
|
+
return t.length > 80 ? `${t.slice(0, 79)}…` : t;
|
|
103
111
|
}
|
|
104
112
|
return `omp ${req.conversationKey.slice(0, 8)}`;
|
|
105
113
|
}
|
package/test/remote.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
|
|
7
7
|
import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
|
|
8
|
+
import { hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Remote mode: remote.json puts the omp extensions on a router elsewhere,
|
|
@@ -102,3 +103,47 @@ describe("connect", () => {
|
|
|
102
103
|
rmSync(h2, { recursive: true, force: true });
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
|
|
107
|
+
describe("omp models.yml for a remote router", () => {
|
|
108
|
+
const BLEND = { inputPerMtok: 1.1, outputPerMtok: 4.4 };
|
|
109
|
+
const NL = String.fromCharCode(10);
|
|
110
|
+
const yaml = (...lines: string[]): string => lines.join(NL) + NL;
|
|
111
|
+
|
|
112
|
+
test("the block names the remote, the key and the three virtual models; a scope is opt-in", () => {
|
|
113
|
+
const block = renderRemoteModelsYml("https://team.example/", "amrt_k", BLEND);
|
|
114
|
+
expect(block).toContain("baseUrl: https://team.example/v1");
|
|
115
|
+
expect(block).toContain("apiKey: amrt_k");
|
|
116
|
+
expect(block).toContain("- id: auto");
|
|
117
|
+
expect(block).toContain("- id: auto-cheap");
|
|
118
|
+
expect(block).toContain("- id: auto-max");
|
|
119
|
+
expect(block).toContain("cost: { input: 1.1, output: 4.4, cacheRead: 0.11, cacheWrite: 1.375 }");
|
|
120
|
+
// Machine-wide file: no scope unless the caller asks for one.
|
|
121
|
+
expect(block).not.toContain("X-Agentdox-Scope");
|
|
122
|
+
expect(renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router")).toContain("X-Agentdox-Scope: omp-router");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("merging keeps other providers, replaces our own block, and is idempotent", () => {
|
|
126
|
+
const block = renderRemoteModelsYml("https://team.example", "k1", BLEND);
|
|
127
|
+
const empty = mergeModelsYml("", block);
|
|
128
|
+
expect(empty.startsWith("providers:")).toBe(true);
|
|
129
|
+
expect(mergeModelsYml(empty, block)).toBe(empty);
|
|
130
|
+
|
|
131
|
+
const existing = yaml("providers:", " openai:", " apiKey: sk-x");
|
|
132
|
+
const merged = mergeModelsYml(existing, block);
|
|
133
|
+
expect(merged).toContain("openai:");
|
|
134
|
+
expect(merged).toContain("baseUrl: https://team.example/v1");
|
|
135
|
+
|
|
136
|
+
// A later connect with a new key replaces the block in place, not a second copy.
|
|
137
|
+
const rekeyed = mergeModelsYml(merged, renderRemoteModelsYml("https://team.example", "k2", BLEND));
|
|
138
|
+
expect(rekeyed).toContain("apiKey: k2");
|
|
139
|
+
expect(rekeyed).not.toContain("apiKey: k1");
|
|
140
|
+
expect(rekeyed.match(/auto-model-router:/g)).toHaveLength(1);
|
|
141
|
+
expect(rekeyed).toContain("openai:");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a hand-written provider of the same name is left alone", () => {
|
|
145
|
+
expect(hasForeignRouterProvider(yaml("providers:", " auto-model-router:", " baseUrl: http://127.0.0.1:1/v1"))).toBe(true);
|
|
146
|
+
expect(hasForeignRouterProvider(mergeModelsYml("", renderRemoteModelsYml("https://t", "k", BLEND)))).toBe(false);
|
|
147
|
+
expect(hasForeignRouterProvider(yaml("providers:", " openai: {}"))).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|