auto-model-router 0.6.0 → 0.6.2
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 +19 -12
- package/hermes-plugin/__init__.py +5 -5
- package/omp-extension/remote-logic.ts +93 -0
- package/omp-extension/report-logic.ts +5 -1
- package/omp-extension/router-embed.ts +9 -9
- package/omp-extension/router-toast.ts +1 -1
- package/omp-extension/router-url.ts +6 -6
- package/package.json +1 -1
- package/src/catalog/composite.ts +14 -2
- package/src/cli/args.ts +1 -0
- package/src/cli/{join.ts → connect.ts} +31 -34
- package/src/index.ts +5 -4
- package/src/server/http.ts +5 -1
- package/src/server/providers.ts +1 -0
- package/test/ollama.test.ts +26 -0
- package/test/{team-client.test.ts → remote.test.ts} +32 -29
- package/test/report-logic.test.ts +2 -0
- package/omp-extension/team-logic.ts +0 -88
|
@@ -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.6.
|
|
10
|
+
"version": "0.6.2",
|
|
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.6.
|
|
17
|
+
"version": "0.6.2",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1149,6 +1149,13 @@ What happens once it is on:
|
|
|
1149
1149
|
cooldown.
|
|
1150
1150
|
- **Ollama reports no cost per response**, so the ledger records the
|
|
1151
1151
|
predicted figure at list price for those rows.
|
|
1152
|
+
- **Ollama Cloud alone works.** With `ollama.enabled` and no OpenRouter key,
|
|
1153
|
+
the router routes over Ollama's models only. OpenRouter's catalog is public,
|
|
1154
|
+
so it is still read (the twins' benchmarks and capabilities come from it),
|
|
1155
|
+
but its models are never candidates; `/health` lists what can serve under
|
|
1156
|
+
`serving` (`["ollama"]`, `["openrouter","ollama"]`, …). Tier coverage is
|
|
1157
|
+
whatever Ollama's catalog spans; a tier with nothing in it relaxes to the
|
|
1158
|
+
best available band, as it does under any other narrowing.
|
|
1152
1159
|
|
|
1153
1160
|
Ollama's compatibility layer differs from OpenRouter's in a few ways the
|
|
1154
1161
|
router handles for you: no `models[]` fallback cascade, no `tool_choice`,
|
|
@@ -1247,18 +1254,18 @@ override already pinned one. Every field is optional; a malformed header is
|
|
|
1247
1254
|
ignored rather than failing the turn. The decision trail records what the
|
|
1248
1255
|
policy changed (`policy: …`).
|
|
1249
1256
|
|
|
1250
|
-
##
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
extensions run in **
|
|
1255
|
-
omp's provider with
|
|
1256
|
-
`/router` hub and digest extensions talk to
|
|
1257
|
-
locally; the
|
|
1258
|
-
config, installs the Hermes plugins and points them at the
|
|
1259
|
-
and the Aider settings, and prints (or with `--profile` persists)
|
|
1260
|
-
for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
|
|
1261
|
-
Delete `
|
|
1257
|
+
## Using a remote router
|
|
1258
|
+
|
|
1259
|
+
`auto-model-router connect --url <router> --key <key>` points this machine at a router
|
|
1260
|
+
running elsewhere: a shared instance on a LAN, or a team edition front door. It writes
|
|
1261
|
+
`<router home>/remote.json`, after which the omp extensions run in **remote mode**: the
|
|
1262
|
+
embed extension registers the remote router as omp's provider with that key instead of
|
|
1263
|
+
binding a local one, and the toast, `/router` hub and digest extensions talk to it. Nothing
|
|
1264
|
+
is classified or selected locally; the remote router is the router. The same command adds
|
|
1265
|
+
the extensions to omp's config, installs the Hermes plugins and points them at the remote,
|
|
1266
|
+
adds the Codex provider and the Aider settings, and prints (or with `--profile` persists)
|
|
1267
|
+
the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
|
|
1268
|
+
shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
|
|
1262
1269
|
|
|
1263
1270
|
## Multiple coding harnesses, one router
|
|
1264
1271
|
|
|
@@ -30,11 +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
|
-
#
|
|
34
|
-
# $HERMES_HOME/.env; the plugin then points Hermes at
|
|
33
|
+
# Remote mode: `auto-model-router connect` sets AUTO_MODEL_ROUTER_URL (and the
|
|
34
|
+
# key) in $HERMES_HOME/.env; the plugin then points Hermes at that router and
|
|
35
35
|
# spawns nothing locally.
|
|
36
|
-
|
|
37
|
-
BASE_URL = f"{
|
|
36
|
+
REMOTE_URL = os.environ.get("AUTO_MODEL_ROUTER_URL", "").rstrip("/")
|
|
37
|
+
BASE_URL = f"{REMOTE_URL}/v1" if REMOTE_URL else f"http://127.0.0.1:{PORT}/v1"
|
|
38
38
|
|
|
39
39
|
# The router binary, provided by `npm install -g auto-model-router`.
|
|
40
40
|
BIN = "auto-model-router"
|
|
@@ -98,7 +98,7 @@ def _spawn_router() -> None:
|
|
|
98
98
|
raise RuntimeError(f"auto-model-router did not come up on port {PORT}")
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
if not
|
|
101
|
+
if not REMOTE_URL:
|
|
102
102
|
_spawn_router()
|
|
103
103
|
profile = ProviderProfile(
|
|
104
104
|
name="auto-model-router",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-router mode for the omp extensions. `auto-model-router connect`
|
|
3
|
+
* writes `<router home>/remote.json`; when it exists the embed extension
|
|
4
|
+
* registers the REMOTE router as omp's provider instead of binding a local
|
|
5
|
+
* one, and the toast, hub and digest extensions talk to it with the key.
|
|
6
|
+
* Nothing is classified or selected locally: the remote router is the router.
|
|
7
|
+
* A shared router on a LAN and the team edition are both remotes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const REMOTE_FILE = "remote.json";
|
|
14
|
+
/** The name the first release used; still read so nothing breaks on upgrade. */
|
|
15
|
+
const LEGACY_FILE = "team.json";
|
|
16
|
+
|
|
17
|
+
export interface RemoteRouter {
|
|
18
|
+
/** The remote router, no trailing slash, e.g. https://router.example.com */
|
|
19
|
+
url: string;
|
|
20
|
+
/** The key that router expects (`server.apiKey`, or a team user key). */
|
|
21
|
+
key: string;
|
|
22
|
+
userId: string;
|
|
23
|
+
name: string;
|
|
24
|
+
joinedAtMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function remoteFilePath(routerHome: string): string {
|
|
28
|
+
return join(routerHome, REMOTE_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parses remote.json defensively; anything malformed ⇒ not in remote mode. */
|
|
32
|
+
export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
35
|
+
if (typeof raw.url !== "string" || typeof raw.key !== "string" || raw.url === "" || raw.key === "") return null;
|
|
36
|
+
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 };
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readRemoteRouter(routerHome: string): RemoteRouter | null {
|
|
43
|
+
for (const path of [remoteFilePath(routerHome), join(routerHome, LEGACY_FILE)]) {
|
|
44
|
+
if (!existsSync(path)) continue;
|
|
45
|
+
try {
|
|
46
|
+
return parseRemoteRouter(readFileSync(path, "utf8"));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The virtual models a remote router serves; costs are the fallback blend so omp can show estimates. */
|
|
55
|
+
export const REMOTE_MODELS: readonly { id: string; name: string }[] = [
|
|
56
|
+
{ id: "auto", name: "auto (remote)" },
|
|
57
|
+
{ id: "auto-cheap", name: "auto-cheap (remote)" },
|
|
58
|
+
{ id: "auto-max", name: "auto-max (remote)" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* omp's provider registration for remote mode: the remote's /v1 with the key,
|
|
63
|
+
* the session and subagent tags, and the virtual models. Costs are USD per
|
|
64
|
+
* million tokens, like the embedded config.
|
|
65
|
+
*/
|
|
66
|
+
export function remoteProviderRegistration(remote: RemoteRouter, sessionId: string, subagent: boolean, blend: { inputPerMtok: number; outputPerMtok: number }): {
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
api: string;
|
|
69
|
+
apiKey: string;
|
|
70
|
+
headers: Record<string, string>;
|
|
71
|
+
models: { id: string; name: string; api: string; reasoning: boolean; input: string[]; contextWindow: number; maxTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number } }[];
|
|
72
|
+
} {
|
|
73
|
+
const headers: Record<string, string> = {};
|
|
74
|
+
if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
|
|
75
|
+
if (subagent) headers["X-Omp-Subagent"] = "1";
|
|
76
|
+
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
77
|
+
return {
|
|
78
|
+
baseUrl: `${remote.url}/v1`,
|
|
79
|
+
api: "openai-completions",
|
|
80
|
+
apiKey: remote.key,
|
|
81
|
+
headers,
|
|
82
|
+
models: REMOTE_MODELS.map((m) => ({
|
|
83
|
+
id: m.id,
|
|
84
|
+
name: m.name,
|
|
85
|
+
api: "openai-completions",
|
|
86
|
+
reasoning: false,
|
|
87
|
+
input: ["text", "image"],
|
|
88
|
+
contextWindow: 200_000,
|
|
89
|
+
maxTokens: 32_000,
|
|
90
|
+
cost: { input: round(blend.inputPerMtok), output: round(blend.outputPerMtok), cacheRead: round(blend.inputPerMtok * 0.1), cacheWrite: round(blend.inputPerMtok * 1.25) },
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -104,6 +104,8 @@ export function renderSoftFailureSpikes(spikes: readonly SoftFailureSpikeView[]
|
|
|
104
104
|
export interface HealthSnapshot {
|
|
105
105
|
status?: string;
|
|
106
106
|
apiKeyConfigured?: boolean;
|
|
107
|
+
/** Upstreams that can serve a turn right now (`openrouter` needs its key; `ollama` needs to be on and out of cooldown). */
|
|
108
|
+
serving?: string[];
|
|
107
109
|
apiKeySource?: string;
|
|
108
110
|
agentdox?: { url?: string; defaultScope?: string; recordTurns?: boolean } | null;
|
|
109
111
|
ollama?: {
|
|
@@ -133,7 +135,9 @@ const mins = (ms: number): string => (ms >= 3_600_000 ? `${(ms / 3_600_000).toFi
|
|
|
133
135
|
/** Renders `/health` as a few plain lines for the transcript. */
|
|
134
136
|
export function renderStatus(baseUrl: string, h: HealthSnapshot, nowMs = Date.now()): string {
|
|
135
137
|
const out: string[] = [`auto-model-router at ${baseUrl}: ${h.status ?? "unknown"}`];
|
|
136
|
-
|
|
138
|
+
const orKey = h.apiKeyConfigured === true ? `configured (${h.apiKeySource ?? "?"})` : h.serving?.includes("ollama") === true ? "missing · routing over ollama cloud only" : "MISSING";
|
|
139
|
+
out.push(`openrouter: key ${orKey}`);
|
|
140
|
+
if (h.serving !== undefined && h.serving.length === 0) out.push("serving: NOTHING (no OpenRouter key and Ollama off or cooling down)");
|
|
137
141
|
const c = h.catalog;
|
|
138
142
|
if (c !== undefined && c !== null) {
|
|
139
143
|
const shrink = c.shrink !== undefined && c.shrink !== null ? ` · SHRANK ${c.shrink.fromModels ?? "?"} -> ${c.shrink.toModels ?? "?"}` : "";
|
|
@@ -26,7 +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 {
|
|
29
|
+
import { readRemoteRouter, remoteProviderRegistration } from "./remote-logic.ts";
|
|
30
30
|
import type { StartedServer } from "../src/server/http.ts";
|
|
31
31
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
32
32
|
|
|
@@ -168,14 +168,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
168
168
|
// notifications to that exact session (see router-toast.ts).
|
|
169
169
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
170
170
|
|
|
171
|
-
//
|
|
172
|
-
// router. Register it as the provider with
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
176
|
-
pi.registerProvider(EMBED_PROVIDER_ID,
|
|
177
|
-
pi.setLabel(`auto-model-router
|
|
178
|
-
writeEmbedLog(`
|
|
171
|
+
// Remote mode (`auto-model-router connect`): a router elsewhere is the
|
|
172
|
+
// router. Register it as the provider with its key and bind nothing
|
|
173
|
+
// locally; the other extensions find it through remote.json.
|
|
174
|
+
const remote = readRemoteRouter(routerHome());
|
|
175
|
+
if (remote !== null) {
|
|
176
|
+
pi.registerProvider(EMBED_PROVIDER_ID, remoteProviderRegistration(remote, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend));
|
|
177
|
+
pi.setLabel(`auto-model-router remote (${remote.url.replace(/^https?:\/\//, "")})`);
|
|
178
|
+
writeEmbedLog(`remote mode url=${remote.url} user=${remote.userId} session=${sessionId}`);
|
|
179
179
|
return;
|
|
180
180
|
}
|
|
181
181
|
|
|
@@ -68,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
68
68
|
// The embedded router binds a free OS-assigned port, so the URL
|
|
69
69
|
// is resolved fresh each tick from the port file the embed
|
|
70
70
|
// extension writes at session_start.
|
|
71
|
-
//
|
|
71
|
+
// Remote mode resolves to the remote router with its key.
|
|
72
72
|
const routerUrl = routerBaseUrl();
|
|
73
73
|
|
|
74
74
|
let res: Response;
|
|
@@ -15,7 +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 {
|
|
18
|
+
import { readRemoteRouter } from "./remote-logic.ts";
|
|
19
19
|
import { resolveRouterUrl } from "./toast-logic.ts";
|
|
20
20
|
|
|
21
21
|
/** `$AUTO_MODEL_ROUTER_HOME` with `~` expanded, default `~/.auto-model-router`. */
|
|
@@ -37,9 +37,9 @@ export function readRouterConfigText(): string | null {
|
|
|
37
37
|
|
|
38
38
|
/** Base URL of the router this omp process should talk to. */
|
|
39
39
|
export function routerBaseUrl(): string {
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
40
|
+
// Remote mode: the router elsewhere is the router.
|
|
41
|
+
const remote = readRemoteRouter(routerHome());
|
|
42
|
+
if (remote !== null) return remote.url;
|
|
43
43
|
return resolveRouterUrl(
|
|
44
44
|
process.env.AUTO_MODEL_ROUTER_URL,
|
|
45
45
|
readRouterConfigText(),
|
|
@@ -51,8 +51,8 @@ export function routerBaseUrl(): string {
|
|
|
51
51
|
|
|
52
52
|
/** Authorization header for a router configured with `server.apiKey`. */
|
|
53
53
|
export function routerAuthHeaders(): Record<string, string> {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
54
|
+
const remote = readRemoteRouter(routerHome());
|
|
55
|
+
if (remote !== null) return { authorization: `Bearer ${remote.key}` };
|
|
56
56
|
const key = process.env.AUTO_MODEL_ROUTER_API_KEY;
|
|
57
57
|
return key === undefined || key === "" ? {} : { authorization: `Bearer ${key}` };
|
|
58
58
|
}
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* Ollama models are left out entirely: a candidate that will 402 or 429 is
|
|
11
11
|
* not a candidate, and hiding it here means the turn routes straight to an
|
|
12
12
|
* OpenRouter model instead of paying a doomed dispatch first.
|
|
13
|
+
*
|
|
14
|
+
* The same rule covers OpenRouter: without a key its models cannot be
|
|
15
|
+
* dispatched (its catalog is public, so they would still be listed), and
|
|
16
|
+
* `serveOpenRouter` leaves them out so an Ollama-only deployment routes over
|
|
17
|
+
* Ollama Cloud alone instead of picking models that 401 at dispatch time. The
|
|
18
|
+
* OpenRouter catalog is still fetched: Ollama's models borrow their twins'
|
|
19
|
+
* benchmarks and capabilities from it.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import type { OllamaAvailability } from "../upstream/ollama.ts";
|
|
@@ -25,6 +32,8 @@ export interface CompositeBias {
|
|
|
25
32
|
usage: OllamaUsageSource;
|
|
26
33
|
/** When given, read on every use instead of the static pair, so a config hot reload applies. */
|
|
27
34
|
live?: () => { costBias: number; biasUntilUsage: number };
|
|
35
|
+
/** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
|
|
36
|
+
serveOpenRouter?: () => boolean;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export function createCompositeCatalog(
|
|
@@ -36,6 +45,7 @@ export function createCompositeCatalog(
|
|
|
36
45
|
let lastBase: CatalogSnapshot | null = null;
|
|
37
46
|
let lastOllama: readonly CatalogModel[] = [];
|
|
38
47
|
let lastAvailable = true;
|
|
48
|
+
let lastServeBase = true;
|
|
39
49
|
let lastBias = 1;
|
|
40
50
|
let merged: CatalogSnapshot | null = null;
|
|
41
51
|
|
|
@@ -47,13 +57,15 @@ export function createCompositeCatalog(
|
|
|
47
57
|
|
|
48
58
|
function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
|
|
49
59
|
const available = availability.available();
|
|
60
|
+
const serveBase = bias.serveOpenRouter?.() ?? true;
|
|
50
61
|
const providerBias = currentBias();
|
|
51
|
-
if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && providerBias === lastBias) return merged;
|
|
62
|
+
if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && serveBase === lastServeBase && providerBias === lastBias) return merged;
|
|
52
63
|
lastBase = base;
|
|
53
64
|
lastOllama = models;
|
|
54
65
|
lastAvailable = available;
|
|
66
|
+
lastServeBase = serveBase;
|
|
55
67
|
lastBias = providerBias;
|
|
56
|
-
merged = mergeSnapshots(base, available ? models : []);
|
|
68
|
+
merged = serveBase ? mergeSnapshots(base, available ? models : []) : { ...base, models: available ? [...models] : [] };
|
|
57
69
|
// A fresh object either way once anything changed; stamp the live bias so
|
|
58
70
|
// candidate scoring reads it off the snapshot it is ranking.
|
|
59
71
|
merged = { ...merged, providerBias: { ollama: providerBias } };
|
package/src/cli/args.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `auto-model-router
|
|
3
|
-
*
|
|
4
|
-
* extensions then run in
|
|
5
|
-
* and configures every harness it finds:
|
|
2
|
+
* `auto-model-router connect --url <router> --key <key>`: point this machine
|
|
3
|
+
* at a remote router (a shared one on a LAN, or the team edition). Writes
|
|
4
|
+
* `<router home>/remote.json` (the omp extensions then run in remote mode and
|
|
5
|
+
* never bind a local router), and configures every harness it finds:
|
|
6
6
|
*
|
|
7
7
|
* omp the four extensions are added to ~/.omp/agent/config.yml
|
|
8
8
|
* Hermes the provider plugin and the native plugin are copied into
|
|
9
|
-
* $HERMES_HOME/plugins and .env points them at the
|
|
9
|
+
* $HERMES_HOME/plugins and .env points them at the remote
|
|
10
10
|
* Codex ~/.codex/config.toml gains the auto-model-router provider
|
|
11
11
|
* Aider ~/.aider.conf.yml gains the base URL, key and model
|
|
12
12
|
* Claude Code ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY (printed; --profile persists)
|
|
@@ -21,10 +21,10 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileS
|
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { dirname, join, resolve } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
-
import {
|
|
24
|
+
import { remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
25
25
|
import { flagString, type CliArgs } from "./args.ts";
|
|
26
26
|
|
|
27
|
-
export interface
|
|
27
|
+
export interface ConnectOptions {
|
|
28
28
|
url: string;
|
|
29
29
|
key: string;
|
|
30
30
|
userId: string;
|
|
@@ -41,8 +41,8 @@ export interface JoinOptions {
|
|
|
41
41
|
pathHas: (bin: string) => boolean;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export interface
|
|
45
|
-
|
|
44
|
+
export interface ConnectReport {
|
|
45
|
+
remoteFile: string;
|
|
46
46
|
configured: string[];
|
|
47
47
|
skipped: string[];
|
|
48
48
|
envLines: string[];
|
|
@@ -51,22 +51,22 @@ export interface JoinReport {
|
|
|
51
51
|
|
|
52
52
|
const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw);
|
|
53
53
|
|
|
54
|
-
function routerHomeOf(o:
|
|
54
|
+
function routerHomeOf(o: ConnectOptions): string {
|
|
55
55
|
return expand(o.env.AUTO_MODEL_ROUTER_HOME ?? join(o.home, ".auto-model-router"), o.home);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function ompAgentDir(o:
|
|
58
|
+
function ompAgentDir(o: ConnectOptions): string {
|
|
59
59
|
const d = o.env.PI_CODING_AGENT_DIR;
|
|
60
60
|
return d !== undefined && d !== "" ? expand(d, o.home) : join(o.home, ".omp", "agent");
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function hermesHome(o:
|
|
63
|
+
function hermesHome(o: ConnectOptions): string {
|
|
64
64
|
const d = o.env.HERMES_HOME;
|
|
65
65
|
if (d !== undefined && d !== "") return expand(d, o.home);
|
|
66
66
|
return o.platform === "win32" ? join(o.env.LOCALAPPDATA ?? join(o.home, "AppData", "Local"), "hermes") : join(o.home, ".hermes");
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const wants = (o:
|
|
69
|
+
const wants = (o: ConnectOptions, h: string): boolean => o.only.length === 0 || o.only.includes(h);
|
|
70
70
|
|
|
71
71
|
/** Adds lines to a YAML `extensions:` list by text, keeping everything else byte-identical. */
|
|
72
72
|
export function addExtensions(text: string, paths: readonly string[]): string {
|
|
@@ -95,7 +95,7 @@ export function setDotenv(text: string, values: Record<string, string>): string
|
|
|
95
95
|
export function codexBlock(url: string): string {
|
|
96
96
|
return `
|
|
97
97
|
[model_providers.auto-model-router]
|
|
98
|
-
name = "auto-model-router (
|
|
98
|
+
name = "auto-model-router (remote)"
|
|
99
99
|
base_url = "${url}/v1"
|
|
100
100
|
env_key = "AUTO_MODEL_ROUTER_API_KEY"
|
|
101
101
|
wire_api = "responses"
|
|
@@ -103,18 +103,18 @@ http_headers = { "X-Omp-Harness" = "codex" }
|
|
|
103
103
|
`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
export function
|
|
107
|
-
const report:
|
|
106
|
+
export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
107
|
+
const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [] };
|
|
108
108
|
const write = (path: string, content: string): void => {
|
|
109
109
|
if (o.dryRun) return;
|
|
110
110
|
mkdirSync(dirname(path), { recursive: true });
|
|
111
111
|
writeFileSync(path, content, "utf8");
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
-
// 1.
|
|
114
|
+
// 1. remote.json: what puts the omp extensions into remote mode.
|
|
115
115
|
const rh = routerHomeOf(o);
|
|
116
|
-
report.
|
|
117
|
-
write(report.
|
|
116
|
+
report.remoteFile = remoteFilePath(rh);
|
|
117
|
+
write(report.remoteFile, `${JSON.stringify({ url: o.url, key: o.key, userId: o.userId, name: o.name, joinedAtMs: Date.now() }, null, 2)}\n`);
|
|
118
118
|
|
|
119
119
|
// 2. omp
|
|
120
120
|
const agentDir = ompAgentDir(o);
|
|
@@ -176,40 +176,37 @@ export function joinTeam(o: JoinOptions): JoinReport {
|
|
|
176
176
|
} else {
|
|
177
177
|
const shell = o.env.SHELL ?? "";
|
|
178
178
|
const rc = shell.includes("zsh") ? join(o.home, ".zshrc") : join(o.home, ".bashrc");
|
|
179
|
-
const block = `\n# auto-model-router
|
|
179
|
+
const block = `\n# auto-model-router remote (added by \`auto-model-router connect\`)\n${report.envLines.map((l) => `export ${l}`).join("\n")}\n`;
|
|
180
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));
|
|
181
|
+
if (!before.includes("# auto-model-router remote") && !before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8");
|
|
182
|
+
else write(rc, before.replace(/\n# auto-model-router (?:remote|team)[^\n]*\n(?:export [^\n]*\n)*/, block));
|
|
183
183
|
report.notes.push(`environment appended to ${rc}; open a new shell or source it`);
|
|
184
184
|
}
|
|
185
185
|
} else report.notes.push("add the environment lines to your shell profile, or re-run with --profile");
|
|
186
186
|
return report;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
export async function
|
|
189
|
+
export async function connectCommand(args: CliArgs): Promise<void> {
|
|
190
190
|
const url = (flagString(args, "url") ?? process.env.AUTO_MODEL_ROUTER_URL ?? "").replace(/\/+$/, "");
|
|
191
191
|
const key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
|
|
192
|
-
if (url === "" || key === "") throw new Error("
|
|
192
|
+
if (url === "" || key === "") throw new Error("connect needs --url <remote router> and --key <its key>");
|
|
193
193
|
const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
|
|
194
194
|
const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
|
|
195
195
|
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
196
|
-
// Verify the key before touching anything.
|
|
196
|
+
// Verify the key against the route every router serves before touching anything.
|
|
197
197
|
let name = flagString(args, "name") ?? "";
|
|
198
198
|
let userId = flagString(args, "user-id") ?? "";
|
|
199
199
|
try {
|
|
200
|
-
const res = await fetch(`${url}/
|
|
201
|
-
if (res.status === 401) throw new Error("the
|
|
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
|
-
}
|
|
200
|
+
const res = await fetch(`${url}/v1/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
|
|
201
|
+
if (res.status === 401) throw new Error("the remote router rejected this key");
|
|
207
202
|
} catch (err) {
|
|
208
203
|
if (err instanceof Error && err.message.includes("rejected")) throw err;
|
|
209
204
|
console.log(`warning: could not reach ${url} to verify the key (${err instanceof Error ? err.message : String(err)}); configuring anyway`);
|
|
210
205
|
}
|
|
211
|
-
|
|
212
|
-
|
|
206
|
+
// HOME wins when set (Git Bash, WSL, CI) so a caller can redirect every write; the OS profile otherwise.
|
|
207
|
+
const home = process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir();
|
|
208
|
+
const report = connectRemote({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home, packageDir, platform: process.platform, pathHas });
|
|
209
|
+
console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
|
|
213
210
|
for (const c of report.configured) console.log(` configured ${c}`);
|
|
214
211
|
for (const s of report.skipped) console.log(` skipped ${s}`);
|
|
215
212
|
console.log("environment:");
|
package/src/index.ts
CHANGED
|
@@ -12,7 +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 {
|
|
15
|
+
import { connectCommand } from "./cli/connect.ts";
|
|
16
16
|
import { modelsCommand } from "./cli/models.ts";
|
|
17
17
|
import { reportCommand } from "./cli/report.ts";
|
|
18
18
|
import { serveCommand } from "./cli/serve.ts";
|
|
@@ -26,7 +26,7 @@ Usage: auto-model-router <command> [options]
|
|
|
26
26
|
stats Show routed spend, per-model share, and escalation rates
|
|
27
27
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
28
28
|
export One row per day, harness and model as CSV (--json for rows)
|
|
29
|
-
|
|
29
|
+
connect Point this machine at a remote router (--url, --key; --profile persists the environment)
|
|
30
30
|
models Show what each complexity tier would consider, and why
|
|
31
31
|
explain Route a saved request without dispatching it, and explain the decision
|
|
32
32
|
config Interactive wizard over the router's own config.yml
|
|
@@ -80,8 +80,9 @@ async function main(): Promise<number> {
|
|
|
80
80
|
case "export":
|
|
81
81
|
await exportCommand(args);
|
|
82
82
|
return 0;
|
|
83
|
-
case "
|
|
84
|
-
|
|
83
|
+
case "connect":
|
|
84
|
+
case "join": // the first release's name
|
|
85
|
+
await connectCommand(args);
|
|
85
86
|
return 0;
|
|
86
87
|
case "models":
|
|
87
88
|
await modelsCommand(args);
|
package/src/server/http.ts
CHANGED
|
@@ -241,7 +241,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
if (cfg.openrouter.apiKey === "") {
|
|
244
|
-
log.warn("
|
|
244
|
+
if (ollama !== null) log.warn("no OpenRouter key: routing over Ollama Cloud models only (OpenRouter's catalog is read for metadata, never served)");
|
|
245
|
+
else log.warn("OPENROUTER_API_KEY is not set and Ollama is off; /v1/chat/completions will fail at dispatch time");
|
|
245
246
|
}
|
|
246
247
|
if (ollama !== null) {
|
|
247
248
|
log.info("ollama cloud upstream enabled", {
|
|
@@ -608,6 +609,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
608
609
|
return json({
|
|
609
610
|
status: "ok",
|
|
610
611
|
apiKeyConfigured: cfg.openrouter.apiKey !== "",
|
|
612
|
+
// Which upstreams turns can actually be served from: OpenRouter needs
|
|
613
|
+
// its key; Ollama needs to be on and out of cooldown.
|
|
614
|
+
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(ollama !== null && ollama.available() ? ["ollama"] : [])],
|
|
611
615
|
// Provenance only; never the key itself.
|
|
612
616
|
apiKeySource: apiKeySource(cfg).source,
|
|
613
617
|
// Provenance only; never the agentdox token itself.
|
package/src/server/providers.ts
CHANGED
|
@@ -55,6 +55,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
55
55
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
56
56
|
usage: ollamaUsage,
|
|
57
57
|
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
58
|
+
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
58
59
|
}),
|
|
59
60
|
ollama,
|
|
60
61
|
ollamaUsage,
|
package/test/ollama.test.ts
CHANGED
|
@@ -398,6 +398,32 @@ describe("multi upstream + composite catalog", () => {
|
|
|
398
398
|
expect(catalog.ollamaModels()).toHaveLength(3); // still known, just hidden
|
|
399
399
|
expect(mergeSnapshots(base, []).models).toBe(base.models);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
test("without an OpenRouter key only the Ollama models are served; the OpenRouter catalog still feeds metadata and lookups", async () => {
|
|
403
|
+
const base: CatalogSnapshot = { models: OR_MODELS, fetchedAtMs: 1, keyScoped: false };
|
|
404
|
+
const openrouter: CatalogSource = { get: async () => base, refresh: async () => base, peek: () => base, find: (s) => OR_MODELS.find((m) => m.slug === s) };
|
|
405
|
+
const ollamaModels = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg: OLLAMA, log });
|
|
406
|
+
let available = true;
|
|
407
|
+
let keyed = false;
|
|
408
|
+
const source = { get: async () => ollamaModels, peek: () => ollamaModels, invalidate: () => {} };
|
|
409
|
+
const breaker = { available: () => available, cooldownUntilMs: () => null, lastTrip: () => null };
|
|
410
|
+
const catalog = createCompositeCatalog(openrouter, source, breaker, { costBias: 1, biasUntilUsage: 1, usage: NO_USAGE, serveOpenRouter: () => keyed });
|
|
411
|
+
|
|
412
|
+
const a = await catalog.get();
|
|
413
|
+
expect(a.models.map((m) => m.provider)).toEqual(["ollama", "ollama", "ollama"]);
|
|
414
|
+
expect(a.fetchedAtMs).toBe(1);
|
|
415
|
+
expect(await catalog.get()).toBe(a); // memoised while nothing changes
|
|
416
|
+
expect(catalog.find("z-ai/glm-5.3-flash")?.provider).toBe("openrouter"); // lookups (served-model attribution) still resolve
|
|
417
|
+
expect(a.models[0]?.quality).toEqual(OR_MODELS.find((m) => m.slug === "z-ai/glm-5.3-flash")?.quality); // twin metadata borrowed
|
|
418
|
+
|
|
419
|
+
available = false;
|
|
420
|
+
expect((await catalog.get()).models).toEqual([]); // nothing can serve: breaker open, no key
|
|
421
|
+
available = true;
|
|
422
|
+
keyed = true; // a hot-reloaded key brings OpenRouter back without a restart
|
|
423
|
+
const c = await catalog.get();
|
|
424
|
+
expect(c.models.length).toBe(OR_MODELS.length + 3);
|
|
425
|
+
expect(c).not.toBe(a);
|
|
426
|
+
});
|
|
401
427
|
});
|
|
402
428
|
|
|
403
429
|
describe("selection over a mixed catalog", () => {
|
|
@@ -3,29 +3,32 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
-
import { addExtensions, codexBlock,
|
|
7
|
-
import {
|
|
6
|
+
import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
|
|
7
|
+
import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* and `
|
|
12
|
-
* does not recognise.
|
|
10
|
+
* Remote mode: remote.json puts the omp extensions on a router elsewhere,
|
|
11
|
+
* and `connect` configures every harness it finds without touching anything
|
|
12
|
+
* it does not recognise.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
describe("
|
|
16
|
-
test("
|
|
17
|
-
expect(
|
|
18
|
-
expect(
|
|
19
|
-
const t =
|
|
15
|
+
describe("remote-logic", () => {
|
|
16
|
+
test("remote.json is parsed defensively and turned into omp's provider registration", () => {
|
|
17
|
+
expect(parseRemoteRouter("nope")).toBeNull();
|
|
18
|
+
expect(parseRemoteRouter(JSON.stringify({ url: "https://t/", key: "" }))).toBeNull();
|
|
19
|
+
const t = parseRemoteRouter(JSON.stringify({ url: "https://team.example/", key: "amrt_k", userId: "u_1", name: "Ada" }))!;
|
|
20
20
|
expect(t.url).toBe("https://team.example");
|
|
21
|
-
const reg =
|
|
21
|
+
const reg = remoteProviderRegistration(t, "sess-1", true, { inputPerMtok: 1, outputPerMtok: 4 });
|
|
22
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
23
|
expect(reg.models.map((m) => m.id)).toEqual(["auto", "auto-cheap", "auto-max"]);
|
|
24
24
|
expect(reg.models[0]!.cost).toEqual({ input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1.25 });
|
|
25
|
-
const dir = mkdtempSync(join(tmpdir(), "amr-
|
|
26
|
-
expect(
|
|
27
|
-
writeFileSync(join(dir, "
|
|
28
|
-
expect(
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-remote-"));
|
|
26
|
+
expect(readRemoteRouter(dir)).toBeNull();
|
|
27
|
+
writeFileSync(join(dir, "remote.json"), JSON.stringify({ url: "https://t", key: "k" }));
|
|
28
|
+
expect(readRemoteRouter(dir)?.key).toBe("k");
|
|
29
|
+
rmSync(join(dir, "remote.json"));
|
|
30
|
+
writeFileSync(join(dir, "team.json"), JSON.stringify({ url: "https://legacy", key: "k2" })); // the first release's name still works
|
|
31
|
+
expect(readRemoteRouter(dir)?.url).toBe("https://legacy");
|
|
29
32
|
rmSync(dir, { recursive: true, force: true });
|
|
30
33
|
});
|
|
31
34
|
|
|
@@ -39,9 +42,9 @@ describe("team-logic", () => {
|
|
|
39
42
|
});
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
describe("
|
|
43
|
-
function scenario(extra: Partial<
|
|
44
|
-
const home = mkdtempSync(join(tmpdir(), "amr-
|
|
45
|
+
describe("connect", () => {
|
|
46
|
+
function scenario(extra: Partial<ConnectOptions> = {}): { home: string; o: ConnectOptions } {
|
|
47
|
+
const home = mkdtempSync(join(tmpdir(), "amr-connect-"));
|
|
45
48
|
const agent = join(home, ".omp", "agent");
|
|
46
49
|
mkdirSync(agent, { recursive: true });
|
|
47
50
|
writeFileSync(join(agent, "config.yml"), "extensions:\n - E:/other/ext.ts\nsetupVersion: 2\n");
|
|
@@ -49,15 +52,15 @@ describe("join", () => {
|
|
|
49
52
|
writeFileSync(join(home, ".hermes", ".env"), "OPENAI_API_KEY=x\n");
|
|
50
53
|
mkdirSync(join(home, ".codex"), { recursive: true });
|
|
51
54
|
writeFileSync(join(home, ".codex", "config.toml"), 'model = "gpt-5"\n');
|
|
52
|
-
const o:
|
|
55
|
+
const o: ConnectOptions = { 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
56
|
return { home, o };
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
test("writes
|
|
59
|
+
test("writes remote.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
|
|
57
60
|
const { home, o } = scenario();
|
|
58
|
-
const r1 =
|
|
59
|
-
expect(existsSync(r1.
|
|
60
|
-
expect(JSON.parse(readFileSync(r1.
|
|
61
|
+
const r1 = connectRemote(o);
|
|
62
|
+
expect(existsSync(r1.remoteFile)).toBe(true);
|
|
63
|
+
expect(JSON.parse(readFileSync(r1.remoteFile, "utf8"))).toMatchObject({ url: "https://team.example", key: "amrt_key", userId: "u_ada", name: "Ada" });
|
|
61
64
|
const ompCfg = readFileSync(join(home, ".omp", "agent", "config.yml"), "utf8");
|
|
62
65
|
expect(ompCfg).toContain("extensions:\n - ");
|
|
63
66
|
expect(ompCfg).toContain("omp-extension/router-embed.ts");
|
|
@@ -72,24 +75,24 @@ describe("join", () => {
|
|
|
72
75
|
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
76
|
// Running again changes nothing.
|
|
74
77
|
const snapshot = [ompCfg, readFileSync(join(home, ".codex", "config.toml"), "utf8"), readFileSync(join(home, ".aider.conf.yml"), "utf8")];
|
|
75
|
-
|
|
78
|
+
connectRemote(o);
|
|
76
79
|
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
80
|
rmSync(home, { recursive: true, force: true });
|
|
78
81
|
});
|
|
79
82
|
|
|
80
83
|
test("--harness restricts, --dry-run writes nothing, --profile appends once to the shell rc", () => {
|
|
81
84
|
const { home, o } = scenario({ only: ["omp"], dryRun: true });
|
|
82
|
-
const r =
|
|
83
|
-
expect(existsSync(r.
|
|
85
|
+
const r = connectRemote(o);
|
|
86
|
+
expect(existsSync(r.remoteFile)).toBe(false);
|
|
84
87
|
expect(r.configured.some((c) => c.startsWith("omp"))).toBe(true);
|
|
85
88
|
expect(r.skipped.some((s) => s.startsWith("Hermes"))).toBe(true);
|
|
86
89
|
expect(existsSync(join(home, ".hermes", "plugins"))).toBe(false);
|
|
87
90
|
const { home: h2, o: o2 } = scenario({ profile: true, env: { SHELL: "/bin/zsh" } });
|
|
88
91
|
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
|
-
|
|
90
|
-
|
|
92
|
+
connectRemote(o2);
|
|
93
|
+
connectRemote(o2);
|
|
91
94
|
const rc = readFileSync(join(h2, ".zshrc"), "utf8");
|
|
92
|
-
expect(rc.split("# auto-model-router
|
|
95
|
+
expect(rc.split("# auto-model-router remote").length).toBe(2);
|
|
93
96
|
expect(rc).toContain("export ANTHROPIC_BASE_URL=https://team.example");
|
|
94
97
|
rmSync(home, { recursive: true, force: true });
|
|
95
98
|
rmSync(h2, { recursive: true, force: true });
|
|
@@ -99,6 +99,8 @@ describe("renderStatus", () => {
|
|
|
99
99
|
test("degrades cleanly when sections are absent", () => {
|
|
100
100
|
const text = renderStatus("http://h", { status: "ok", apiKeyConfigured: false });
|
|
101
101
|
expect(text).toContain("key MISSING");
|
|
102
|
+
expect(renderStatus("http://h", { status: "ok", apiKeyConfigured: false, serving: ["ollama"] })).toContain("routing over ollama cloud only");
|
|
103
|
+
expect(renderStatus("http://h", { status: "ok", apiKeyConfigured: false, serving: [] })).toContain("serving: NOTHING");
|
|
102
104
|
expect(text).toContain("catalog: not fetched yet");
|
|
103
105
|
expect(text).toContain("ollama cloud: disabled");
|
|
104
106
|
expect(text).toContain("agentdox: off");
|
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
}
|