auto-model-router 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.5.0",
10
+ "version": "0.6.0",
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.5.0",
17
+ "version": "0.6.0",
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
+ ## 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.
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
- BASE_URL = f"http://127.0.0.1:{PORT}/v1"
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
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"
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
- _spawn_router()
98
-
101
+ if not TEAM_URL:
102
+ _spawn_router()
99
103
  profile = ProviderProfile(
100
104
  name="auto-model-router",
101
105
  api_mode="chat_completions",
@@ -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 { readTeamClient, teamProviderRegistration } from "./team-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
+ // 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}`);
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 { embedPortPath, readEmbedPort } from "./embed-logic.ts";
35
- import { newestId, resolveRouterUrl, selectToasts, type ToastDecision } from "./toast-logic.ts";
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
- const embedPort = readEmbedPort(embedPortFile());
95
- const routerUrl = resolveRouterUrl(
96
- process.env.AUTO_MODEL_ROUTER_URL,
97
- readRouterConfig(),
98
- parseYaml,
99
- process.env.AUTO_MODEL_ROUTER_PORT,
100
- embedPort,
101
- );
71
+ // Team-client mode resolves to the team endpoint with the member 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: ROUTER_API_KEY === undefined ? {} : { authorization: `Bearer ${ROUTER_API_KEY}` },
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 { readTeamClient } from "./team-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
+ // Team-client mode: the team endpoint is the router.
41
+ const team = readTeamClient(routerHome());
42
+ if (team !== null) return team.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 team = readTeamClient(routerHome());
55
+ if (team !== null) return { authorization: `Bearer ${team.key}` };
50
56
  const key = process.env.AUTO_MODEL_ROUTER_API_KEY;
51
57
  return key === undefined || key === "" ? {} : { authorization: `Bearer ${key}` };
52
58
  }
