auto-model-router 0.19.0 → 0.20.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.
@@ -0,0 +1,299 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { connectRemote, type ConnectOptions, type ConnectReport } from "../src/cli/connect.ts";
7
+ import { mergeClineProviders, mergeContinueConfig, mergeOpenCodeConfig } from "../src/cli/harnesses.ts";
8
+
9
+ /**
10
+ * The harnesses `connect` reaches beyond omp, Hermes, Codex, Aider and Claude
11
+ * Code. Each automated one starts from a fixture in the shape its harness
12
+ * actually writes (`test/fixtures/connect/`), so the assertions are about
13
+ * merging into a REAL file rather than into an empty one: the user's other keys,
14
+ * providers and comments must survive, a second run must change nothing,
15
+ * `--harness <id>` must select exactly one, and a machine without the harness
16
+ * must be told it was skipped rather than have a file invented for it.
17
+ *
18
+ * The two manual harnesses are held to the other half of that promise — they
19
+ * print and write nothing at all.
20
+ */
21
+
22
+ const FIXTURES = join(import.meta.dir, "fixtures", "connect");
23
+
24
+ /** A temp HOME with nothing in it: every harness is absent until a test plants one. */
25
+ function bare(extra: Partial<ConnectOptions> = {}): { home: string; o: ConnectOptions } {
26
+ const home = mkdtempSync(join(tmpdir(), "amr-harness-"));
27
+ const o: ConnectOptions = {
28
+ url: "https://team.example",
29
+ key: "amrt_key",
30
+ userId: "u_ada",
31
+ name: "Ada",
32
+ profile: false,
33
+ dryRun: false,
34
+ only: [],
35
+ // Point every harness this suite does not exercise at a path that does not exist.
36
+ env: { HOME: home, PI_CODING_AGENT_DIR: join(home, "no-omp"), HERMES_HOME: join(home, "no-hermes"), AUTO_MODEL_ROUTER_HOME: join(home, ".auto-model-router"), XDG_CONFIG_HOME: join(home, ".config") },
37
+ home,
38
+ packageDir: process.cwd(),
39
+ platform: "linux",
40
+ pathHas: () => false,
41
+ ...extra,
42
+ };
43
+ return { home, o };
44
+ }
45
+
46
+ /** Plants a captured config file for a harness under the temp HOME. */
47
+ function plant(home: string, fixture: string, ...rel: string[]): string {
48
+ const target = join(home, ...rel);
49
+ mkdirSync(join(target, ".."), { recursive: true });
50
+ cpSync(join(FIXTURES, fixture), target);
51
+ return target;
52
+ }
53
+
54
+ const configuredFor = (r: ConnectReport, prefix: string): string | undefined => r.configured.find((c) => c.startsWith(prefix));
55
+ const backupsIn = (dir: string): string[] => readdirSync(dir).filter((f) => f.endsWith(".bak"));
56
+
57
+ describe("OpenCode", () => {
58
+ test("the provider block is merged into opencode.json, the plugin is copied, and a second run changes nothing", () => {
59
+ const { home, o } = bare();
60
+ const dir = join(home, ".config", "opencode");
61
+ const cfg = plant(home, "opencode.json", ".config", "opencode", "opencode.json");
62
+ try {
63
+ const r = connectRemote(o);
64
+ expect(configuredFor(r, "OpenCode (")).toContain("opencode.json");
65
+ // The exact file: our provider added, `model` pointed at it, and every
66
+ // other key — the schema, the MCP server, the user's own Ollama provider — kept.
67
+ expect(JSON.parse(readFileSync(cfg, "utf8"))).toEqual({
68
+ $schema: "https://opencode.ai/config.json",
69
+ model: "auto-model-router/auto",
70
+ mcp: { unityMCP: { type: "remote", url: "http://127.0.0.1:8080/mcp", enabled: true } },
71
+ provider: {
72
+ ollama: {
73
+ npm: "@ai-sdk/openai-compatible",
74
+ name: "Ollama",
75
+ options: { baseURL: "http://localhost:11434/v1" },
76
+ models: { "qwen2.5:7b": { name: "Qwen2.5 7B (local)" } },
77
+ },
78
+ "auto-model-router": {
79
+ npm: "@ai-sdk/openai-compatible",
80
+ name: "auto-model-router",
81
+ options: { baseURL: "https://team.example/v1", apiKey: "amrt_key", headers: { "X-Omp-Harness": "opencode" } },
82
+ models: { auto: { name: "auto" }, "auto-cheap": { name: "auto-cheap" }, "auto-max": { name: "auto-max" } },
83
+ },
84
+ },
85
+ });
86
+ // The plugin is what gives OpenCode session identity, the toast and the digest.
87
+ expect(readFileSync(join(dir, "plugin", "auto-model-router.ts"), "utf8")).toContain("chat.headers");
88
+ expect(backupsIn(dir)).toHaveLength(1); // the previous file was kept
89
+ const after = readFileSync(cfg, "utf8");
90
+ connectRemote(o);
91
+ expect(readFileSync(cfg, "utf8")).toBe(after);
92
+ expect(backupsIn(dir)).toHaveLength(1); // and a no-op run backs nothing up
93
+ } finally {
94
+ rmSync(home, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ test("--harness opencode selects it alone, and an absent OpenCode is reported as skipped", () => {
99
+ const { home, o } = bare({ only: ["opencode"] });
100
+ plant(home, "opencode.json", ".config", "opencode", "opencode.json");
101
+ try {
102
+ expect(connectRemote(o).configured.map((c) => c.split(" (")[0])).toEqual(["OpenCode"]);
103
+ } finally {
104
+ rmSync(home, { recursive: true, force: true });
105
+ }
106
+ const { home: h2, o: o2 } = bare();
107
+ try {
108
+ expect(connectRemote(o2).skipped).toContain("OpenCode (not on PATH and no ~/.config/opencode)");
109
+ expect(existsSync(join(h2, ".config", "opencode"))).toBe(false);
110
+ } finally {
111
+ rmSync(h2, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("a pinned scope rides in the provider's headers; a file we cannot parse is left alone", () => {
116
+ expect(JSON.parse(mergeOpenCodeConfig("", "https://t", "k", "omp-router")!).provider["auto-model-router"].options.headers).toEqual({ "X-Omp-Harness": "opencode", "X-Agentdox-Scope": "omp-router" });
117
+ expect(mergeOpenCodeConfig("[]", "https://t", "k")).toBeNull();
118
+ expect(mergeOpenCodeConfig("{ not json", "https://t", "k")).toBeNull();
119
+ expect(mergeOpenCodeConfig(mergeOpenCodeConfig("", "https://t", "k")!, "https://t", "k")).toBeNull();
120
+ });
121
+ });
122
+
123
+ describe("Cline", () => {
124
+ const REL = [".cline", "data", "settings"];
125
+
126
+ test("the openai-compatible provider is merged into providers.json beside the user's own, and a second run changes nothing", () => {
127
+ const { home, o } = bare();
128
+ const dir = join(home, ...REL);
129
+ const cfg = plant(home, "cline-providers.json", ...REL, "providers.json");
130
+ try {
131
+ const r = connectRemote(o);
132
+ expect(configuredFor(r, "Cline (")).toContain("providers.json");
133
+ const written = JSON.parse(readFileSync(cfg, "utf8")) as Record<string, unknown>;
134
+ // The harness id can only travel in `settings.headers`: cline's own auth
135
+ // command has no flag for it, which is why connect writes the file itself.
136
+ expect((written.providers as Record<string, { settings: unknown }>)["openai-compatible"]!.settings).toEqual({
137
+ provider: "openai-compatible",
138
+ apiKey: "amrt_key",
139
+ model: "auto",
140
+ baseUrl: "https://team.example/v1",
141
+ headers: { "X-Omp-Harness": "cline" },
142
+ });
143
+ expect(written.lastUsedProvider).toBe("openai-compatible");
144
+ expect(written.version).toBe(1);
145
+ // The user's other provider is untouched, key and stamp included.
146
+ expect((written.providers as Record<string, unknown>).anthropic).toEqual({
147
+ settings: { provider: "anthropic", apiKey: "sk-ant-existing", model: "claude-sonnet-4-5" },
148
+ updatedAt: "2026-09-10T05:27:55.818Z",
149
+ tokenSource: "manual",
150
+ });
151
+ expect(backupsIn(dir)).toHaveLength(1);
152
+ const after = readFileSync(cfg, "utf8");
153
+ connectRemote(o);
154
+ expect(readFileSync(cfg, "utf8")).toBe(after);
155
+ expect(backupsIn(dir)).toHaveLength(1);
156
+ } finally {
157
+ rmSync(home, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test("--harness cline selects it alone, and an absent Cline is reported as skipped", () => {
162
+ const { home, o } = bare({ only: ["cline"] });
163
+ plant(home, "cline-providers.json", ...REL, "providers.json");
164
+ try {
165
+ expect(connectRemote(o).configured.map((c) => c.split(" (")[0])).toEqual(["Cline"]);
166
+ } finally {
167
+ rmSync(home, { recursive: true, force: true });
168
+ }
169
+ const { home: h2, o: o2 } = bare();
170
+ try {
171
+ expect(connectRemote(o2).skipped).toContain("Cline (not on PATH and no ~/.cline)");
172
+ expect(existsSync(join(h2, ".cline"))).toBe(false);
173
+ } finally {
174
+ rmSync(h2, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ test("the stamp only moves when the settings do, a pinned scope rides along, and an unreadable file is left alone", () => {
179
+ const first = mergeClineProviders("", "https://t", "k", "2026-01-01T00:00:00.000Z", "omp-router")!;
180
+ expect(JSON.parse(first).providers["openai-compatible"].settings.headers).toEqual({ "X-Omp-Harness": "cline", "X-Agentdox-Scope": "omp-router" });
181
+ expect(mergeClineProviders(first, "https://t", "k", "2027-01-01T00:00:00.000Z", "omp-router")).toBeNull();
182
+ // A new key is a real change, so the stamp does move.
183
+ const rekeyed = mergeClineProviders(first, "https://t", "k2", "2027-01-01T00:00:00.000Z", "omp-router")!;
184
+ expect(JSON.parse(rekeyed).providers["openai-compatible"].updatedAt).toBe("2027-01-01T00:00:00.000Z");
185
+ expect(mergeClineProviders("nope", "https://t", "k", "2026-01-01T00:00:00.000Z")).toBeNull();
186
+ });
187
+ });
188
+
189
+ describe("Continue", () => {
190
+ test("the three profiles become model entries in config.yaml, keeping the file's comments, and a second run changes nothing", () => {
191
+ const { home, o } = bare();
192
+ const dir = join(home, ".continue");
193
+ const cfg = plant(home, "continue-config.yaml", ".continue", "config.yaml");
194
+ try {
195
+ const r = connectRemote(o);
196
+ expect(configuredFor(r, "Continue (")).toContain("config.yaml");
197
+ const text = readFileSync(cfg, "utf8");
198
+ // The document is edited, not re-serialised: comments, the user's own model
199
+ // and the context blocks are all still there, in their original order.
200
+ expect(text).toContain("# My Continue assistant — hand-edited, comments and all.");
201
+ expect(text).toContain("name: Local Assistant");
202
+ expect(text).toContain(" - name: Ollama Qwen");
203
+ expect(text.indexOf("context:")).toBeGreaterThan(text.indexOf("auto-model-router-max"));
204
+ for (const [name, model] of [
205
+ ["auto-model-router", "auto"],
206
+ ["auto-model-router-cheap", "auto-cheap"],
207
+ ["auto-model-router-max", "auto-max"],
208
+ ]) {
209
+ expect(text).toContain(` - name: ${name}\n provider: openai\n model: ${model}\n apiBase: https://team.example/v1\n apiKey: amrt_key\n`);
210
+ }
211
+ expect(text).toContain(" X-Omp-Harness: continue");
212
+ expect(backupsIn(dir)).toHaveLength(1);
213
+ connectRemote(o);
214
+ expect(readFileSync(cfg, "utf8")).toBe(text);
215
+ expect(backupsIn(dir)).toHaveLength(1);
216
+ } finally {
217
+ rmSync(home, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test("--harness continue selects it alone, and an absent Continue is reported as skipped", () => {
222
+ const { home, o } = bare({ only: ["continue"] });
223
+ plant(home, "continue-config.yaml", ".continue", "config.yaml");
224
+ try {
225
+ expect(connectRemote(o).configured.map((c) => c.split(" (")[0])).toEqual(["Continue"]);
226
+ } finally {
227
+ rmSync(home, { recursive: true, force: true });
228
+ }
229
+ const { home: h2, o: o2 } = bare();
230
+ try {
231
+ expect(connectRemote(o2).skipped).toContain("Continue (no ~/.continue)");
232
+ expect(existsSync(join(h2, ".continue"))).toBe(false);
233
+ } finally {
234
+ rmSync(h2, { recursive: true, force: true });
235
+ }
236
+ });
237
+
238
+ test("a fresh file gets the schema's required fields first, a re-key replaces entries in place, and broken YAML is left alone", () => {
239
+ const fresh = mergeContinueConfig("", "https://t", "k")!;
240
+ expect(fresh.startsWith("name: auto-model-router\nversion: 0.0.1\nschema: v1\nmodels:\n - name:")).toBe(true);
241
+ expect(mergeContinueConfig(fresh, "https://t", "k")).toBeNull();
242
+ const rekeyed = mergeContinueConfig(fresh, "https://t", "k2")!;
243
+ expect(rekeyed.match(/- name: auto-model-router\n/g)).toHaveLength(1); // replaced, not appended
244
+ expect(rekeyed).toContain("apiKey: k2");
245
+ expect(rekeyed).not.toContain("apiKey: k\n");
246
+ expect(mergeContinueConfig("models:\n - [unclosed\n", "https://t", "k")).toBeNull();
247
+ expect(mergeContinueConfig("- a\n- b\n", "https://t", "k")).toBeNull(); // a sequence is not an assistant file
248
+ });
249
+ });
250
+
251
+ describe("Cursor and Windsurf keep their provider settings where no file can reach them", () => {
252
+ test("connect prints what to set and writes nothing under either home", () => {
253
+ const { home, o } = bare({ only: ["cursor", "windsurf"] });
254
+ mkdirSync(join(home, ".cursor"), { recursive: true });
255
+ mkdirSync(join(home, ".codeium", "windsurf"), { recursive: true });
256
+ try {
257
+ const r = connectRemote(o);
258
+ expect(r.configured).toEqual([]);
259
+ expect(r.manual.map((m) => m.harness)).toEqual(["Cursor", "Windsurf"]);
260
+ const cursor = r.manual[0]!.lines.join("\n");
261
+ expect(cursor).toContain("base URL: https://team.example/v1");
262
+ expect(cursor).toContain("API key: amrt_key");
263
+ // Windsurf has no base-URL field of its own, so the useful answer is the extension.
264
+ expect(r.manual[1]!.lines.join("\n")).toContain("Cline extension");
265
+ // Nothing was written into either harness's directory.
266
+ expect(readdirSync(join(home, ".cursor"))).toEqual([]);
267
+ expect(readdirSync(join(home, ".codeium", "windsurf"))).toEqual([]);
268
+ } finally {
269
+ rmSync(home, { recursive: true, force: true });
270
+ }
271
+ });
272
+
273
+ test("a loopback router is called out for Cursor, which proxies chat through its own servers", () => {
274
+ const { home, o } = bare({ only: ["cursor"], url: "http://127.0.0.1:8788" });
275
+ mkdirSync(join(home, ".cursor"), { recursive: true });
276
+ try {
277
+ expect(connectRemote(o).manual[0]!.lines.join("\n")).toContain("reachable from the internet");
278
+ } finally {
279
+ rmSync(home, { recursive: true, force: true });
280
+ }
281
+ // A public remote gets no such warning, and an uninstalled Cursor gets no recipe.
282
+ const { home: h2, o: o2 } = bare({ only: ["cursor"] });
283
+ mkdirSync(join(h2, ".cursor"), { recursive: true });
284
+ try {
285
+ expect(connectRemote(o2).manual[0]!.lines.join("\n")).not.toContain("reachable from the internet");
286
+ } finally {
287
+ rmSync(h2, { recursive: true, force: true });
288
+ }
289
+ const { home: h3, o: o3 } = bare();
290
+ try {
291
+ const r = connectRemote(o3);
292
+ expect(r.manual).toEqual([]);
293
+ expect(r.skipped).toContain("Cursor (no ~/.cursor)");
294
+ expect(r.skipped).toContain("Windsurf (no ~/.codeium)");
295
+ } finally {
296
+ rmSync(h3, { recursive: true, force: true });
297
+ }
298
+ });
299
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "version": 1,
3
+ "lastUsedProvider": "anthropic",
4
+ "modes": {},
5
+ "providers": {
6
+ "anthropic": {
7
+ "settings": {
8
+ "provider": "anthropic",
9
+ "apiKey": "sk-ant-existing",
10
+ "model": "claude-sonnet-4-5"
11
+ },
12
+ "updatedAt": "2026-09-10T05:27:55.818Z",
13
+ "tokenSource": "manual"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,14 @@
1
+ # My Continue assistant — hand-edited, comments and all.
2
+ name: Local Assistant
3
+ version: 0.0.1
4
+ schema: v1
5
+ models:
6
+ - name: Ollama Qwen
7
+ provider: ollama
8
+ model: qwen2.5-coder:7b
9
+ roles:
10
+ - chat
11
+ - edit
12
+ context:
13
+ - provider: code
14
+ - provider: diff
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "model": "anthropic/claude-sonnet-4-5",
4
+ "provider": {
5
+ "ollama": {
6
+ "npm": "@ai-sdk/openai-compatible",
7
+ "name": "Ollama",
8
+ "options": { "baseURL": "http://localhost:11434/v1" },
9
+ "models": { "qwen2.5:7b": { "name": "Qwen2.5 7B (local)" } }
10
+ }
11
+ },
12
+ "mcp": {
13
+ "unityMCP": { "type": "remote", "url": "http://127.0.0.1:8080/mcp", "enabled": true }
14
+ }
15
+ }