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
|
@@ -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
|
+
});
|
package/test/turn.test.ts
CHANGED
|
@@ -77,6 +77,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
77
77
|
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
79
|
report: { baselines: [], dailySummary: false },
|
|
80
|
+
anthropic: { models: { "*haiku*": "auto-cheap", "claude-*": "auto" } },
|
|
80
81
|
harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
|
|
81
82
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000, toolAliases: {} },
|
|
82
83
|
profiles: [],
|