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.
@@ -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.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.6.0",
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
- ## Joining a team router
1251
-
1252
- The team edition's install script runs `auto-model-router join --url <team> --key <key>`
1253
- on a member's machine. It writes `<router home>/team.json`, after which the omp
1254
- extensions run in **team-client mode**: the embed extension registers the team endpoint as
1255
- omp's provider with the member's key instead of binding a local router, and the toast,
1256
- `/router` hub and digest extensions talk to the team. Nothing is classified or selected
1257
- locally; the team router is the router. The same command adds the extensions to omp's
1258
- config, installs the Hermes plugins and points them at the team, adds the Codex provider
1259
- and the Aider settings, and prints (or with `--profile` persists) the environment lines
1260
- for Claude Code. `--harness omp,hermes` restricts it; `--dry-run` shows the changes.
1261
- Delete `team.json` to leave team mode.
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
- # Team mode: `auto-model-router join` sets AUTO_MODEL_ROUTER_URL (and the key) in
34
- # $HERMES_HOME/.env; the plugin then points Hermes at the team endpoint and
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
- TEAM_URL = os.environ.get("AUTO_MODEL_ROUTER_URL", "").rstrip("/")
37
- BASE_URL = f"{TEAM_URL}/v1" if TEAM_URL else f"http://127.0.0.1:{PORT}/v1"
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 TEAM_URL:
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 { readTeamClient, teamProviderRegistration } from "./team-logic.ts";
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
- // Team-client mode (`auto-model-router join`): the team endpoint is the
172
- // router. Register it as the provider with the member's key and bind
173
- // nothing locally; the other extensions find the team through team.json.
174
- const team = readTeamClient(routerHome());
175
- if (team !== null) {
176
- pi.registerProvider(EMBED_PROVIDER_ID, teamProviderRegistration(team, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend));
177
- pi.setLabel(`auto-model-router team (${team.url.replace(/^https?:\/\//, "")})`);
178
- writeEmbedLog(`team mode url=${team.url} user=${team.userId} session=${sessionId}`);
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
- // Team-client mode resolves to the team endpoint with the member key.
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 { readTeamClient } from "./team-logic.ts";
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
- // Team-client mode: the team endpoint is the router.
41
- const team = readTeamClient(routerHome());
42
- if (team !== null) return team.url;
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 team = readTeamClient(routerHome());
55
- if (team !== null) return { authorization: `Bearer ${team.key}` };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
package/src/cli/args.ts CHANGED
@@ -33,6 +33,7 @@ const COMMANDS: Record<string, true> = {
33
33
  stats: true,
34
34
  report: true,
35
35
  export: true,
36
+ connect: true,
36
37
  join: true,
37
38
  models: true,
38
39
  explain: true,
@@ -1,12 +1,12 @@
1
1
  /**
2
- * `auto-model-router join --url <team> --key <key>`: make this machine a
3
- * member of a team router. Writes `<router home>/team.json` (the omp
4
- * extensions then run in team-client mode and never bind a local router),
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 team
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 { teamFilePath } from "../../omp-extension/team-logic.ts";
24
+ import { remoteFilePath } from "../../omp-extension/remote-logic.ts";
25
25
  import { flagString, type CliArgs } from "./args.ts";
26
26
 
27
- export interface JoinOptions {
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 JoinReport {
45
- teamFile: string;
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: JoinOptions): string {
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: JoinOptions): string {
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: JoinOptions): string {
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: JoinOptions, h: string): boolean => o.only.length === 0 || o.only.includes(h);
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 (team)"
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 joinTeam(o: JoinOptions): JoinReport {
107
- const report: JoinReport = { teamFile: "", configured: [], skipped: [], envLines: [], notes: [] };
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. team.json: what puts the omp extensions into team-client mode.
114
+ // 1. remote.json: what puts the omp extensions into remote mode.
115
115
  const rh = routerHomeOf(o);
116
- report.teamFile = teamFilePath(rh);
117
- write(report.teamFile, `${JSON.stringify({ url: o.url, key: o.key, userId: o.userId, name: o.name, joinedAtMs: Date.now() }, null, 2)}\n`);
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 team (added by \`auto-model-router join\`)\n${report.envLines.map((l) => `export ${l}`).join("\n")}\n`;
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 joinCommand(args: CliArgs): Promise<void> {
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("join needs --url <team endpoint> and --key <your team key>");
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}/me`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
201
- if (res.status === 401) throw new Error("the team rejected this key");
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
- const report = joinTeam({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home: homedir(), packageDir, platform: process.platform, pathHas });
212
- console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.teamFile}${name === "" ? "" : ` for ${name}`}`);
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 { joinCommand } from "./cli/join.ts";
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
- join Make this machine a member of a team router (--url, --key; --profile persists the environment)
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 "join":
84
- await joinCommand(args);
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, joinTeam, setDotenv, type JoinOptions } from "../src/cli/join.ts";
7
- import { parseTeamClient, readTeamClient, teamProviderRegistration } from "../omp-extension/team-logic.ts";
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
- * Team-client mode: team.json puts the omp extensions on the team endpoint,
11
- * and `join` configures every harness it finds without touching anything it
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("team-logic", () => {
16
- test("team.json is parsed defensively and turned into omp's provider registration", () => {
17
- expect(parseTeamClient("nope")).toBeNull();
18
- expect(parseTeamClient(JSON.stringify({ url: "https://t/", key: "" }))).toBeNull();
19
- const t = parseTeamClient(JSON.stringify({ url: "https://team.example/", key: "amrt_k", userId: "u_1", name: "Ada" }))!;
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 = teamProviderRegistration(t, "sess-1", true, { inputPerMtok: 1, outputPerMtok: 4 });
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-team-client-"));
26
- expect(readTeamClient(dir)).toBeNull();
27
- writeFileSync(join(dir, "team.json"), JSON.stringify({ url: "https://t", key: "k" }));
28
- expect(readTeamClient(dir)?.key).toBe("k");
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("join", () => {
43
- function scenario(extra: Partial<JoinOptions> = {}): { home: string; o: JoinOptions } {
44
- const home = mkdtempSync(join(tmpdir(), "amr-join-"));
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: JoinOptions = { 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 };
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 team.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
59
+ test("writes remote.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
57
60
  const { home, o } = scenario();
58
- const r1 = joinTeam(o);
59
- expect(existsSync(r1.teamFile)).toBe(true);
60
- expect(JSON.parse(readFileSync(r1.teamFile, "utf8"))).toMatchObject({ url: "https://team.example", key: "amrt_key", userId: "u_ada", name: "Ada" });
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
- joinTeam(o);
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 = joinTeam(o);
83
- expect(existsSync(r.teamFile)).toBe(false);
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
- joinTeam(o2);
90
- joinTeam(o2);
92
+ connectRemote(o2);
93
+ connectRemote(o2);
91
94
  const rc = readFileSync(join(h2, ".zshrc"), "utf8");
92
- expect(rc.split("# auto-model-router team").length).toBe(2);
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
- }