auto-model-router 0.6.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 +12 -12
- package/hermes-plugin/__init__.py +5 -5
- package/omp-extension/remote-logic.ts +93 -0
- package/omp-extension/router-embed.ts +9 -9
- package/omp-extension/router-toast.ts +1 -1
- package/omp-extension/router-url.ts +6 -6
- package/package.json +1 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/{join.ts → connect.ts} +31 -34
- package/src/index.ts +5 -4
- package/test/{team-client.test.ts → remote.test.ts} +32 -29
- package/omp-extension/team-logic.ts +0 -88
|
@@ -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.6.
|
|
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.6.
|
|
17
|
+
"version": "0.6.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1247,18 +1247,18 @@ 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
|
-
##
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
extensions run in **
|
|
1255
|
-
omp's provider with
|
|
1256
|
-
`/router` hub and digest extensions talk to
|
|
1257
|
-
locally; the
|
|
1258
|
-
config, installs the Hermes plugins and points them at the
|
|
1259
|
-
and the Aider settings, and prints (or with `--profile` persists)
|
|
1260
|
-
for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
|
|
1261
|
-
Delete `
|
|
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
1262
|
|
|
1263
1263
|
## Multiple coding harnesses, one router
|
|
1264
1264
|
|
|
@@ -30,11 +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
|
-
#
|
|
34
|
-
# $HERMES_HOME/.env; the plugin then points Hermes at
|
|
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
35
|
# spawns nothing locally.
|
|
36
|
-
|
|
37
|
-
BASE_URL = f"{
|
|
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"
|
|
38
38
|
|
|
39
39
|
# The router binary, provided by `npm install -g auto-model-router`.
|
|
40
40
|
BIN = "auto-model-router"
|
|
@@ -98,7 +98,7 @@ def _spawn_router() -> None:
|
|
|
98
98
|
raise RuntimeError(f"auto-model-router did not come up on port {PORT}")
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
if not
|
|
101
|
+
if not REMOTE_URL:
|
|
102
102
|
_spawn_router()
|
|
103
103
|
profile = ProviderProfile(
|
|
104
104
|
name="auto-model-router",
|
|
@@ -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,7 +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 {
|
|
29
|
+
import { readRemoteRouter, remoteProviderRegistration } from "./remote-logic.ts";
|
|
30
30
|
import type { StartedServer } from "../src/server/http.ts";
|
|
31
31
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
32
32
|
|
|
@@ -168,14 +168,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
168
168
|
// notifications to that exact session (see router-toast.ts).
|
|
169
169
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
170
170
|
|
|
171
|
-
//
|
|
172
|
-
// router. Register it as the provider with
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
176
|
-
pi.registerProvider(EMBED_PROVIDER_ID,
|
|
177
|
-
pi.setLabel(`auto-model-router
|
|
178
|
-
writeEmbedLog(`
|
|
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
179
|
return;
|
|
180
180
|
}
|
|
181
181
|
|
|
@@ -68,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
68
68
|
// The embedded router binds a free OS-assigned port, so the URL
|
|
69
69
|
// is resolved fresh each tick from the port file the embed
|
|
70
70
|
// extension writes at session_start.
|
|
71
|
-
//
|
|
71
|
+
// Remote mode resolves to the remote router with its key.
|
|
72
72
|
const routerUrl = routerBaseUrl();
|
|
73
73
|
|
|
74
74
|
let res: Response;
|
|
@@ -15,7 +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 {
|
|
18
|
+
import { readRemoteRouter } from "./remote-logic.ts";
|
|
19
19
|
import { resolveRouterUrl } from "./toast-logic.ts";
|
|
20
20
|
|
|
21
21
|
/** `$AUTO_MODEL_ROUTER_HOME` with `~` expanded, default `~/.auto-model-router`. */
|
|
@@ -37,9 +37,9 @@ export function readRouterConfigText(): string | null {
|
|
|
37
37
|
|
|
38
38
|
/** Base URL of the router this omp process should talk to. */
|
|
39
39
|
export function routerBaseUrl(): string {
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
40
|
+
// Remote mode: the router elsewhere is the router.
|
|
41
|
+
const remote = readRemoteRouter(routerHome());
|
|
42
|
+
if (remote !== null) return remote.url;
|
|
43
43
|
return resolveRouterUrl(
|
|
44
44
|
process.env.AUTO_MODEL_ROUTER_URL,
|
|
45
45
|
readRouterConfigText(),
|
|
@@ -51,8 +51,8 @@ export function routerBaseUrl(): string {
|
|
|
51
51
|
|
|
52
52
|
/** Authorization header for a router configured with `server.apiKey`. */
|
|
53
53
|
export function routerAuthHeaders(): Record<string, string> {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
54
|
+
const remote = readRemoteRouter(routerHome());
|
|
55
|
+
if (remote !== null) return { authorization: `Bearer ${remote.key}` };
|
|
56
56
|
const key = process.env.AUTO_MODEL_ROUTER_API_KEY;
|
|
57
57
|
return key === undefined || key === "" ? {} : { authorization: `Bearer ${key}` };
|
|
58
58
|
}
|
package/package.json
CHANGED
package/src/cli/args.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `auto-model-router
|
|
3
|
-
*
|
|
4
|
-
* extensions then run in
|
|
5
|
-
* and configures every harness it finds:
|
|
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
6
|
*
|
|
7
7
|
* omp the four extensions are added to ~/.omp/agent/config.yml
|
|
8
8
|
* Hermes the provider plugin and the native plugin are copied into
|
|
9
|
-
* $HERMES_HOME/plugins and .env points them at the
|
|
9
|
+
* $HERMES_HOME/plugins and .env points them at the remote
|
|
10
10
|
* Codex ~/.codex/config.toml gains the auto-model-router provider
|
|
11
11
|
* Aider ~/.aider.conf.yml gains the base URL, key and model
|
|
12
12
|
* Claude Code ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY (printed; --profile persists)
|
|
@@ -21,10 +21,10 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileS
|
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { dirname, join, resolve } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
-
import {
|
|
24
|
+
import { remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
25
25
|
import { flagString, type CliArgs } from "./args.ts";
|
|
26
26
|
|
|
27
|
-
export interface
|
|
27
|
+
export interface ConnectOptions {
|
|
28
28
|
url: string;
|
|
29
29
|
key: string;
|
|
30
30
|
userId: string;
|
|
@@ -41,8 +41,8 @@ export interface JoinOptions {
|
|
|
41
41
|
pathHas: (bin: string) => boolean;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export interface
|
|
45
|
-
|
|
44
|
+
export interface ConnectReport {
|
|
45
|
+
remoteFile: string;
|
|
46
46
|
configured: string[];
|
|
47
47
|
skipped: string[];
|
|
48
48
|
envLines: string[];
|
|
@@ -51,22 +51,22 @@ export interface JoinReport {
|
|
|
51
51
|
|
|
52
52
|
const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw);
|
|
53
53
|
|
|
54
|
-
function routerHomeOf(o:
|
|
54
|
+
function routerHomeOf(o: ConnectOptions): string {
|
|
55
55
|
return expand(o.env.AUTO_MODEL_ROUTER_HOME ?? join(o.home, ".auto-model-router"), o.home);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function ompAgentDir(o:
|
|
58
|
+
function ompAgentDir(o: ConnectOptions): string {
|
|
59
59
|
const d = o.env.PI_CODING_AGENT_DIR;
|
|
60
60
|
return d !== undefined && d !== "" ? expand(d, o.home) : join(o.home, ".omp", "agent");
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function hermesHome(o:
|
|
63
|
+
function hermesHome(o: ConnectOptions): string {
|
|
64
64
|
const d = o.env.HERMES_HOME;
|
|
65
65
|
if (d !== undefined && d !== "") return expand(d, o.home);
|
|
66
66
|
return o.platform === "win32" ? join(o.env.LOCALAPPDATA ?? join(o.home, "AppData", "Local"), "hermes") : join(o.home, ".hermes");
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const wants = (o:
|
|
69
|
+
const wants = (o: ConnectOptions, h: string): boolean => o.only.length === 0 || o.only.includes(h);
|
|
70
70
|
|
|
71
71
|
/** Adds lines to a YAML `extensions:` list by text, keeping everything else byte-identical. */
|
|
72
72
|
export function addExtensions(text: string, paths: readonly string[]): string {
|
|
@@ -95,7 +95,7 @@ export function setDotenv(text: string, values: Record<string, string>): string
|
|
|
95
95
|
export function codexBlock(url: string): string {
|
|
96
96
|
return `
|
|
97
97
|
[model_providers.auto-model-router]
|
|
98
|
-
name = "auto-model-router (
|
|
98
|
+
name = "auto-model-router (remote)"
|
|
99
99
|
base_url = "${url}/v1"
|
|
100
100
|
env_key = "AUTO_MODEL_ROUTER_API_KEY"
|
|
101
101
|
wire_api = "responses"
|
|
@@ -103,18 +103,18 @@ http_headers = { "X-Omp-Harness" = "codex" }
|
|
|
103
103
|
`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
export function
|
|
107
|
-
const report:
|
|
106
|
+
export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
107
|
+
const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [] };
|
|
108
108
|
const write = (path: string, content: string): void => {
|
|
109
109
|
if (o.dryRun) return;
|
|
110
110
|
mkdirSync(dirname(path), { recursive: true });
|
|
111
111
|
writeFileSync(path, content, "utf8");
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
-
// 1.
|
|
114
|
+
// 1. remote.json: what puts the omp extensions into remote mode.
|
|
115
115
|
const rh = routerHomeOf(o);
|
|
116
|
-
report.
|
|
117
|
-
write(report.
|
|
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
118
|
|
|
119
119
|
// 2. omp
|
|
120
120
|
const agentDir = ompAgentDir(o);
|
|
@@ -176,40 +176,37 @@ export function joinTeam(o: JoinOptions): JoinReport {
|
|
|
176
176
|
} else {
|
|
177
177
|
const shell = o.env.SHELL ?? "";
|
|
178
178
|
const rc = shell.includes("zsh") ? join(o.home, ".zshrc") : join(o.home, ".bashrc");
|
|
179
|
-
const block = `\n# auto-model-router
|
|
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
180
|
const before = existsSync(rc) ? readFileSync(rc, "utf8") : "";
|
|
181
|
-
if (!before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8");
|
|
182
|
-
else write(rc, before.replace(/\n# auto-model-router team[^\n]*\n(?:export [^\n]*\n)*/, block));
|
|
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
183
|
report.notes.push(`environment appended to ${rc}; open a new shell or source it`);
|
|
184
184
|
}
|
|
185
185
|
} else report.notes.push("add the environment lines to your shell profile, or re-run with --profile");
|
|
186
186
|
return report;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
export async function
|
|
189
|
+
export async function connectCommand(args: CliArgs): Promise<void> {
|
|
190
190
|
const url = (flagString(args, "url") ?? process.env.AUTO_MODEL_ROUTER_URL ?? "").replace(/\/+$/, "");
|
|
191
191
|
const key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
|
|
192
|
-
if (url === "" || key === "") throw new Error("
|
|
192
|
+
if (url === "" || key === "") throw new Error("connect needs --url <remote router> and --key <its key>");
|
|
193
193
|
const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
|
|
194
194
|
const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
|
|
195
195
|
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
196
|
-
// Verify the key before touching anything.
|
|
196
|
+
// Verify the key against the route every router serves before touching anything.
|
|
197
197
|
let name = flagString(args, "name") ?? "";
|
|
198
198
|
let userId = flagString(args, "user-id") ?? "";
|
|
199
199
|
try {
|
|
200
|
-
const res = await fetch(`${url}/
|
|
201
|
-
if (res.status === 401) throw new Error("the
|
|
202
|
-
if (res.ok) {
|
|
203
|
-
const me = (await res.json()) as { user?: { id?: string; name?: string } };
|
|
204
|
-
userId = me.user?.id ?? userId;
|
|
205
|
-
name = me.user?.name ?? name;
|
|
206
|
-
}
|
|
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");
|
|
207
202
|
} catch (err) {
|
|
208
203
|
if (err instanceof Error && err.message.includes("rejected")) throw err;
|
|
209
204
|
console.log(`warning: could not reach ${url} to verify the key (${err instanceof Error ? err.message : String(err)}); configuring anyway`);
|
|
210
205
|
}
|
|
211
|
-
|
|
212
|
-
|
|
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}`}`);
|
|
213
210
|
for (const c of report.configured) console.log(` configured ${c}`);
|
|
214
211
|
for (const s of report.skipped) console.log(` skipped ${s}`);
|
|
215
212
|
console.log("environment:");
|
package/src/index.ts
CHANGED
|
@@ -12,7 +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 {
|
|
15
|
+
import { connectCommand } from "./cli/connect.ts";
|
|
16
16
|
import { modelsCommand } from "./cli/models.ts";
|
|
17
17
|
import { reportCommand } from "./cli/report.ts";
|
|
18
18
|
import { serveCommand } from "./cli/serve.ts";
|
|
@@ -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
|
-
|
|
29
|
+
connect Point this machine at a remote router (--url, --key; --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
|
|
@@ -80,8 +80,9 @@ async function main(): Promise<number> {
|
|
|
80
80
|
case "export":
|
|
81
81
|
await exportCommand(args);
|
|
82
82
|
return 0;
|
|
83
|
-
case "
|
|
84
|
-
|
|
83
|
+
case "connect":
|
|
84
|
+
case "join": // the first release's name
|
|
85
|
+
await connectCommand(args);
|
|
85
86
|
return 0;
|
|
86
87
|
case "models":
|
|
87
88
|
await modelsCommand(args);
|
|
@@ -3,29 +3,32 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
-
import { addExtensions, codexBlock,
|
|
7
|
-
import {
|
|
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
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* and `
|
|
12
|
-
* does not recognise.
|
|
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
13
|
*/
|
|
14
14
|
|
|
15
|
-
describe("
|
|
16
|
-
test("
|
|
17
|
-
expect(
|
|
18
|
-
expect(
|
|
19
|
-
const t =
|
|
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
20
|
expect(t.url).toBe("https://team.example");
|
|
21
|
-
const reg =
|
|
21
|
+
const reg = remoteProviderRegistration(t, "sess-1", true, { inputPerMtok: 1, outputPerMtok: 4 });
|
|
22
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
23
|
expect(reg.models.map((m) => m.id)).toEqual(["auto", "auto-cheap", "auto-max"]);
|
|
24
24
|
expect(reg.models[0]!.cost).toEqual({ input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1.25 });
|
|
25
|
-
const dir = mkdtempSync(join(tmpdir(), "amr-
|
|
26
|
-
expect(
|
|
27
|
-
writeFileSync(join(dir, "
|
|
28
|
-
expect(
|
|
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");
|
|
29
32
|
rmSync(dir, { recursive: true, force: true });
|
|
30
33
|
});
|
|
31
34
|
|
|
@@ -39,9 +42,9 @@ describe("team-logic", () => {
|
|
|
39
42
|
});
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
describe("
|
|
43
|
-
function scenario(extra: Partial<
|
|
44
|
-
const home = mkdtempSync(join(tmpdir(), "amr-
|
|
45
|
+
describe("connect", () => {
|
|
46
|
+
function scenario(extra: Partial<ConnectOptions> = {}): { home: string; o: ConnectOptions } {
|
|
47
|
+
const home = mkdtempSync(join(tmpdir(), "amr-connect-"));
|
|
45
48
|
const agent = join(home, ".omp", "agent");
|
|
46
49
|
mkdirSync(agent, { recursive: true });
|
|
47
50
|
writeFileSync(join(agent, "config.yml"), "extensions:\n - E:/other/ext.ts\nsetupVersion: 2\n");
|
|
@@ -49,15 +52,15 @@ describe("join", () => {
|
|
|
49
52
|
writeFileSync(join(home, ".hermes", ".env"), "OPENAI_API_KEY=x\n");
|
|
50
53
|
mkdirSync(join(home, ".codex"), { recursive: true });
|
|
51
54
|
writeFileSync(join(home, ".codex", "config.toml"), 'model = "gpt-5"\n');
|
|
52
|
-
const o:
|
|
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 };
|
|
53
56
|
return { home, o };
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
test("writes
|
|
59
|
+
test("writes remote.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
|
|
57
60
|
const { home, o } = scenario();
|
|
58
|
-
const r1 =
|
|
59
|
-
expect(existsSync(r1.
|
|
60
|
-
expect(JSON.parse(readFileSync(r1.
|
|
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" });
|
|
61
64
|
const ompCfg = readFileSync(join(home, ".omp", "agent", "config.yml"), "utf8");
|
|
62
65
|
expect(ompCfg).toContain("extensions:\n - ");
|
|
63
66
|
expect(ompCfg).toContain("omp-extension/router-embed.ts");
|
|
@@ -72,24 +75,24 @@ describe("join", () => {
|
|
|
72
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"]);
|
|
73
76
|
// Running again changes nothing.
|
|
74
77
|
const snapshot = [ompCfg, readFileSync(join(home, ".codex", "config.toml"), "utf8"), readFileSync(join(home, ".aider.conf.yml"), "utf8")];
|
|
75
|
-
|
|
78
|
+
connectRemote(o);
|
|
76
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);
|
|
77
80
|
rmSync(home, { recursive: true, force: true });
|
|
78
81
|
});
|
|
79
82
|
|
|
80
83
|
test("--harness restricts, --dry-run writes nothing, --profile appends once to the shell rc", () => {
|
|
81
84
|
const { home, o } = scenario({ only: ["omp"], dryRun: true });
|
|
82
|
-
const r =
|
|
83
|
-
expect(existsSync(r.
|
|
85
|
+
const r = connectRemote(o);
|
|
86
|
+
expect(existsSync(r.remoteFile)).toBe(false);
|
|
84
87
|
expect(r.configured.some((c) => c.startsWith("omp"))).toBe(true);
|
|
85
88
|
expect(r.skipped.some((s) => s.startsWith("Hermes"))).toBe(true);
|
|
86
89
|
expect(existsSync(join(home, ".hermes", "plugins"))).toBe(false);
|
|
87
90
|
const { home: h2, o: o2 } = scenario({ profile: true, env: { SHELL: "/bin/zsh" } });
|
|
88
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" };
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
connectRemote(o2);
|
|
93
|
+
connectRemote(o2);
|
|
91
94
|
const rc = readFileSync(join(h2, ".zshrc"), "utf8");
|
|
92
|
-
expect(rc.split("# auto-model-router
|
|
95
|
+
expect(rc.split("# auto-model-router remote").length).toBe(2);
|
|
93
96
|
expect(rc).toContain("export ANTHROPIC_BASE_URL=https://team.example");
|
|
94
97
|
rmSync(home, { recursive: true, force: true });
|
|
95
98
|
rmSync(h2, { recursive: true, force: true });
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Team client mode for the omp extensions. `auto-model-router join` writes
|
|
3
|
-
* `<router home>/team.json`; when it exists the embed extension registers the
|
|
4
|
-
* TEAM endpoint as omp's provider instead of binding a local router, and the
|
|
5
|
-
* toast, hub and digest extensions talk to the team with the member's key.
|
|
6
|
-
* Nothing is classified or selected locally: the team router is the router.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
|
|
12
|
-
export const TEAM_FILE = "team.json";
|
|
13
|
-
|
|
14
|
-
export interface TeamClient {
|
|
15
|
-
/** The team endpoint, no trailing slash, e.g. https://team.example.com */
|
|
16
|
-
url: string;
|
|
17
|
-
/** The member's user key (`amrt_…`). */
|
|
18
|
-
key: string;
|
|
19
|
-
userId: string;
|
|
20
|
-
name: string;
|
|
21
|
-
joinedAtMs: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function teamFilePath(routerHome: string): string {
|
|
25
|
-
return join(routerHome, TEAM_FILE);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Parses team.json defensively; anything malformed ⇒ not in team mode. */
|
|
29
|
-
export function parseTeamClient(text: string): TeamClient | null {
|
|
30
|
-
try {
|
|
31
|
-
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
32
|
-
if (typeof raw.url !== "string" || typeof raw.key !== "string" || raw.url === "" || raw.key === "") return null;
|
|
33
|
-
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 };
|
|
34
|
-
} catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function readTeamClient(routerHome: string): TeamClient | null {
|
|
40
|
-
const path = teamFilePath(routerHome);
|
|
41
|
-
if (!existsSync(path)) return null;
|
|
42
|
-
try {
|
|
43
|
-
return parseTeamClient(readFileSync(path, "utf8"));
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The virtual models the team serves; costs are the router's fallback blend so omp can show estimates. */
|
|
50
|
-
export const TEAM_MODELS: readonly { id: string; name: string }[] = [
|
|
51
|
-
{ id: "auto", name: "auto (team)" },
|
|
52
|
-
{ id: "auto-cheap", name: "auto-cheap (team)" },
|
|
53
|
-
{ id: "auto-max", name: "auto-max (team)" },
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* omp's provider registration for team mode: the team's /v1 with the member
|
|
58
|
-
* key, the session and subagent tags the team forwards to the router, and the
|
|
59
|
-
* virtual models. Costs are USD per million tokens, like the embedded config.
|
|
60
|
-
*/
|
|
61
|
-
export function teamProviderRegistration(team: TeamClient, sessionId: string, subagent: boolean, blend: { inputPerMtok: number; outputPerMtok: number }): {
|
|
62
|
-
baseUrl: string;
|
|
63
|
-
api: string;
|
|
64
|
-
apiKey: string;
|
|
65
|
-
headers: Record<string, string>;
|
|
66
|
-
models: { id: string; name: string; api: string; reasoning: boolean; input: string[]; contextWindow: number; maxTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number } }[];
|
|
67
|
-
} {
|
|
68
|
-
const headers: Record<string, string> = {};
|
|
69
|
-
if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
|
|
70
|
-
if (subagent) headers["X-Omp-Subagent"] = "1";
|
|
71
|
-
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
72
|
-
return {
|
|
73
|
-
baseUrl: `${team.url}/v1`,
|
|
74
|
-
api: "openai-completions",
|
|
75
|
-
apiKey: team.key,
|
|
76
|
-
headers,
|
|
77
|
-
models: TEAM_MODELS.map((m) => ({
|
|
78
|
-
id: m.id,
|
|
79
|
-
name: m.name,
|
|
80
|
-
api: "openai-completions",
|
|
81
|
-
reasoning: false,
|
|
82
|
-
input: ["text", "image"],
|
|
83
|
-
contextWindow: 200_000,
|
|
84
|
-
maxTokens: 32_000,
|
|
85
|
-
cost: { input: round(blend.inputPerMtok), output: round(blend.outputPerMtok), cacheRead: round(blend.inputPerMtok * 0.1), cacheWrite: round(blend.inputPerMtok * 1.25) },
|
|
86
|
-
})),
|
|
87
|
-
};
|
|
88
|
-
}
|