auto-model-router 0.4.14 → 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.4.14",
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.4.14",
17
+ "version": "0.6.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1184,6 +1184,51 @@ text, not the conversation, so a hard task that only becomes hard three tool
1184
1184
  calls in stays on the router (the router's own escalation still applies
1185
1185
  there); and the switch happens at prompt boundaries, never mid-turn.
1186
1186
 
1187
+ ## Claude Code (Anthropic Messages API)
1188
+
1189
+ The router also speaks the Anthropic Messages API, which is the only wire Claude Code
1190
+ uses. Point Claude Code at the router and every turn is routed like any other harness's,
1191
+ to OpenRouter, Ollama Cloud, or whatever upstream is configured:
1192
+
1193
+ ```bash
1194
+ export ANTHROPIC_BASE_URL=http://127.0.0.1:8788
1195
+ export ANTHROPIC_API_KEY=<server.apiKey, or any string when the router has no key>
1196
+ claude
1197
+ ```
1198
+
1199
+ `POST /v1/messages` (streaming and not) and `POST /v1/messages/count_tokens` are served;
1200
+ the key may arrive as `x-api-key` or as a bearer. Claude Code asks for `claude-*` model
1201
+ names, which `anthropic.models` maps to profiles (first matching glob wins):
1202
+
1203
+ ```yaml
1204
+ anthropic:
1205
+ models:
1206
+ "*haiku*": auto-cheap # Claude Code's background chores
1207
+ "claude-*": auto # real turns; try auto-max for an opus-only feel
1208
+ ```
1209
+
1210
+ Profile ids pass through, so `ANTHROPIC_MODEL=auto-max` works too. The harness id defaults
1211
+ to `claude-code` (from the user agent) and the session id is taken from the `metadata`
1212
+ Claude Code sends, so reports, budgets and the team edition see it like any other harness.
1213
+
1214
+ What is translated: system prompts (string or blocks), text, image, `tool_use` and
1215
+ `tool_result` blocks, custom tools and `tool_choice` (including
1216
+ `disable_parallel_tool_use`), `stop_sequences`, `thinking` budgets and `output_config.effort`
1217
+ (as reasoning effort), and back: text, `tool_use` and `thinking` blocks, the four stop
1218
+ reasons, and usage with cache read and cache creation counts. The routing summary rides on
1219
+ `message_delta` as `x_auto_model_router`.
1220
+
1221
+ Not available through the router: Anthropic server-side tools (web search, web fetch, code
1222
+ execution) and Anthropic-schema client tools (`bash_*`, `text_editor_*`) are dropped from
1223
+ the tool list, since no upstream serves them; thinking blocks come back unsigned and are
1224
+ dropped again on replay; `count_tokens` is the router's own estimate. Client `cache_control`
1225
+ markers are replaced by the router's own breakpoint plan.
1226
+
1227
+ Claude Code prices its own cost line from the Claude model name it asked for, so the
1228
+ figure it shows is not what was spent; the router's ledger (`/router report`, the team
1229
+ edition) is. Verified with Claude Code 2.1.263 headless (`claude -p` with the Read tool)
1230
+ routed to a DeepSeek model; the captured request is `test/fixtures/harness/claude-code.json`.
1231
+
1187
1232
  ## Per-request routing policy
1188
1233
 
1189
1234
  A front door in front of the router (the team edition, or any proxy that
@@ -1202,6 +1247,19 @@ override already pinned one. Every field is optional; a malformed header is
1202
1247
  ignored rather than failing the turn. The decision trail records what the
1203
1248
  policy changed (`policy: …`).
1204
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
+
1205
1263
  ## Multiple coding harnesses, one router
1206
1264
 
1207
1265
  **One router process for everything.** omp's embed extension binds a private
@@ -1229,6 +1287,7 @@ plus a harness header; the rest needs the harness's own hook API.
1229
1287
  | Harness | Wire | Harness id | Session id | Subagent flag | Toast | `/router` | Digest | Daily summary | Model switch |
1230
1288
  | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
1231
1289
  | omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
1290
+ | Claude Code | Anthropic Messages (`/v1/messages`) | derived (`claude-code`) | from `metadata` | no | no | no | no | no | no |
1232
1291
  | Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
1233
1292
  | Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
1234
1293
  | Aider | config only | via model settings | no | no | no | no | no tools | no | no |
@@ -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.4.14",
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,
@@ -335,6 +335,13 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
335
335
  { path: "report.dailySummary", label: "Daily summary at session start", kind: "boolean" },
336
336
  ],
337
337
  },
338
+ {
339
+ title: "Claude Code (Anthropic Messages wire)",
340
+ fields: [
341
+ { path: "anthropic.models.*haiku*", label: "Model names matching *haiku* route to profile", kind: "string", optional: true, hint: "e.g. auto-cheap" },
342
+ { path: "anthropic.models.claude-*", label: "Model names matching claude-* route to profile", kind: "string", optional: true, hint: "e.g. auto or auto-max" },
343
+ ],
344
+ },
338
345
  {
339
346
  title: "Harness switch",
340
347
  fields: [
@@ -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
+ }
@@ -335,6 +335,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
335
335
  // One transcript message per day, at the first interactive session start.
336
336
  dailySummary: true,
337
337
  },
338
+ anthropic: {
339
+ // haiku is what Claude Code uses for background chores; everything else is a real turn.
340
+ models: { "*haiku*": "auto-cheap", "claude-*": "auto" },
341
+ },
338
342
  harnessSwitch: {
339
343
  // Off: moving the harness's own model is a visible change the operator opts into.
340
344
  enabled: false,
@@ -277,6 +277,7 @@ export const configInputSchema = z.strictObject({
277
277
  budget: budget.optional(),
278
278
  profiles: z.array(profile).optional(),
279
279
  report: z.strictObject({ baselines: z.array(z.string()).optional(), dailySummary: z.boolean().optional() }).optional(),
280
+ anthropic: z.object({ models: z.record(z.string(), z.string()).optional() }).strict().optional(),
280
281
  harnessSwitch: z
281
282
  .strictObject({
282
283
  enabled: z.boolean().optional(),
@@ -620,6 +620,16 @@ export interface ReportConfig {
620
620
  * each user prompt and the harness moves its own active model to a
621
621
  * harness-native one for the mapped tiers. See omp-extension/router-switch.ts.
622
622
  */
623
+ /** The Anthropic Messages wire (`POST /v1/messages`, Claude Code). */
624
+ export interface AnthropicConfig {
625
+ /**
626
+ * Which router profile a Messages `model` name means, first matching glob
627
+ * wins. Claude Code asks for `claude-*` names; unmatched names pass through
628
+ * so profile ids (`auto`, `auto-max`) still work.
629
+ */
630
+ models: Record<string, string>;
631
+ }
632
+
623
633
  export interface HarnessSwitchConfig {
624
634
  enabled: boolean;
625
635
  /**
@@ -830,6 +840,7 @@ export interface RouterConfig {
830
840
  report: ReportConfig;
831
841
  digest: DigestConfig;
832
842
  harnessSwitch: HarnessSwitchConfig;
843
+ anthropic: AnthropicConfig;
833
844
  profiles: ProfileConfig[];
834
845
  ledger: LedgerConfig;
835
846
  /**
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;