@@ -0,0 +1,88 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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
@@ -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,7 @@ const COMMANDS: Record<string, true> = {
31
33
  stats: true,
32
34
  report: true,
33
35
  export: true,
36
+ join: true,
34
37
  models: true,
35
38
  explain: true,
36
39
  config: true,
@@ -0,0 +1,218 @@
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:
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 team
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 { teamFilePath } from "../../omp-extension/team-logic.ts";
25
+ import { flagString, type CliArgs } from "./args.ts";
26
+
27
+ export interface JoinOptions {
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 JoinReport {
45
+ teamFile: 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: JoinOptions): string {
55
+ return expand(o.env.AUTO_MODEL_ROUTER_HOME ?? join(o.home, ".auto-model-router"), o.home);
56
+ }
57
+
58
+ function ompAgentDir(o: JoinOptions): 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: JoinOptions): 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: JoinOptions, 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 (team)"
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 joinTeam(o: JoinOptions): JoinReport {
107
+ const report: JoinReport = { teamFile: "", 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. team.json: what puts the omp extensions into team-client mode.
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`);
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 team (added by \`auto-model-router join\`)\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 team")) appendFileSync(rc, block, "utf8");
182
+ else write(rc, before.replace(/\n# auto-model-router 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 joinCommand(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("join needs --url <team endpoint> and --key <your team 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 before touching anything.
197
+ let name = flagString(args, "name") ?? "";
198
+ let userId = flagString(args, "user-id") ?? "";
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
+ }
207
+ } catch (err) {
208
+ if (err instanceof Error && err.message.includes("rejected")) throw err;
209
+ console.log(`warning: could not reach ${url} to verify the key (${err instanceof Error ? err.message : String(err)}); configuring anyway`);
210
+ }
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}`}`);
213
+ for (const c of report.configured) console.log(` configured ${c}`);
214
+ for (const s of report.skipped) console.log(` skipped ${s}`);
215
+ console.log("environment:");
216
+ for (const l of report.envLines) console.log(` ${process.platform === "win32" ? "$env:" : "export "}${process.platform === "win32" ? l.replace("=", '="') + '"' : l}`);
217
+ for (const n of report.notes) console.log(`note: ${n}`);
218
+ }
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 { joinCommand } from "./cli/join.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
+ join Make this machine a member of a team 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,9 @@ async function main(): Promise<number> {
78
80
  case "export":
79
81
  await exportCommand(args);
80
82
  return 0;
83
+ case "join":
84
+ await joinCommand(args);
85
+ return 0;
81
86
  case "models":
82
87
  await modelsCommand(args);
83
88
  return 0;
@@ -0,0 +1,97 @@
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, joinTeam, setDotenv, type JoinOptions } from "../src/cli/join.ts";
7
+ import { parseTeamClient, readTeamClient, teamProviderRegistration } from "../omp-extension/team-logic.ts";
8
+
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.
13
+ */
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" }))!;
20
+ expect(t.url).toBe("https://team.example");
21
+ const reg = teamProviderRegistration(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-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");
29
+ rmSync(dir, { recursive: true, force: true });
30
+ });
31
+
32
+ test("text edits keep files byte-identical apart from the lines they add", () => {
33
+ expect(addExtensions("", ["/a.ts"])).toBe("extensions:\n - /a.ts\n");
34
+ 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");
35
+ expect(addExtensions("foo: 1\r\n", ["/a.ts"])).toBe("foo: 1\r\nextensions:\r\n - /a.ts\r\n");
36
+ expect(setDotenv("A=1\nB=2\n", { B: "3", C: "4" })).toBe("A=1\nB=3\nC=4\n");
37
+ expect(setDotenv("", { A: "1" })).toBe("A=1\n");
38
+ expect(codexBlock("https://t")).toContain('base_url = "https://t/v1"');
39
+ });
40
+ });
41
+
42
+ describe("join", () => {
43
+ function scenario(extra: Partial<JoinOptions> = {}): { home: string; o: JoinOptions } {
44
+ const home = mkdtempSync(join(tmpdir(), "amr-join-"));
45
+ const agent = join(home, ".omp", "agent");
46
+ mkdirSync(agent, { recursive: true });
47
+ writeFileSync(join(agent, "config.yml"), "extensions:\n - E:/other/ext.ts\nsetupVersion: 2\n");
48
+ mkdirSync(join(home, ".hermes"), { recursive: true });
49
+ writeFileSync(join(home, ".hermes", ".env"), "OPENAI_API_KEY=x\n");
50
+ mkdirSync(join(home, ".codex"), { recursive: true });
51
+ 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 };
53
+ return { home, o };
54
+ }
55
+
56
+ test("writes team.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
57
+ 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 ompCfg = readFileSync(join(home, ".omp", "agent", "config.yml"), "utf8");
62
+ expect(ompCfg).toContain("extensions:\n - ");
63
+ expect(ompCfg).toContain("omp-extension/router-embed.ts");
64
+ expect(ompCfg).toContain("E:/other/ext.ts");
65
+ expect(ompCfg).toContain("setupVersion: 2");
66
+ expect(existsSync(join(home, ".hermes", "plugins", "model-providers", "auto-model-router", "__init__.py"))).toBe(true);
67
+ expect(existsSync(join(home, ".hermes", "plugins", "auto-model-router", "plugin.yaml"))).toBe(true);
68
+ 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");
69
+ expect(readFileSync(join(home, ".codex", "config.toml"), "utf8")).toContain("[model_providers.auto-model-router]");
70
+ expect(readFileSync(join(home, ".aider.conf.yml"), "utf8")).toContain("openai-api-base: https://team.example/v1");
71
+ expect(r1.configured.join("\n")).toMatch(/omp[\s\S]*Hermes[\s\S]*Codex[\s\S]*Aider[\s\S]*Claude Code/);
72
+ 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
+ // Running again changes nothing.
74
+ const snapshot = [ompCfg, readFileSync(join(home, ".codex", "config.toml"), "utf8"), readFileSync(join(home, ".aider.conf.yml"), "utf8")];
75
+ joinTeam(o);
76
+ 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
+ rmSync(home, { recursive: true, force: true });
78
+ });
79
+
80
+ test("--harness restricts, --dry-run writes nothing, --profile appends once to the shell rc", () => {
81
+ const { home, o } = scenario({ only: ["omp"], dryRun: true });
82
+ const r = joinTeam(o);
83
+ expect(existsSync(r.teamFile)).toBe(false);
84
+ expect(r.configured.some((c) => c.startsWith("omp"))).toBe(true);
85
+ expect(r.skipped.some((s) => s.startsWith("Hermes"))).toBe(true);
86
+ expect(existsSync(join(home, ".hermes", "plugins"))).toBe(false);
87
+ const { home: h2, o: o2 } = scenario({ profile: true, env: { SHELL: "/bin/zsh" } });
88
+ 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);
91
+ const rc = readFileSync(join(h2, ".zshrc"), "utf8");
92
+ expect(rc.split("# auto-model-router team").length).toBe(2);
93
+ expect(rc).toContain("export ANTHROPIC_BASE_URL=https://team.example");
94
+ rmSync(home, { recursive: true, force: true });
95
+ rmSync(h2, { recursive: true, force: true });
96
+ });
97
+ });