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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +13 -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/join.ts +218 -0
- package/src/index.ts +5 -0
- package/test/team-client.test.ts +97 -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
|
@@ -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
|
-
|
|
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/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/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
|
+
});
|