auto-model-router 0.13.0 → 0.14.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 +6 -0
- package/package.json +1 -1
- package/src/catalog/static-catalog.ts +2 -1
- package/src/cli/connect.ts +72 -0
- package/src/cli/refresh.ts +3 -0
- package/src/config/hot-reload.ts +3 -0
- package/src/cost/report.ts +8 -6
- package/src/index.ts +1 -1
- package/src/lib.ts +1 -0
- package/src/server/providers.ts +1 -1
- package/test/mcp-entry.test.ts +157 -0
- package/test/upstreams.test.ts +1 -1
|
@@ -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.14.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.14.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1365,6 +1365,12 @@ skills: Claude Code's `~/.claude/skills/<name>/` and omp's `~/.omp/agent/skills/
|
|
|
1365
1365
|
the bundle no longer carries, and a skill of the same name the member wrote themselves is
|
|
1366
1366
|
left alone with a note. A remote without skills answers 404 and nothing happens.
|
|
1367
1367
|
|
|
1368
|
+
A remote whose `/setup/info` says `mcp: true` (a team edition serving shared context) also
|
|
1369
|
+
gets a `team-context` MCP server written for omp (`~/.omp/agent/mcp.json`) and Claude Code
|
|
1370
|
+
(`~/.claude.json`): `type: http`, the remote's `/mcp`, the member key in the Authorization
|
|
1371
|
+
header. Every refresh rewrites it with the current key, like models.yml; other servers in
|
|
1372
|
+
those files are untouched, and a remote that stops serving MCP has the entry removed.
|
|
1373
|
+
|
|
1368
1374
|
## Direct upstreams: OpenAI, Azure OpenAI, Anthropic, vLLM
|
|
1369
1375
|
|
|
1370
1376
|
OpenRouter and Ollama Cloud are the built-in upstreams. `upstreams:` adds named ones the
|
package/package.json
CHANGED
|
@@ -47,7 +47,8 @@ export function buildUpstreamModels(entry: UpstreamEntry, openrouter: readonly C
|
|
|
47
47
|
priceTiers: [],
|
|
48
48
|
quality: m.quality !== undefined ? { ...m.quality } : twin === null ? {} : { ...twin.quality },
|
|
49
49
|
tokenizer: tokenizerFor(entry.kind, twin),
|
|
50
|
-
|
|
50
|
+
// A $0 model here is a self-hosted server, not a public provider's rate-limited free tier: never excluded as "free".
|
|
51
|
+
isFree: false,
|
|
51
52
|
createdAtMs: twin?.createdAtMs ?? 0,
|
|
52
53
|
author: entry.id,
|
|
53
54
|
};
|
package/src/cli/connect.ts
CHANGED
|
@@ -68,6 +68,12 @@ export interface ConnectOptions {
|
|
|
68
68
|
exePath?: string;
|
|
69
69
|
/** The remote's skills bundle, installed into every configured harness that reads user-level skills. */
|
|
70
70
|
skills?: SkillsBundle;
|
|
71
|
+
/**
|
|
72
|
+
* The remote's MCP endpoint (a team edition serving shared context): written as the
|
|
73
|
+
* `team-context` server for omp and Claude Code with the member key, rewritten on every
|
|
74
|
+
* refresh like models.yml. `null` removes an entry a previous connect wrote.
|
|
75
|
+
*/
|
|
76
|
+
mcp?: { url: string | null };
|
|
71
77
|
platform: string;
|
|
72
78
|
pathHas: (bin: string) => boolean;
|
|
73
79
|
}
|
|
@@ -80,6 +86,39 @@ export interface ConnectReport {
|
|
|
80
86
|
notes: string[];
|
|
81
87
|
/** What the remote's skills bundle did, when there was one. */
|
|
82
88
|
skills?: SkillsInstallReport;
|
|
89
|
+
/** Files that gained (or lost) the team-context MCP server. */
|
|
90
|
+
mcp?: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The name of the MCP server connect manages in a harness's config. */
|
|
94
|
+
export const MCP_SERVER_NAME = "team-context";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Merges the team-context server into an `mcpServers` JSON file (omp's mcp.json, Claude
|
|
98
|
+
* Code's ~/.claude.json), leaving every other key and server alone; `url` null removes it.
|
|
99
|
+
* Returns the new text, or null when nothing changes.
|
|
100
|
+
*/
|
|
101
|
+
export function mergeMcpServers(before: string, url: string | null, key: string): string | null {
|
|
102
|
+
let root: Record<string, unknown> = {};
|
|
103
|
+
if (before.trim() !== "") {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(before) as unknown;
|
|
106
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
107
|
+
root = parsed as Record<string, unknown>;
|
|
108
|
+
} catch {
|
|
109
|
+
return null; // a file we cannot parse is not ours to rewrite
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const servers = { ...((root.mcpServers as Record<string, unknown> | undefined) ?? {}) };
|
|
113
|
+
if (url === null) {
|
|
114
|
+
if (!(MCP_SERVER_NAME in servers)) return null;
|
|
115
|
+
delete servers[MCP_SERVER_NAME];
|
|
116
|
+
} else {
|
|
117
|
+
const next = { type: "http", url, headers: { Authorization: `Bearer ${key}` } };
|
|
118
|
+
if (JSON.stringify(servers[MCP_SERVER_NAME]) === JSON.stringify(next)) return null;
|
|
119
|
+
servers[MCP_SERVER_NAME] = next;
|
|
120
|
+
}
|
|
121
|
+
return `${JSON.stringify({ ...root, mcpServers: servers }, null, 2)}\n`;
|
|
83
122
|
}
|
|
84
123
|
|
|
85
124
|
const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw);
|
|
@@ -376,6 +415,24 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
376
415
|
for (const s of report.skills.skipped) report.notes.push(`skill ${s}`);
|
|
377
416
|
}
|
|
378
417
|
|
|
418
|
+
// 6c. The remote's MCP endpoint (shared context tools), for the harnesses configured above
|
|
419
|
+
// that read a user-level mcpServers file; the member key travels in the header and is
|
|
420
|
+
// rewritten with every refresh, like models.yml.
|
|
421
|
+
if (o.mcp !== undefined) {
|
|
422
|
+
const files: string[] = [];
|
|
423
|
+
if (report.configured.some((c) => c.startsWith("omp ("))) files.push(join(agentDir, "mcp.json"));
|
|
424
|
+
if (report.configured.some((c) => c.startsWith("Claude Code ("))) files.push(join(o.home, ".claude.json"));
|
|
425
|
+
report.mcp = [];
|
|
426
|
+
for (const path of files) {
|
|
427
|
+
const before = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
428
|
+
const after = mergeMcpServers(before, o.mcp.url, o.key);
|
|
429
|
+
if (after === null) continue;
|
|
430
|
+
write(path, after);
|
|
431
|
+
report.mcp.push(path);
|
|
432
|
+
}
|
|
433
|
+
if (o.mcp.url !== null && files.length > 0) report.configured.push(`MCP (${MCP_SERVER_NAME} → ${o.mcp.url}: ${files.map((f) => f.replaceAll("\\", "/")).join(", ")})`);
|
|
434
|
+
}
|
|
435
|
+
|
|
379
436
|
// 7. Persist the environment.
|
|
380
437
|
if (o.profile && !o.dryRun) {
|
|
381
438
|
if (o.platform === "win32") {
|
|
@@ -433,6 +490,18 @@ export async function exchangeSetupToken(url: string, token: string, device: str
|
|
|
433
490
|
};
|
|
434
491
|
}
|
|
435
492
|
|
|
493
|
+
/** What a team edition says about itself at /setup/info; a plain router answers nothing. */
|
|
494
|
+
export async function fetchSetupInfo(url: string, fetchImpl: typeof fetch = fetch): Promise<{ mcp: boolean }> {
|
|
495
|
+
try {
|
|
496
|
+
const res = await fetchImpl(`${url}/setup/info`, { signal: AbortSignal.timeout(10_000) });
|
|
497
|
+
if (!res.ok) return { mcp: false };
|
|
498
|
+
const body = (await res.json()) as { mcp?: unknown };
|
|
499
|
+
return { mcp: body.mcp === true };
|
|
500
|
+
} catch {
|
|
501
|
+
return { mcp: false };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
436
505
|
/** Adds `dir` to the user's PATH on Windows, once, through the registry-backed API rather than setx. */
|
|
437
506
|
function addToUserPathWindows(dir: string): void {
|
|
438
507
|
const quoted = `'${dir.replaceAll("'", "''")}'`;
|
|
@@ -495,6 +564,8 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
495
564
|
const scopeFlag = flagString(args, "scope");
|
|
496
565
|
// The remote's skills for the agents on this machine; a remote without any serves 404.
|
|
497
566
|
const skills = await fetchSkills(url, key, fetchImpl);
|
|
567
|
+
// Its shared-context MCP endpoint, when it is a team edition serving one.
|
|
568
|
+
const info = await fetchSetupInfo(url, fetchImpl);
|
|
498
569
|
const report = connectRemote({
|
|
499
570
|
url,
|
|
500
571
|
key,
|
|
@@ -515,6 +586,7 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
515
586
|
...(device === "" ? {} : { device }),
|
|
516
587
|
...(exePath === null ? {} : { exePath }),
|
|
517
588
|
...(skills.bundle === null ? {} : { skills: skills.bundle }),
|
|
589
|
+
mcp: { url: info.mcp ? `${url}/mcp` : null },
|
|
518
590
|
});
|
|
519
591
|
if (skills.note !== undefined) report.notes.push(skills.note);
|
|
520
592
|
if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
|
package/src/cli/refresh.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
17
|
+
import { fetchSetupInfo } from "./connect.ts";
|
|
17
18
|
import { fetchSkills } from "./skills.ts";
|
|
18
19
|
import { homedir } from "node:os";
|
|
19
20
|
import { dirname, resolve } from "node:path";
|
|
@@ -99,6 +100,7 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
99
100
|
const exePath = opts.remote.executable ?? executablePath() ?? undefined;
|
|
100
101
|
// A refresh is when the team's skills reach a machine that has not re-run connect.
|
|
101
102
|
const skills = await fetchSkills(opts.remote.url, fresh.key, opts.fetchImpl ?? fetch);
|
|
103
|
+
const info = await fetchSetupInfo(opts.remote.url, opts.fetchImpl ?? fetch);
|
|
102
104
|
connectRemote({
|
|
103
105
|
url: opts.remote.url,
|
|
104
106
|
key: fresh.key,
|
|
@@ -121,6 +123,7 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
121
123
|
...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
|
|
122
124
|
...(exePath !== undefined ? { exePath } : {}),
|
|
123
125
|
...(skills.bundle === null ? {} : { skills: skills.bundle }),
|
|
126
|
+
mcp: { url: info.mcp ? `${opts.remote.url}/mcp` : null },
|
|
124
127
|
// undefined keeps whatever scope the managed models.yml block already carries.
|
|
125
128
|
});
|
|
126
129
|
return fresh;
|
package/src/config/hot-reload.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { existsSync, readFileSync, watch, type FSWatcher } from "node:fs";
|
|
|
30
30
|
import { parse as parseYaml } from "yaml";
|
|
31
31
|
import { configInputSchema } from "./schema.ts";
|
|
32
32
|
import { assignInPlace } from "./apply.ts";
|
|
33
|
+
import { completeUpstreams } from "./upstreams.ts";
|
|
33
34
|
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
34
35
|
import { deepMerge, resolveTilde } from "./load.ts";
|
|
35
36
|
import type { RouterConfig } from "./types.ts";
|
|
@@ -182,6 +183,8 @@ export function watchConfig(
|
|
|
182
183
|
// One in-place pass over the whole config: block identity survives, and a
|
|
183
184
|
// knob deleted from the file reverts, exactly as a restart would leave it.
|
|
184
185
|
const changed = assignInPlace(live as unknown as Record<string, unknown>, staged, "", { prune: true });
|
|
186
|
+
// A reloaded upstream list is as sparse as the file; clients read complete records.
|
|
187
|
+
if (changed.some((c) => c === "upstreams" || c.startsWith("upstreams."))) completeUpstreams(live);
|
|
185
188
|
if (changed.length > 0) opts.onReload?.({ changed });
|
|
186
189
|
};
|
|
187
190
|
|
package/src/cost/report.ts
CHANGED
|
@@ -138,12 +138,14 @@ const PT = "json_extract(usage, '$.promptTokens')";
|
|
|
138
138
|
const CT = "json_extract(usage, '$.cachedTokens')";
|
|
139
139
|
const COMP = "json_extract(usage, '$.completionTokens')";
|
|
140
140
|
/** Named upstream ids the ledger's provider derivation knows; set by createProviders from the live config. */
|
|
141
|
-
let knownUpstreamIds: readonly string[] = [];
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
let knownUpstreamIds: () => readonly string[] = () => [];
|
|
142
|
+
/** Ids, or a getter read live so a hot-reloaded list applies; an embedder (the team edition) calls this too, since the registry is per process. */
|
|
143
|
+
export function setKnownUpstreamIds(ids: readonly string[] | (() => readonly string[])): void {
|
|
144
|
+
const read = typeof ids === "function" ? ids : () => ids;
|
|
145
|
+
knownUpstreamIds = () => read().filter((id) => /^[a-z0-9][a-z0-9-]{0,31}$/.test(id));
|
|
144
146
|
}
|
|
145
147
|
export function knownUpstreams(): readonly string[] {
|
|
146
|
-
return knownUpstreamIds;
|
|
148
|
+
return knownUpstreamIds();
|
|
147
149
|
}
|
|
148
150
|
/** The provider of a slug: its namespace when that names a known upstream, else OpenRouter's own. */
|
|
149
151
|
export function providerOfSlug(slug: string): string {
|
|
@@ -151,13 +153,13 @@ export function providerOfSlug(slug: string): string {
|
|
|
151
153
|
const cut = slug.indexOf("/");
|
|
152
154
|
if (cut > 0) {
|
|
153
155
|
const head = slug.slice(0, cut);
|
|
154
|
-
if (knownUpstreamIds.includes(head)) return head;
|
|
156
|
+
if (knownUpstreamIds().includes(head)) return head;
|
|
155
157
|
}
|
|
156
158
|
return "openrouter";
|
|
157
159
|
}
|
|
158
160
|
/** SQL twin of providerOfSlug; ids are validated to a slug alphabet so they can be inlined. */
|
|
159
161
|
function providerCase(): string {
|
|
160
|
-
return `CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ${knownUpstreamIds.map((id) => `WHEN slug LIKE '${id}/%' THEN '${id}'`).join(" ")} ELSE 'openrouter' END`;
|
|
162
|
+
return `CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ${knownUpstreamIds().map((id) => `WHEN slug LIKE '${id}/%' THEN '${id}'`).join(" ")} ELSE 'openrouter' END`;
|
|
161
163
|
}
|
|
162
164
|
const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
|
|
163
165
|
const EST = "json_extract(usage, '$.cachedEstimated') = 1";
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ Usage: auto-model-router <command> [options]
|
|
|
28
28
|
stats Show routed spend, per-model share, and escalation rates
|
|
29
29
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
30
30
|
export One row per day, harness and model as CSV (--json for rows)
|
|
31
|
-
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH; the remote's skills are installed for Claude Code and omp)
|
|
31
|
+
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH; the remote's skills and its MCP endpoint are installed for Claude Code and omp)
|
|
32
32
|
refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
|
|
33
33
|
token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
|
|
34
34
|
models Show what each complexity tier would consider, and why
|
package/src/lib.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { loadConfig, apiKeySource } from "./config/load.ts";
|
|
|
17
17
|
export { DEFAULT_CONFIG } from "./config/defaults.ts";
|
|
18
18
|
export type { RouterConfig, UpstreamEntry, UpstreamKind, UpstreamModelConfig } from "./config/types.ts";
|
|
19
19
|
export { RESERVED_UPSTREAM_IDS } from "./config/schema.ts";
|
|
20
|
+
export { setKnownUpstreamIds, providerOfSlug } from "./cost/report.ts";
|
|
20
21
|
export type { DeepPartial } from "./config/load.ts";
|
|
21
22
|
export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
|
|
22
23
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
package/src/server/providers.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
82
82
|
};
|
|
83
83
|
const namedServing = (): string[] => cfg.upstreams.filter((u) => u.enabled && namedServingOne(u.id)).map((u) => u.id);
|
|
84
84
|
const staticCatalog = createStaticCatalogSource(cfg, log);
|
|
85
|
-
setKnownUpstreamIds(cfg.upstreams.map((u) => u.id));
|
|
85
|
+
setKnownUpstreamIds(() => cfg.upstreams.map((u) => u.id));
|
|
86
86
|
return {
|
|
87
87
|
upstream: createMultiUpstream(openrouter, ollama, named, () => cfg.upstreams.map((u) => u.id)),
|
|
88
88
|
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log, fetch, db), { available: ollamaServing, cooldownUntilMs: () => ollama.cooldownUntilMs(), lastTrip: () => ollama.lastTrip() }, {
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
import { connectRemote, fetchSetupInfo, MCP_SERVER_NAME, mergeMcpServers } from "../src/cli/connect.ts";
|
|
6
|
+
import { refreshAndRewrite } from "../src/cli/refresh.ts";
|
|
7
|
+
import { parseRemoteRouter } from "../omp-extension/remote-logic.ts";
|
|
8
|
+
|
|
9
|
+
const NL = "\n";
|
|
10
|
+
const read = (p: string): Record<string, unknown> => JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
|
|
11
|
+
const servers = (p: string): Record<string, unknown> => (read(p).mcpServers as Record<string, unknown>) ?? {};
|
|
12
|
+
const auth = (p: string): string => (servers(p)[MCP_SERVER_NAME] as { headers: { Authorization: string } }).headers.Authorization;
|
|
13
|
+
|
|
14
|
+
describe("mergeMcpServers", () => {
|
|
15
|
+
test("adds the team-context server next to the others and keeps every other key", () => {
|
|
16
|
+
const before = JSON.stringify({ $schema: "https://x/mcp-schema.json", mcpServers: { agentdox: { type: "http", url: "http://localhost:3003/mcp" } }, other: 1 });
|
|
17
|
+
const after = mergeMcpServers(before, "https://team.example/mcp", "amrt_k");
|
|
18
|
+
expect(after).not.toBeNull();
|
|
19
|
+
const doc = JSON.parse(after!) as Record<string, unknown>;
|
|
20
|
+
expect(doc.$schema).toBe("https://x/mcp-schema.json");
|
|
21
|
+
expect(doc.other).toBe(1);
|
|
22
|
+
expect(doc.mcpServers).toEqual({
|
|
23
|
+
agentdox: { type: "http", url: "http://localhost:3003/mcp" },
|
|
24
|
+
[MCP_SERVER_NAME]: { type: "http", url: "https://team.example/mcp", headers: { Authorization: "Bearer amrt_k" } },
|
|
25
|
+
});
|
|
26
|
+
expect(after!.endsWith("\n")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("an empty or missing file becomes a fresh mcpServers document", () => {
|
|
30
|
+
expect(JSON.parse(mergeMcpServers("", "https://t/mcp", "k")!)).toEqual({ mcpServers: { [MCP_SERVER_NAME]: { type: "http", url: "https://t/mcp", headers: { Authorization: "Bearer k" } } } });
|
|
31
|
+
expect(JSON.parse(mergeMcpServers(" \n", "https://t/mcp", "k")!)).toHaveProperty("mcpServers");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("is idempotent, removes on null, and leaves a file it cannot parse alone", () => {
|
|
35
|
+
const one = mergeMcpServers("", "https://t/mcp", "k")!;
|
|
36
|
+
expect(mergeMcpServers(one, "https://t/mcp", "k")).toBeNull(); // unchanged
|
|
37
|
+
expect(mergeMcpServers(one, "https://t/mcp", "k2")).not.toBeNull(); // a new key rewrites
|
|
38
|
+
const removed = mergeMcpServers(one, null, "k")!;
|
|
39
|
+
expect(JSON.parse(removed)).toEqual({ mcpServers: {} });
|
|
40
|
+
expect(mergeMcpServers(removed, null, "k")).toBeNull(); // nothing to remove
|
|
41
|
+
expect(mergeMcpServers("{ not json", "https://t/mcp", "k")).toBeNull();
|
|
42
|
+
expect(mergeMcpServers("[1,2]", "https://t/mcp", "k")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("connect writes the team MCP endpoint", () => {
|
|
47
|
+
function fixture(): { home: string; agent: string; env: Record<string, string> } {
|
|
48
|
+
const home = mkdtempSync(join(tmpdir(), "amr-mcp-connect-"));
|
|
49
|
+
mkdirSync(join(home, ".claude"), { recursive: true });
|
|
50
|
+
const agent = join(home, ".omp", "agent");
|
|
51
|
+
mkdirSync(agent, { recursive: true });
|
|
52
|
+
writeFileSync(join(agent, "config.yml"), `extensions: []${NL}`, "utf8");
|
|
53
|
+
const env = { HOME: home, PI_CODING_AGENT_DIR: agent, AUTO_MODEL_ROUTER_HOME: join(home, ".auto-model-router"), HERMES_HOME: join(home, "no-hermes") };
|
|
54
|
+
return { home, agent, env };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("into omp mcp.json and Claude Code ~/.claude.json, only for the harnesses it configured", () => {
|
|
58
|
+
const { home, agent, env } = fixture();
|
|
59
|
+
try {
|
|
60
|
+
// Pre-existing servers and unrelated settings survive.
|
|
61
|
+
writeFileSync(join(agent, "mcp.json"), JSON.stringify({ $schema: "s", mcpServers: { agentdox: { type: "http", url: "http://localhost:3003/mcp" } } }, null, 2));
|
|
62
|
+
writeFileSync(join(home, ".claude.json"), JSON.stringify({ claudeAiMcpEverConnected: true, mcpServers: {} }));
|
|
63
|
+
const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u", name: "Ada", profile: false, dryRun: false, only: ["omp", "claude"], env, home, packageDir: "/pkg", mcp: { url: "https://team.example/mcp" }, platform: "linux", pathHas: () => false });
|
|
64
|
+
expect(r.mcp?.length).toBe(2);
|
|
65
|
+
expect(r.configured.some((c) => c.startsWith(`MCP (${MCP_SERVER_NAME} → https://team.example/mcp`))).toBe(true);
|
|
66
|
+
const omp = read(join(agent, "mcp.json"));
|
|
67
|
+
expect(omp.$schema).toBe("s");
|
|
68
|
+
expect(servers(join(agent, "mcp.json"))).toEqual({
|
|
69
|
+
agentdox: { type: "http", url: "http://localhost:3003/mcp" },
|
|
70
|
+
[MCP_SERVER_NAME]: { type: "http", url: "https://team.example/mcp", headers: { Authorization: "Bearer amrt_k" } },
|
|
71
|
+
});
|
|
72
|
+
const claude = read(join(home, ".claude.json"));
|
|
73
|
+
expect(claude.claudeAiMcpEverConnected).toBe(true);
|
|
74
|
+
expect(servers(join(home, ".claude.json"))[MCP_SERVER_NAME]).toEqual({ type: "http", url: "https://team.example/mcp", headers: { Authorization: "Bearer amrt_k" } });
|
|
75
|
+
|
|
76
|
+
// A second connect with the same key changes nothing.
|
|
77
|
+
const r2 = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u", name: "Ada", profile: false, dryRun: false, only: ["omp", "claude"], env, home, packageDir: "/pkg", mcp: { url: "https://team.example/mcp" }, platform: "linux", pathHas: () => false });
|
|
78
|
+
expect(r2.mcp).toEqual([]);
|
|
79
|
+
|
|
80
|
+
// Only Claude Code asked for: the omp file is not touched even though it exists.
|
|
81
|
+
rmSync(join(agent, "mcp.json"));
|
|
82
|
+
const r3 = connectRemote({ url: "https://team.example", key: "amrt_k3", userId: "u", name: "Ada", profile: false, dryRun: false, only: ["claude"], env, home, packageDir: "/pkg", mcp: { url: "https://team.example/mcp" }, platform: "linux", pathHas: () => false });
|
|
83
|
+
expect(r3.mcp).toEqual([join(home, ".claude.json")]);
|
|
84
|
+
expect(existsSync(join(agent, "mcp.json"))).toBe(false);
|
|
85
|
+
expect(auth(join(home, ".claude.json"))).toBe("Bearer amrt_k3");
|
|
86
|
+
|
|
87
|
+
// A remote that stops serving MCP has the entry removed; the neighbours stay.
|
|
88
|
+
const r4 = connectRemote({ url: "https://team.example", key: "amrt_k3", userId: "u", name: "Ada", profile: false, dryRun: false, only: ["claude"], env, home, packageDir: "/pkg", mcp: { url: null }, platform: "linux", pathHas: () => false });
|
|
89
|
+
expect(r4.mcp).toEqual([join(home, ".claude.json")]);
|
|
90
|
+
expect(servers(join(home, ".claude.json"))).toEqual({});
|
|
91
|
+
expect(read(join(home, ".claude.json")).claudeAiMcpEverConnected).toBe(true);
|
|
92
|
+
expect(r4.configured.some((c) => c.startsWith("MCP ("))).toBe(false);
|
|
93
|
+
} finally {
|
|
94
|
+
rmSync(home, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a dry run reports the files without writing them; no mcp option leaves them alone", () => {
|
|
99
|
+
const { home, agent, env } = fixture();
|
|
100
|
+
try {
|
|
101
|
+
const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u", name: "Ada", profile: false, dryRun: true, only: ["omp"], env, home, packageDir: "/pkg", mcp: { url: "https://team.example/mcp" }, platform: "linux", pathHas: () => false });
|
|
102
|
+
expect(r.mcp).toEqual([join(agent, "mcp.json")]);
|
|
103
|
+
expect(existsSync(join(agent, "mcp.json"))).toBe(false);
|
|
104
|
+
const r2 = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u", name: "Ada", profile: false, dryRun: false, only: ["omp"], env, home, packageDir: "/pkg", platform: "linux", pathHas: () => false });
|
|
105
|
+
expect(r2.mcp).toBeUndefined();
|
|
106
|
+
expect(existsSync(join(agent, "mcp.json"))).toBe(false);
|
|
107
|
+
} finally {
|
|
108
|
+
rmSync(home, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("refresh rewrites the entry with the new key, asking the remote whether it still serves MCP", async () => {
|
|
113
|
+
const { home, agent, env } = fixture();
|
|
114
|
+
const routerHome = env.AUTO_MODEL_ROUTER_HOME!;
|
|
115
|
+
try {
|
|
116
|
+
connectRemote({ url: "https://team.example", key: "amrt_old", userId: "u_ada", name: "Ada", refreshToken: "amrr_r1", keyExpiresAtMs: 1, refreshExpiresAtMs: 2, profile: false, dryRun: false, only: ["omp"], env, home, packageDir: process.cwd(), mcp: { url: "https://team.example/mcp" }, platform: "linux", pathHas: () => false });
|
|
117
|
+
expect(auth(join(agent, "mcp.json"))).toBe("Bearer amrt_old");
|
|
118
|
+
const seen: string[] = [];
|
|
119
|
+
const fetchImpl = (async (input: string | URL | Request) => {
|
|
120
|
+
const url = String(input);
|
|
121
|
+
seen.push(url);
|
|
122
|
+
if (url.endsWith("/setup/info")) return Response.json({ version: "0.18.0", mcp: true });
|
|
123
|
+
if (url.endsWith("/setup/skills")) return new Response("", { status: 404 });
|
|
124
|
+
return Response.json({ key: "amrt_new", keyExpiresAtMs: 50, refreshToken: "amrr_r2", refreshExpiresAtMs: 90 });
|
|
125
|
+
}) as unknown as typeof fetch;
|
|
126
|
+
const remote = parseRemoteRouter(readFileSync(join(routerHome, "remote.json"), "utf8"))!;
|
|
127
|
+
await refreshAndRewrite({ remote, fetchImpl, home, packageDir: process.cwd(), env, platform: "linux", pathHas: () => false, routerHome });
|
|
128
|
+
expect(seen.some((u) => u === "https://team.example/setup/info")).toBe(true);
|
|
129
|
+
expect(servers(join(agent, "mcp.json"))[MCP_SERVER_NAME]).toEqual({ type: "http", url: "https://team.example/mcp", headers: { Authorization: "Bearer amrt_new" } });
|
|
130
|
+
// The remote no longer serves MCP (a plain router answers 404): the entry goes.
|
|
131
|
+
const plain = (async (input: string | URL | Request) => {
|
|
132
|
+
const url = String(input);
|
|
133
|
+
if (url.endsWith("/setup/info") || url.endsWith("/setup/skills")) return new Response("", { status: 404 });
|
|
134
|
+
return Response.json({ key: "amrt_new2", keyExpiresAtMs: 60, refreshToken: "amrr_r3", refreshExpiresAtMs: 99 });
|
|
135
|
+
}) as unknown as typeof fetch;
|
|
136
|
+
const remote2 = parseRemoteRouter(readFileSync(join(routerHome, "remote.json"), "utf8"))!;
|
|
137
|
+
await refreshAndRewrite({ remote: remote2, fetchImpl: plain, home, packageDir: process.cwd(), env, platform: "linux", pathHas: () => false, routerHome });
|
|
138
|
+
expect(servers(join(agent, "mcp.json"))).toEqual({});
|
|
139
|
+
} finally {
|
|
140
|
+
rmSync(home, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("fetchSetupInfo", () => {
|
|
146
|
+
test("reads mcp from a team, and answers false for a plain router, a bad body or a dead remote", async () => {
|
|
147
|
+
expect(await fetchSetupInfo("https://t", (async () => Response.json({ mcp: true })) as unknown as typeof fetch)).toEqual({ mcp: true });
|
|
148
|
+
expect(await fetchSetupInfo("https://t", (async () => Response.json({ mcp: "yes" })) as unknown as typeof fetch)).toEqual({ mcp: false });
|
|
149
|
+
expect(await fetchSetupInfo("https://t", (async () => new Response("", { status: 404 })) as unknown as typeof fetch)).toEqual({ mcp: false });
|
|
150
|
+
expect(await fetchSetupInfo("https://t", (async () => new Response("<html>", { status: 200 })) as unknown as typeof fetch)).toEqual({ mcp: false });
|
|
151
|
+
expect(
|
|
152
|
+
await fetchSetupInfo("https://t", (async () => {
|
|
153
|
+
throw new Error("down");
|
|
154
|
+
}) as unknown as typeof fetch),
|
|
155
|
+
).toEqual({ mcp: false });
|
|
156
|
+
});
|
|
157
|
+
});
|
package/test/upstreams.test.ts
CHANGED
|
@@ -108,7 +108,7 @@ describe("static catalog", () => {
|
|
|
108
108
|
applyConfigPatch(cfg, { upstreams: [{ id: "vllm", kind: "openai", baseUrl: "http://vllm:8000/v1", models: [{ id: "llama", input: 0, output: 0 }] }] } as never);
|
|
109
109
|
const second = src.get(twins);
|
|
110
110
|
expect(second).not.toBe(first);
|
|
111
|
-
expect(second[0]!.isFree).toBe(
|
|
111
|
+
expect(second[0]!.isFree).toBe(false); // $0 self-hosted models must not fall under the free-tier exclusion
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
|