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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +59 -0
- package/hermes-plugin/__init__.py +7 -3
- package/omp-extension/router-embed.ts +12 -0
- package/omp-extension/router-toast.ts +5 -34
- package/omp-extension/router-url.ts +6 -0
- package/omp-extension/team-logic.ts +88 -0
- package/package.json +1 -1
- package/src/cli/args.ts +3 -0
- package/src/cli/config-wizard.ts +7 -0
- package/src/cli/join.ts +218 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +11 -0
- package/src/index.ts +5 -0
- package/src/server/http.ts +27 -6
- package/src/wire/anthropic/messages.ts +481 -0
- package/src/wire/types.ts +1 -1
- package/test/anthropic-wire.test.ts +287 -0
- package/test/failover.test.ts +1 -0
- package/test/fixtures/harness/claude-code.json +858 -0
- package/test/team-client.test.ts +97 -0
- package/test/turn.test.ts +1 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.6.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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
35
|
-
import { newestId,
|
|
30
|
+
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
31
|
+
import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
|
|
36
32
|
|
|
37
33
|
/** Raw router config.yml, or null when there is none to read. */
|
|
38
|
-
function readRouterConfig(): string | null {
|
|
39
|
-
const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
40
|
-
const home =
|
|
41
|
-
raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
|
|
42
|
-
const path = join(home, "config.yml");
|
|
43
|
-
if (!existsSync(path)) return null;
|
|
44
|
-
try {
|
|
45
|
-
return readFileSync(path, "utf8");
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
34
|
|
|
51
35
|
/** Absolute path of the shared embed port file (main session writes it). */
|
|
52
|
-
function embedPortFile(): string {
|
|
53
|
-
const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
54
|
-
const home =
|
|
55
|
-
raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
|
|
56
|
-
return embedPortPath(home);
|
|
57
|
-
}
|
|
58
36
|
|
|
59
|
-
const ROUTER_API_KEY = process.env.AUTO_MODEL_ROUTER_API_KEY;
|
|
60
37
|
// This harness's id, matching the X-Omp-Harness header the router records.
|
|
61
38
|
// Empty ⇒ toast every harness (single-harness default).
|
|
62
39
|
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
|
|
@@ -91,19 +68,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
91
68
|
// The embedded router binds a free OS-assigned port, so the URL
|
|
92
69
|
// is resolved fresh each tick from the port file the embed
|
|
93
70
|
// extension writes at session_start.
|
|
94
|
-
|
|
95
|
-
const routerUrl =
|
|
96
|
-
process.env.AUTO_MODEL_ROUTER_URL,
|
|
97
|
-
readRouterConfig(),
|
|
98
|
-
parseYaml,
|
|
99
|
-
process.env.AUTO_MODEL_ROUTER_PORT,
|
|
100
|
-
embedPort,
|
|
101
|
-
);
|
|
71
|
+
// 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:
|
|
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
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,
|
package/src/cli/config-wizard.ts
CHANGED
|
@@ -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: [
|
package/src/cli/join.ts
ADDED
|
@@ -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/config/defaults.ts
CHANGED
|
@@ -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,
|
package/src/config/schema.ts
CHANGED
|
@@ -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(),
|
package/src/config/types.ts
CHANGED
|
@@ -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;
|