auto-model-router 0.5.0 → 0.6.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 +13 -0
- package/hermes-plugin/__init__.py +7 -3
- package/omp-extension/remote-logic.ts +93 -0
- package/omp-extension/router-embed.ts +12 -0
- package/omp-extension/router-toast.ts +5 -34
- package/omp-extension/router-url.ts +6 -0
- package/package.json +1 -1
- package/src/cli/args.ts +4 -0
- package/src/cli/connect.ts +215 -0
- package/src/index.ts +6 -0
- package/test/remote.test.ts +100 -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.
|
|
10
|
+
"version": "0.6.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.
|
|
17
|
+
"version": "0.6.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1247,6 +1247,19 @@ override already pinned one. Every field is optional; a malformed header is
|
|
|
1247
1247
|
ignored rather than failing the turn. The decision trail records what the
|
|
1248
1248
|
policy changed (`policy: …`).
|
|
1249
1249
|
|
|
1250
|
+
## Using a remote router
|
|
1251
|
+
|
|
1252
|
+
`auto-model-router connect --url <router> --key <key>` points this machine at a router
|
|
1253
|
+
running elsewhere: a shared instance on a LAN, or a team edition front door. It writes
|
|
1254
|
+
`<router home>/remote.json`, after which the omp extensions run in **remote mode**: the
|
|
1255
|
+
embed extension registers the remote router as omp's provider with that key instead of
|
|
1256
|
+
binding a local one, and the toast, `/router` hub and digest extensions talk to it. Nothing
|
|
1257
|
+
is classified or selected locally; the remote router is the router. The same command adds
|
|
1258
|
+
the extensions to omp's config, installs the Hermes plugins and points them at the remote,
|
|
1259
|
+
adds the Codex provider and the Aider settings, and prints (or with `--profile` persists)
|
|
1260
|
+
the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
|
|
1261
|
+
shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
|
|
1262
|
+
|
|
1250
1263
|
## Multiple coding harnesses, one router
|
|
1251
1264
|
|
|
1252
1265
|
**One router process for everything.** omp's embed extension binds a private
|
|
@@ -30,7 +30,11 @@ from providers.base import ProviderProfile
|
|
|
30
30
|
|
|
31
31
|
# Fixed port the standalone router binds. Hermes points at this URL.
|
|
32
32
|
PORT = int(os.environ.get("AUTO_MODEL_ROUTER_PORT", "8788"))
|
|
33
|
-
|
|
33
|
+
# Remote mode: `auto-model-router connect` sets AUTO_MODEL_ROUTER_URL (and the
|
|
34
|
+
# key) in $HERMES_HOME/.env; the plugin then points Hermes at that router and
|
|
35
|
+
# spawns nothing locally.
|
|
36
|
+
REMOTE_URL = os.environ.get("AUTO_MODEL_ROUTER_URL", "").rstrip("/")
|
|
37
|
+
BASE_URL = f"{REMOTE_URL}/v1" if REMOTE_URL else f"http://127.0.0.1:{PORT}/v1"
|
|
34
38
|
|
|
35
39
|
# The router binary, provided by `npm install -g auto-model-router`.
|
|
36
40
|
BIN = "auto-model-router"
|
|
@@ -94,8 +98,8 @@ def _spawn_router() -> None:
|
|
|
94
98
|
raise RuntimeError(f"auto-model-router did not come up on port {PORT}")
|
|
95
99
|
|
|
96
100
|
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
if not REMOTE_URL:
|
|
102
|
+
_spawn_router()
|
|
99
103
|
profile = ProviderProfile(
|
|
100
104
|
name="auto-model-router",
|
|
101
105
|
api_mode="chat_completions",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-router mode for the omp extensions. `auto-model-router connect`
|
|
3
|
+
* writes `<router home>/remote.json`; when it exists the embed extension
|
|
4
|
+
* registers the REMOTE router as omp's provider instead of binding a local
|
|
5
|
+
* one, and the toast, hub and digest extensions talk to it with the key.
|
|
6
|
+
* Nothing is classified or selected locally: the remote router is the router.
|
|
7
|
+
* A shared router on a LAN and the team edition are both remotes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const REMOTE_FILE = "remote.json";
|
|
14
|
+
/** The name the first release used; still read so nothing breaks on upgrade. */
|
|
15
|
+
const LEGACY_FILE = "team.json";
|
|
16
|
+
|
|
17
|
+
export interface RemoteRouter {
|
|
18
|
+
/** The remote router, no trailing slash, e.g. https://router.example.com */
|
|
19
|
+
url: string;
|
|
20
|
+
/** The key that router expects (`server.apiKey`, or a team user key). */
|
|
21
|
+
key: string;
|
|
22
|
+
userId: string;
|
|
23
|
+
name: string;
|
|
24
|
+
joinedAtMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function remoteFilePath(routerHome: string): string {
|
|
28
|
+
return join(routerHome, REMOTE_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parses remote.json defensively; anything malformed ⇒ not in remote mode. */
|
|
32
|
+
export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
35
|
+
if (typeof raw.url !== "string" || typeof raw.key !== "string" || raw.url === "" || raw.key === "") return null;
|
|
36
|
+
return { url: raw.url.replace(/\/+$/, ""), key: raw.key, userId: typeof raw.userId === "string" ? raw.userId : "", name: typeof raw.name === "string" ? raw.name : "", joinedAtMs: typeof raw.joinedAtMs === "number" ? raw.joinedAtMs : 0 };
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readRemoteRouter(routerHome: string): RemoteRouter | null {
|
|
43
|
+
for (const path of [remoteFilePath(routerHome), join(routerHome, LEGACY_FILE)]) {
|
|
44
|
+
if (!existsSync(path)) continue;
|
|
45
|
+
try {
|
|
46
|
+
return parseRemoteRouter(readFileSync(path, "utf8"));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The virtual models a remote router serves; costs are the fallback blend so omp can show estimates. */
|
|
55
|
+
export const REMOTE_MODELS: readonly { id: string; name: string }[] = [
|
|
56
|
+
{ id: "auto", name: "auto (remote)" },
|
|
57
|
+
{ id: "auto-cheap", name: "auto-cheap (remote)" },
|
|
58
|
+
{ id: "auto-max", name: "auto-max (remote)" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* omp's provider registration for remote mode: the remote's /v1 with the key,
|
|
63
|
+
* the session and subagent tags, and the virtual models. Costs are USD per
|
|
64
|
+
* million tokens, like the embedded config.
|
|
65
|
+
*/
|
|
66
|
+
export function remoteProviderRegistration(remote: RemoteRouter, sessionId: string, subagent: boolean, blend: { inputPerMtok: number; outputPerMtok: number }): {
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
api: string;
|
|
69
|
+
apiKey: string;
|
|
70
|
+
headers: Record<string, string>;
|
|
71
|
+
models: { id: string; name: string; api: string; reasoning: boolean; input: string[]; contextWindow: number; maxTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number } }[];
|
|
72
|
+
} {
|
|
73
|
+
const headers: Record<string, string> = {};
|
|
74
|
+
if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
|
|
75
|
+
if (subagent) headers["X-Omp-Subagent"] = "1";
|
|
76
|
+
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
77
|
+
return {
|
|
78
|
+
baseUrl: `${remote.url}/v1`,
|
|
79
|
+
api: "openai-completions",
|
|
80
|
+
apiKey: remote.key,
|
|
81
|
+
headers,
|
|
82
|
+
models: REMOTE_MODELS.map((m) => ({
|
|
83
|
+
id: m.id,
|
|
84
|
+
name: m.name,
|
|
85
|
+
api: "openai-completions",
|
|
86
|
+
reasoning: false,
|
|
87
|
+
input: ["text", "image"],
|
|
88
|
+
contextWindow: 200_000,
|
|
89
|
+
maxTokens: 32_000,
|
|
90
|
+
cost: { input: round(blend.inputPerMtok), output: round(blend.outputPerMtok), cacheRead: round(blend.inputPerMtok * 0.1), cacheWrite: round(blend.inputPerMtok * 1.25) },
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -26,6 +26,7 @@ import { join } from "node:path";
|
|
|
26
26
|
import { ompModelsPath } from "../src/cli/config-cmd.ts";
|
|
27
27
|
import { loadConfig } from "../src/config/load.ts";
|
|
28
28
|
import { startServer } from "../src/server/http.ts";
|
|
29
|
+
import { readRemoteRouter, remoteProviderRegistration } from "./remote-logic.ts";
|
|
29
30
|
import type { StartedServer } from "../src/server/http.ts";
|
|
30
31
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
31
32
|
|
|
@@ -167,6 +168,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
167
168
|
// notifications to that exact session (see router-toast.ts).
|
|
168
169
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
169
170
|
|
|
171
|
+
// Remote mode (`auto-model-router connect`): a router elsewhere is the
|
|
172
|
+
// router. Register it as the provider with its key and bind nothing
|
|
173
|
+
// locally; the other extensions find it through remote.json.
|
|
174
|
+
const remote = readRemoteRouter(routerHome());
|
|
175
|
+
if (remote !== null) {
|
|
176
|
+
pi.registerProvider(EMBED_PROVIDER_ID, remoteProviderRegistration(remote, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend));
|
|
177
|
+
pi.setLabel(`auto-model-router remote (${remote.url.replace(/^https?:\/\//, "")})`);
|
|
178
|
+
writeEmbedLog(`remote mode url=${remote.url} user=${remote.userId} session=${sessionId}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
170
182
|
// This module is cached per PROCESS, so `app` and `boundPort` are
|
|
171
183
|
// process-global even when omp loads the extension into more than one
|
|
172
184
|
// host. A router already bound in this process is therefore reusable:
|
|
@@ -23,40 +23,17 @@
|
|
|
23
23
|
* the poll authenticates.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
-
import { homedir } from "node:os";
|
|
28
|
-
import { join } from "node:path";
|
|
29
26
|
|
|
30
|
-
import { parse as parseYaml } from "yaml";
|
|
31
27
|
|
|
32
28
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
33
29
|
|
|
34
|
-
import {
|
|
35
|
-
import { newestId,
|
|
30
|
+
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
31
|
+
import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
|
|
36
32
|
|
|
37
33
|
/** 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
34
|
|
|
51
35
|
/** 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
36
|
|
|
59
|
-
const ROUTER_API_KEY = process.env.AUTO_MODEL_ROUTER_API_KEY;
|
|
60
37
|
// This harness's id, matching the X-Omp-Harness header the router records.
|
|
61
38
|
// Empty ⇒ toast every harness (single-harness default).
|
|
62
39
|
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
|
|
@@ -91,19 +68,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
91
68
|
// The embedded router binds a free OS-assigned port, so the URL
|
|
92
69
|
// is resolved fresh each tick from the port file the embed
|
|
93
70
|
// extension writes at session_start.
|
|
94
|
-
|
|
95
|
-
const routerUrl =
|
|
96
|
-
process.env.AUTO_MODEL_ROUTER_URL,
|
|
97
|
-
readRouterConfig(),
|
|
98
|
-
parseYaml,
|
|
99
|
-
process.env.AUTO_MODEL_ROUTER_PORT,
|
|
100
|
-
embedPort,
|
|
101
|
-
);
|
|
71
|
+
// Remote mode resolves to the remote router with its key.
|
|
72
|
+
const routerUrl = routerBaseUrl();
|
|
102
73
|
|
|
103
74
|
let res: Response;
|
|
104
75
|
try {
|
|
105
76
|
res = await fetch(`${routerUrl}/v1/router/decisions?limit=20`, {
|
|
106
|
-
headers:
|
|
77
|
+
headers: routerAuthHeaders(),
|
|
107
78
|
signal: AbortSignal.timeout(3_000),
|
|
108
79
|
});
|
|
109
80
|
} catch {
|
|
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
import { parse as parseYaml } from "yaml";
|
|
16
16
|
|
|
17
17
|
import { embedPortPath, readEmbedPort } from "./embed-logic.ts";
|
|
18
|
+
import { readRemoteRouter } from "./remote-logic.ts";
|
|
18
19
|
import { resolveRouterUrl } from "./toast-logic.ts";
|
|
19
20
|
|
|
20
21
|
/** `$AUTO_MODEL_ROUTER_HOME` with `~` expanded, default `~/.auto-model-router`. */
|
|
@@ -36,6 +37,9 @@ export function readRouterConfigText(): string | null {
|
|
|
36
37
|
|
|
37
38
|
/** Base URL of the router this omp process should talk to. */
|
|
38
39
|
export function routerBaseUrl(): string {
|
|
40
|
+
// Remote mode: the router elsewhere is the router.
|
|
41
|
+
const remote = readRemoteRouter(routerHome());
|
|
42
|
+
if (remote !== null) return remote.url;
|
|
39
43
|
return resolveRouterUrl(
|
|
40
44
|
process.env.AUTO_MODEL_ROUTER_URL,
|
|
41
45
|
readRouterConfigText(),
|
|
@@ -47,6 +51,8 @@ export function routerBaseUrl(): string {
|
|
|
47
51
|
|
|
48
52
|
/** Authorization header for a router configured with `server.apiKey`. */
|
|
49
53
|
export function routerAuthHeaders(): Record<string, string> {
|
|
54
|
+
const remote = readRemoteRouter(routerHome());
|
|
55
|
+
if (remote !== null) return { authorization: `Bearer ${remote.key}` };
|
|
50
56
|
const key = process.env.AUTO_MODEL_ROUTER_API_KEY;
|
|
51
57
|
return key === undefined || key === "" ? {} : { authorization: `Bearer ${key}` };
|
|
52
58
|
}
|
package/package.json
CHANGED
package/src/cli/args.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface CliArgs {
|
|
|
20
20
|
*/
|
|
21
21
|
const BOOLEAN_FLAGS: Record<string, true> = {
|
|
22
22
|
json: true,
|
|
23
|
+
profile: true,
|
|
24
|
+
"dry-run": true,
|
|
23
25
|
write: true,
|
|
24
26
|
print: true,
|
|
25
27
|
help: true,
|
|
@@ -31,6 +33,8 @@ const COMMANDS: Record<string, true> = {
|
|
|
31
33
|
stats: true,
|
|
32
34
|
report: true,
|
|
33
35
|
export: true,
|
|
36
|
+
connect: true,
|
|
37
|
+
join: true,
|
|
34
38
|
models: true,
|
|
35
39
|
explain: true,
|
|
36
40
|
config: true,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `auto-model-router connect --url <router> --key <key>`: point this machine
|
|
3
|
+
* at a remote router (a shared one on a LAN, or the team edition). Writes
|
|
4
|
+
* `<router home>/remote.json` (the omp extensions then run in remote mode and
|
|
5
|
+
* never bind a local router), and configures every harness it finds:
|
|
6
|
+
*
|
|
7
|
+
* omp the four extensions are added to ~/.omp/agent/config.yml
|
|
8
|
+
* Hermes the provider plugin and the native plugin are copied into
|
|
9
|
+
* $HERMES_HOME/plugins and .env points them at the remote
|
|
10
|
+
* Codex ~/.codex/config.toml gains the auto-model-router provider
|
|
11
|
+
* Aider ~/.aider.conf.yml gains the base URL, key and model
|
|
12
|
+
* Claude Code ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY (printed; --profile persists)
|
|
13
|
+
*
|
|
14
|
+
* Every write is idempotent and announced. `--profile` persists the
|
|
15
|
+
* environment lines (shell rc on POSIX, user environment on Windows).
|
|
16
|
+
* `--dry-run` prints what would change. Honours PI_CODING_AGENT_DIR,
|
|
17
|
+
* HERMES_HOME and AUTO_MODEL_ROUTER_HOME, so a test can point it anywhere.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
25
|
+
import { flagString, type CliArgs } from "./args.ts";
|
|
26
|
+
|
|
27
|
+
export interface ConnectOptions {
|
|
28
|
+
url: string;
|
|
29
|
+
key: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
name: string;
|
|
32
|
+
profile: boolean;
|
|
33
|
+
dryRun: boolean;
|
|
34
|
+
/** Restrict to these harnesses (omp, hermes, codex, aider, claude); empty ⇒ every one detected. */
|
|
35
|
+
only: string[];
|
|
36
|
+
env: Record<string, string | undefined>;
|
|
37
|
+
home: string;
|
|
38
|
+
/** Where this package lives (the extensions are referenced from here). */
|
|
39
|
+
packageDir: string;
|
|
40
|
+
platform: string;
|
|
41
|
+
pathHas: (bin: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ConnectReport {
|
|
45
|
+
remoteFile: string;
|
|
46
|
+
configured: string[];
|
|
47
|
+
skipped: string[];
|
|
48
|
+
envLines: string[];
|
|
49
|
+
notes: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw);
|
|
53
|
+
|
|
54
|
+
function routerHomeOf(o: ConnectOptions): string {
|
|
55
|
+
return expand(o.env.AUTO_MODEL_ROUTER_HOME ?? join(o.home, ".auto-model-router"), o.home);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ompAgentDir(o: ConnectOptions): string {
|
|
59
|
+
const d = o.env.PI_CODING_AGENT_DIR;
|
|
60
|
+
return d !== undefined && d !== "" ? expand(d, o.home) : join(o.home, ".omp", "agent");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hermesHome(o: ConnectOptions): string {
|
|
64
|
+
const d = o.env.HERMES_HOME;
|
|
65
|
+
if (d !== undefined && d !== "") return expand(d, o.home);
|
|
66
|
+
return o.platform === "win32" ? join(o.env.LOCALAPPDATA ?? join(o.home, "AppData", "Local"), "hermes") : join(o.home, ".hermes");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const wants = (o: ConnectOptions, h: string): boolean => o.only.length === 0 || o.only.includes(h);
|
|
70
|
+
|
|
71
|
+
/** Adds lines to a YAML `extensions:` list by text, keeping everything else byte-identical. */
|
|
72
|
+
export function addExtensions(text: string, paths: readonly string[]): string {
|
|
73
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
74
|
+
const missing = paths.filter((p) => !text.includes(p));
|
|
75
|
+
if (missing.length === 0) return text;
|
|
76
|
+
const lines = missing.map((p) => ` - ${p}`);
|
|
77
|
+
const m = /^extensions:[ \t]*\r?\n/m.exec(text);
|
|
78
|
+
if (m === null) return `${text}${text.endsWith("\n") || text === "" ? "" : eol}extensions:${eol}${lines.join(eol)}${eol}`;
|
|
79
|
+
const at = m.index + m[0].length;
|
|
80
|
+
return `${text.slice(0, at)}${lines.join(eol)}${eol}${text.slice(at)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Sets `KEY=value` lines in a dotenv-style file, replacing existing keys. */
|
|
84
|
+
export function setDotenv(text: string, values: Record<string, string>): string {
|
|
85
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
86
|
+
let out = text;
|
|
87
|
+
for (const [k, v] of Object.entries(values)) {
|
|
88
|
+
const re = new RegExp(`^${k}=.*$`, "m");
|
|
89
|
+
if (re.test(out)) out = out.replace(re, `${k}=${v}`);
|
|
90
|
+
else out = `${out}${out === "" || out.endsWith("\n") ? "" : eol}${k}=${v}${eol}`;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function codexBlock(url: string): string {
|
|
96
|
+
return `
|
|
97
|
+
[model_providers.auto-model-router]
|
|
98
|
+
name = "auto-model-router (remote)"
|
|
99
|
+
base_url = "${url}/v1"
|
|
100
|
+
env_key = "AUTO_MODEL_ROUTER_API_KEY"
|
|
101
|
+
wire_api = "responses"
|
|
102
|
+
http_headers = { "X-Omp-Harness" = "codex" }
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
107
|
+
const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [] };
|
|
108
|
+
const write = (path: string, content: string): void => {
|
|
109
|
+
if (o.dryRun) return;
|
|
110
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
111
|
+
writeFileSync(path, content, "utf8");
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// 1. remote.json: what puts the omp extensions into remote mode.
|
|
115
|
+
const rh = routerHomeOf(o);
|
|
116
|
+
report.remoteFile = remoteFilePath(rh);
|
|
117
|
+
write(report.remoteFile, `${JSON.stringify({ url: o.url, key: o.key, userId: o.userId, name: o.name, joinedAtMs: Date.now() }, null, 2)}\n`);
|
|
118
|
+
|
|
119
|
+
// 2. omp
|
|
120
|
+
const agentDir = ompAgentDir(o);
|
|
121
|
+
if (wants(o, "omp") && existsSync(agentDir)) {
|
|
122
|
+
const cfgPath = join(agentDir, "config.yml");
|
|
123
|
+
const ext = ["router-toast", "router-embed", "router-configure", "router-digest"].map((n) => resolve(o.packageDir, "omp-extension", `${n}.ts`).replaceAll("\\", "/"));
|
|
124
|
+
const before = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
125
|
+
const after = addExtensions(before, ext);
|
|
126
|
+
if (after !== before) write(cfgPath, after);
|
|
127
|
+
report.configured.push(`omp (${cfgPath}; pick auto-model-router/auto as the model)`);
|
|
128
|
+
} else report.skipped.push("omp (no ~/.omp/agent)");
|
|
129
|
+
|
|
130
|
+
// 3. Hermes
|
|
131
|
+
const hh = hermesHome(o);
|
|
132
|
+
if (wants(o, "hermes") && existsSync(hh)) {
|
|
133
|
+
if (!o.dryRun) {
|
|
134
|
+
cpSync(join(o.packageDir, "hermes-plugin"), join(hh, "plugins", "model-providers", "auto-model-router"), { recursive: true });
|
|
135
|
+
cpSync(join(o.packageDir, "hermes-plugin", "native"), join(hh, "plugins", "auto-model-router"), { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
const envPath = join(hh, ".env");
|
|
138
|
+
write(envPath, setDotenv(existsSync(envPath) ? readFileSync(envPath, "utf8") : "", { AUTO_MODEL_ROUTER_URL: o.url, AUTO_MODEL_ROUTER_API_KEY: o.key }));
|
|
139
|
+
report.configured.push(`Hermes (${hh}/plugins; restart Hermes and select auto-model-router/auto)`);
|
|
140
|
+
} else report.skipped.push("Hermes (no HERMES_HOME)");
|
|
141
|
+
|
|
142
|
+
// 4. Codex
|
|
143
|
+
const codexDir = join(o.home, ".codex");
|
|
144
|
+
if (wants(o, "codex") && existsSync(codexDir)) {
|
|
145
|
+
const p = join(codexDir, "config.toml");
|
|
146
|
+
const before = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
147
|
+
if (!before.includes("[model_providers.auto-model-router]")) write(p, before + codexBlock(o.url));
|
|
148
|
+
report.configured.push(`Codex (${p}; set model = "auto" and model_provider = "auto-model-router")`);
|
|
149
|
+
report.envLines.push(`AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
|
|
150
|
+
} else report.skipped.push("Codex (no ~/.codex)");
|
|
151
|
+
|
|
152
|
+
// 5. Aider
|
|
153
|
+
const aiderConf = join(o.home, ".aider.conf.yml");
|
|
154
|
+
if (wants(o, "aider") && (existsSync(aiderConf) || o.pathHas("aider"))) {
|
|
155
|
+
const before = existsSync(aiderConf) ? readFileSync(aiderConf, "utf8") : "";
|
|
156
|
+
if (!before.includes("openai-api-base:")) write(aiderConf, `${before}${before === "" || before.endsWith("\n") ? "" : "\n"}openai-api-base: ${o.url}/v1\nopenai-api-key: ${o.key}\nmodel: openai/auto\n`);
|
|
157
|
+
report.configured.push(`Aider (${aiderConf})`);
|
|
158
|
+
} else report.skipped.push("Aider (not found)");
|
|
159
|
+
|
|
160
|
+
// 6. Claude Code: environment only.
|
|
161
|
+
if (wants(o, "claude") && o.pathHas("claude")) {
|
|
162
|
+
report.envLines.push(`ANTHROPIC_BASE_URL=${o.url}`, `ANTHROPIC_API_KEY=${o.key}`);
|
|
163
|
+
report.configured.push("Claude Code (environment)");
|
|
164
|
+
} else report.skipped.push("Claude Code (not on PATH)");
|
|
165
|
+
report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
|
|
166
|
+
report.envLines = [...new Set(report.envLines)];
|
|
167
|
+
|
|
168
|
+
// 7. Persist the environment.
|
|
169
|
+
if (o.profile && !o.dryRun) {
|
|
170
|
+
if (o.platform === "win32") {
|
|
171
|
+
for (const line of report.envLines) {
|
|
172
|
+
const [k, ...v] = line.split("=");
|
|
173
|
+
Bun.spawnSync(["setx", k!, v.join("=")], { stdout: "ignore", stderr: "ignore" });
|
|
174
|
+
}
|
|
175
|
+
report.notes.push("user environment variables set with setx; open a new terminal");
|
|
176
|
+
} else {
|
|
177
|
+
const shell = o.env.SHELL ?? "";
|
|
178
|
+
const rc = shell.includes("zsh") ? join(o.home, ".zshrc") : join(o.home, ".bashrc");
|
|
179
|
+
const block = `\n# auto-model-router remote (added by \`auto-model-router connect\`)\n${report.envLines.map((l) => `export ${l}`).join("\n")}\n`;
|
|
180
|
+
const before = existsSync(rc) ? readFileSync(rc, "utf8") : "";
|
|
181
|
+
if (!before.includes("# auto-model-router remote") && !before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8");
|
|
182
|
+
else write(rc, before.replace(/\n# auto-model-router (?:remote|team)[^\n]*\n(?:export [^\n]*\n)*/, block));
|
|
183
|
+
report.notes.push(`environment appended to ${rc}; open a new shell or source it`);
|
|
184
|
+
}
|
|
185
|
+
} else report.notes.push("add the environment lines to your shell profile, or re-run with --profile");
|
|
186
|
+
return report;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function connectCommand(args: CliArgs): Promise<void> {
|
|
190
|
+
const url = (flagString(args, "url") ?? process.env.AUTO_MODEL_ROUTER_URL ?? "").replace(/\/+$/, "");
|
|
191
|
+
const key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
|
|
192
|
+
if (url === "" || key === "") throw new Error("connect needs --url <remote router> and --key <its key>");
|
|
193
|
+
const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
|
|
194
|
+
const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
|
|
195
|
+
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
196
|
+
// Verify the key against the route every router serves before touching anything.
|
|
197
|
+
let name = flagString(args, "name") ?? "";
|
|
198
|
+
let userId = flagString(args, "user-id") ?? "";
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetch(`${url}/v1/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
|
|
201
|
+
if (res.status === 401) throw new Error("the remote router rejected this key");
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err instanceof Error && err.message.includes("rejected")) throw err;
|
|
204
|
+
console.log(`warning: could not reach ${url} to verify the key (${err instanceof Error ? err.message : String(err)}); configuring anyway`);
|
|
205
|
+
}
|
|
206
|
+
// HOME wins when set (Git Bash, WSL, CI) so a caller can redirect every write; the OS profile otherwise.
|
|
207
|
+
const home = process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir();
|
|
208
|
+
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 });
|
|
209
|
+
console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
|
|
210
|
+
for (const c of report.configured) console.log(` configured ${c}`);
|
|
211
|
+
for (const s of report.skipped) console.log(` skipped ${s}`);
|
|
212
|
+
console.log("environment:");
|
|
213
|
+
for (const l of report.envLines) console.log(` ${process.platform === "win32" ? "$env:" : "export "}${process.platform === "win32" ? l.replace("=", '="') + '"' : l}`);
|
|
214
|
+
for (const n of report.notes) console.log(`note: ${n}`);
|
|
215
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { parseArgv } from "./cli/args.ts";
|
|
|
12
12
|
import { configCommand } from "./cli/config-cmd.ts";
|
|
13
13
|
import { explainCommand } from "./cli/explain.ts";
|
|
14
14
|
import { exportCommand } from "./cli/export.ts";
|
|
15
|
+
import { connectCommand } from "./cli/connect.ts";
|
|
15
16
|
import { modelsCommand } from "./cli/models.ts";
|
|
16
17
|
import { reportCommand } from "./cli/report.ts";
|
|
17
18
|
import { serveCommand } from "./cli/serve.ts";
|
|
@@ -25,6 +26,7 @@ Usage: auto-model-router <command> [options]
|
|
|
25
26
|
stats Show routed spend, per-model share, and escalation rates
|
|
26
27
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
27
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)
|
|
28
30
|
models Show what each complexity tier would consider, and why
|
|
29
31
|
explain Route a saved request without dispatching it, and explain the decision
|
|
30
32
|
config Interactive wizard over the router's own config.yml
|
|
@@ -78,6 +80,10 @@ async function main(): Promise<number> {
|
|
|
78
80
|
case "export":
|
|
79
81
|
await exportCommand(args);
|
|
80
82
|
return 0;
|
|
83
|
+
case "connect":
|
|
84
|
+
case "join": // the first release's name
|
|
85
|
+
await connectCommand(args);
|
|
86
|
+
return 0;
|
|
81
87
|
case "models":
|
|
82
88
|
await modelsCommand(args);
|
|
83
89
|
return 0;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
|
|
7
|
+
import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Remote mode: remote.json puts the omp extensions on a router elsewhere,
|
|
11
|
+
* and `connect` configures every harness it finds without touching anything
|
|
12
|
+
* it does not recognise.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
describe("remote-logic", () => {
|
|
16
|
+
test("remote.json is parsed defensively and turned into omp's provider registration", () => {
|
|
17
|
+
expect(parseRemoteRouter("nope")).toBeNull();
|
|
18
|
+
expect(parseRemoteRouter(JSON.stringify({ url: "https://t/", key: "" }))).toBeNull();
|
|
19
|
+
const t = parseRemoteRouter(JSON.stringify({ url: "https://team.example/", key: "amrt_k", userId: "u_1", name: "Ada" }))!;
|
|
20
|
+
expect(t.url).toBe("https://team.example");
|
|
21
|
+
const reg = remoteProviderRegistration(t, "sess-1", true, { inputPerMtok: 1, outputPerMtok: 4 });
|
|
22
|
+
expect(reg).toMatchObject({ baseUrl: "https://team.example/v1", api: "openai-completions", apiKey: "amrt_k", headers: { "X-Omp-Session": "sess-1", "X-Omp-Subagent": "1" } });
|
|
23
|
+
expect(reg.models.map((m) => m.id)).toEqual(["auto", "auto-cheap", "auto-max"]);
|
|
24
|
+
expect(reg.models[0]!.cost).toEqual({ input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1.25 });
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-remote-"));
|
|
26
|
+
expect(readRemoteRouter(dir)).toBeNull();
|
|
27
|
+
writeFileSync(join(dir, "remote.json"), JSON.stringify({ url: "https://t", key: "k" }));
|
|
28
|
+
expect(readRemoteRouter(dir)?.key).toBe("k");
|
|
29
|
+
rmSync(join(dir, "remote.json"));
|
|
30
|
+
writeFileSync(join(dir, "team.json"), JSON.stringify({ url: "https://legacy", key: "k2" })); // the first release's name still works
|
|
31
|
+
expect(readRemoteRouter(dir)?.url).toBe("https://legacy");
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("text edits keep files byte-identical apart from the lines they add", () => {
|
|
36
|
+
expect(addExtensions("", ["/a.ts"])).toBe("extensions:\n - /a.ts\n");
|
|
37
|
+
expect(addExtensions("foo: 1\nextensions:\n - /x.ts\nbar: 2\n", ["/x.ts", "/a.ts"])).toBe("foo: 1\nextensions:\n - /a.ts\n - /x.ts\nbar: 2\n");
|
|
38
|
+
expect(addExtensions("foo: 1\r\n", ["/a.ts"])).toBe("foo: 1\r\nextensions:\r\n - /a.ts\r\n");
|
|
39
|
+
expect(setDotenv("A=1\nB=2\n", { B: "3", C: "4" })).toBe("A=1\nB=3\nC=4\n");
|
|
40
|
+
expect(setDotenv("", { A: "1" })).toBe("A=1\n");
|
|
41
|
+
expect(codexBlock("https://t")).toContain('base_url = "https://t/v1"');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("connect", () => {
|
|
46
|
+
function scenario(extra: Partial<ConnectOptions> = {}): { home: string; o: ConnectOptions } {
|
|
47
|
+
const home = mkdtempSync(join(tmpdir(), "amr-connect-"));
|
|
48
|
+
const agent = join(home, ".omp", "agent");
|
|
49
|
+
mkdirSync(agent, { recursive: true });
|
|
50
|
+
writeFileSync(join(agent, "config.yml"), "extensions:\n - E:/other/ext.ts\nsetupVersion: 2\n");
|
|
51
|
+
mkdirSync(join(home, ".hermes"), { recursive: true });
|
|
52
|
+
writeFileSync(join(home, ".hermes", ".env"), "OPENAI_API_KEY=x\n");
|
|
53
|
+
mkdirSync(join(home, ".codex"), { recursive: true });
|
|
54
|
+
writeFileSync(join(home, ".codex", "config.toml"), 'model = "gpt-5"\n');
|
|
55
|
+
const o: ConnectOptions = { url: "https://team.example", key: "amrt_key", userId: "u_ada", name: "Ada", profile: false, dryRun: false, only: [], env: { HOME: home, HERMES_HOME: join(home, ".hermes"), PI_CODING_AGENT_DIR: agent, AUTO_MODEL_ROUTER_HOME: join(home, ".auto-model-router") }, home, packageDir: process.cwd(), platform: "linux", pathHas: (b) => b === "claude" || b === "aider", ...extra };
|
|
56
|
+
return { home, o };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("writes remote.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
|
|
60
|
+
const { home, o } = scenario();
|
|
61
|
+
const r1 = connectRemote(o);
|
|
62
|
+
expect(existsSync(r1.remoteFile)).toBe(true);
|
|
63
|
+
expect(JSON.parse(readFileSync(r1.remoteFile, "utf8"))).toMatchObject({ url: "https://team.example", key: "amrt_key", userId: "u_ada", name: "Ada" });
|
|
64
|
+
const ompCfg = readFileSync(join(home, ".omp", "agent", "config.yml"), "utf8");
|
|
65
|
+
expect(ompCfg).toContain("extensions:\n - ");
|
|
66
|
+
expect(ompCfg).toContain("omp-extension/router-embed.ts");
|
|
67
|
+
expect(ompCfg).toContain("E:/other/ext.ts");
|
|
68
|
+
expect(ompCfg).toContain("setupVersion: 2");
|
|
69
|
+
expect(existsSync(join(home, ".hermes", "plugins", "model-providers", "auto-model-router", "__init__.py"))).toBe(true);
|
|
70
|
+
expect(existsSync(join(home, ".hermes", "plugins", "auto-model-router", "plugin.yaml"))).toBe(true);
|
|
71
|
+
expect(readFileSync(join(home, ".hermes", ".env"), "utf8")).toBe("OPENAI_API_KEY=x\nAUTO_MODEL_ROUTER_URL=https://team.example\nAUTO_MODEL_ROUTER_API_KEY=amrt_key\n");
|
|
72
|
+
expect(readFileSync(join(home, ".codex", "config.toml"), "utf8")).toContain("[model_providers.auto-model-router]");
|
|
73
|
+
expect(readFileSync(join(home, ".aider.conf.yml"), "utf8")).toContain("openai-api-base: https://team.example/v1");
|
|
74
|
+
expect(r1.configured.join("\n")).toMatch(/omp[\s\S]*Hermes[\s\S]*Codex[\s\S]*Aider[\s\S]*Claude Code/);
|
|
75
|
+
expect(r1.envLines).toEqual(["AUTO_MODEL_ROUTER_URL=https://team.example", "AUTO_MODEL_ROUTER_API_KEY=amrt_key", "ANTHROPIC_BASE_URL=https://team.example", "ANTHROPIC_API_KEY=amrt_key"]);
|
|
76
|
+
// Running again changes nothing.
|
|
77
|
+
const snapshot = [ompCfg, readFileSync(join(home, ".codex", "config.toml"), "utf8"), readFileSync(join(home, ".aider.conf.yml"), "utf8")];
|
|
78
|
+
connectRemote(o);
|
|
79
|
+
expect([readFileSync(join(home, ".omp", "agent", "config.yml"), "utf8"), readFileSync(join(home, ".codex", "config.toml"), "utf8"), readFileSync(join(home, ".aider.conf.yml"), "utf8")]).toEqual(snapshot);
|
|
80
|
+
rmSync(home, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("--harness restricts, --dry-run writes nothing, --profile appends once to the shell rc", () => {
|
|
84
|
+
const { home, o } = scenario({ only: ["omp"], dryRun: true });
|
|
85
|
+
const r = connectRemote(o);
|
|
86
|
+
expect(existsSync(r.remoteFile)).toBe(false);
|
|
87
|
+
expect(r.configured.some((c) => c.startsWith("omp"))).toBe(true);
|
|
88
|
+
expect(r.skipped.some((s) => s.startsWith("Hermes"))).toBe(true);
|
|
89
|
+
expect(existsSync(join(home, ".hermes", "plugins"))).toBe(false);
|
|
90
|
+
const { home: h2, o: o2 } = scenario({ profile: true, env: { SHELL: "/bin/zsh" } });
|
|
91
|
+
o2.env = { ...o.env, HOME: h2, HERMES_HOME: join(h2, ".hermes"), PI_CODING_AGENT_DIR: join(h2, ".omp", "agent"), AUTO_MODEL_ROUTER_HOME: join(h2, ".auto-model-router"), SHELL: "/bin/zsh" };
|
|
92
|
+
connectRemote(o2);
|
|
93
|
+
connectRemote(o2);
|
|
94
|
+
const rc = readFileSync(join(h2, ".zshrc"), "utf8");
|
|
95
|
+
expect(rc.split("# auto-model-router remote").length).toBe(2);
|
|
96
|
+
expect(rc).toContain("export ANTHROPIC_BASE_URL=https://team.example");
|
|
97
|
+
rmSync(home, { recursive: true, force: true });
|
|
98
|
+
rmSync(h2, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
});
|