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.
@@ -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.0",
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.0",
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
- ## Joining a team router
1251
-
1252
- The team edition's install script runs `auto-model-router join --url <team> --key <key>`
1253
- on a member's machine. It writes `<router home>/team.json`, after which the omp
1254
- extensions run in **team-client mode**: the embed extension registers the team endpoint as
1255
- omp's provider with the member's key instead of binding a local router, and the toast,
1256
- `/router` hub and digest extensions talk to the team. Nothing is classified or selected
1257
- locally; the team router is the router. The same command adds the extensions to omp's
1258
- config, installs the Hermes plugins and points them at the team, adds the Codex provider
1259
- and the Aider settings, and prints (or with `--profile` persists) the environment lines
1260
- for Claude Code. `--harness omp,hermes` restricts it; `--dry-run` shows the changes.
1261
- Delete `team.json` to leave team mode.
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
- # Team mode: `auto-model-router join` sets AUTO_MODEL_ROUTER_URL (and the key) in
34
- # $HERMES_HOME/.env; the plugin then points Hermes at the team endpoint and
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
- TEAM_URL = os.environ.get("AUTO_MODEL_ROUTER_URL", "").rstrip("/")
37
- BASE_URL = f"{TEAM_URL}/v1" if TEAM_URL else f"http://127.0.0.1:{PORT}/v1"
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 TEAM_URL:
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
- out.push(`openrouter: key ${h.apiKeyConfigured === true ? `configured (${h.apiKeySource ?? "?"})` : "MISSING"}`);
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 { readTeamClient, teamProviderRegistration } from "./team-logic.ts";
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
- // Team-client mode (`auto-model-router join`): the team endpoint is the
172
- // router. Register it as the provider with the member's key and bind
173
- // nothing locally; the other extensions find the team through team.json.
174
- const team = readTeamClient(routerHome());
175
- if (team !== null) {
176
- pi.registerProvider(EMBED_PROVIDER_ID, teamProviderRegistration(team, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend));
177
- pi.setLabel(`auto-model-router team (${team.url.replace(/^https?:\/\//, "")})`);
178
- writeEmbedLog(`team mode url=${team.url} user=${team.userId} session=${sessionId}`);
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
- // Team-client mode resolves to the team endpoint with the member key.
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 { readTeamClient } from "./team-logic.ts";
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
- // Team-client mode: the team endpoint is the router.
41
- const team = readTeamClient(routerHome());
42
- if (team !== null) return team.url;
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 team = readTeamClient(routerHome());
55
- if (team !== null) return { authorization: `Bearer ${team.key}` };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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
@@ -33,6 +33,7 @@ const COMMANDS: Record<string, true> = {
33
33
  stats: true,
34
34
  report: true,
35
35
  export: true,
36
+ connect: true,
36
37
  join: true,
37
38
  models: true,
38
39
  explain: true,
@@ -1,12 +1,12 @@
1
1
  /**
2
- * `auto-model-router join --url <team> --key <key>`: make this machine a
3
- * member of a team router. Writes `<router home>/team.json` (the omp
4
- * extensions then run in team-client mode and never bind a local router),
5
- * and configures every harness it finds:
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 team
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 { teamFilePath } from "../../omp-extension/team-logic.ts";
24
+ import { remoteFilePath } from "../../omp-extension/remote-logic.ts";
25
25
  import { flagString, type CliArgs } from "./args.ts";
26
26
 
27
- export interface JoinOptions {
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 JoinReport {
45
- teamFile: string;
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: JoinOptions): string {
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: JoinOptions): string {
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: JoinOptions): string {
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: JoinOptions, h: string): boolean => o.only.length === 0 || o.only.includes(h);
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 (team)"
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 joinTeam(o: JoinOptions): JoinReport {
107
- const report: JoinReport = { teamFile: "", configured: [], skipped: [], envLines: [], notes: [] };
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. team.json: what puts the omp extensions into team-client mode.
114
+ // 1. remote.json: what puts the omp extensions into remote mode.
115
115
  const rh = routerHomeOf(o);
116
- report.teamFile = teamFilePath(rh);
117
- write(report.teamFile, `${JSON.stringify({ url: o.url, key: o.key, userId: o.userId, name: o.name, joinedAtMs: Date.now() }, null, 2)}\n`);
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 team (added by \`auto-model-router join\`)\n${report.envLines.map((l) => `export ${l}`).join("\n")}\n`;
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 joinCommand(args: CliArgs): Promise<void> {
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("join needs --url <team endpoint> and --key <your team key>");
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}/me`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
201
- if (res.status === 401) throw new Error("the team rejected this key");
202
- if (res.ok) {
203
- const me = (await res.json()) as { user?: { id?: string; name?: string } };
204
- userId = me.user?.id ?? userId;
205
- name = me.user?.name ?? name;
206
- }
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
- const report = joinTeam({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home: homedir(), packageDir, platform: process.platform, pathHas });
212
- console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.teamFile}${name === "" ? "" : ` for ${name}`}`);
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 { joinCommand } from "./cli/join.ts";
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
- join Make this machine a member of a team router (--url, --key; --profile persists the environment)
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 "join":
84
- await joinCommand(args);
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);
@@ -241,7 +241,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
241
241
  }
242
242
 
243
243
  if (cfg.openrouter.apiKey === "") {
244
- log.warn("OPENROUTER_API_KEY is not set; /v1/chat/completions will fail at dispatch time");
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.
@@ -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,
@@ -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, joinTeam, setDotenv, type JoinOptions } from "../src/cli/join.ts";
7
- import { parseTeamClient, readTeamClient, teamProviderRegistration } from "../omp-extension/team-logic.ts";
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
- * Team-client mode: team.json puts the omp extensions on the team endpoint,
11
- * and `join` configures every harness it finds without touching anything it
12
- * does not recognise.
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("team-logic", () => {
16
- test("team.json is parsed defensively and turned into omp's provider registration", () => {
17
- expect(parseTeamClient("nope")).toBeNull();
18
- expect(parseTeamClient(JSON.stringify({ url: "https://t/", key: "" }))).toBeNull();
19
- const t = parseTeamClient(JSON.stringify({ url: "https://team.example/", key: "amrt_k", userId: "u_1", name: "Ada" }))!;
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 = teamProviderRegistration(t, "sess-1", true, { inputPerMtok: 1, outputPerMtok: 4 });
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-team-client-"));
26
- expect(readTeamClient(dir)).toBeNull();
27
- writeFileSync(join(dir, "team.json"), JSON.stringify({ url: "https://t", key: "k" }));
28
- expect(readTeamClient(dir)?.key).toBe("k");
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("join", () => {
43
- function scenario(extra: Partial<JoinOptions> = {}): { home: string; o: JoinOptions } {
44
- const home = mkdtempSync(join(tmpdir(), "amr-join-"));
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: JoinOptions = { url: "https://team.example", key: "amrt_key", userId: "u_ada", name: "Ada", profile: false, dryRun: false, only: [], env: { HOME: home, HERMES_HOME: join(home, ".hermes"), PI_CODING_AGENT_DIR: agent, AUTO_MODEL_ROUTER_HOME: join(home, ".auto-model-router") }, home, packageDir: process.cwd(), platform: "linux", pathHas: (b) => b === "claude" || b === "aider", ...extra };
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 team.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
59
+ test("writes remote.json and configures omp, Hermes, Codex, Aider and Claude Code idempotently", () => {
57
60
  const { home, o } = scenario();
58
- const r1 = joinTeam(o);
59
- expect(existsSync(r1.teamFile)).toBe(true);
60
- expect(JSON.parse(readFileSync(r1.teamFile, "utf8"))).toMatchObject({ url: "https://team.example", key: "amrt_key", userId: "u_ada", name: "Ada" });
61
+ const 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
- joinTeam(o);
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 = joinTeam(o);
83
- expect(existsSync(r.teamFile)).toBe(false);
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
- joinTeam(o2);
90
- joinTeam(o2);
92
+ connectRemote(o2);
93
+ connectRemote(o2);
91
94
  const rc = readFileSync(join(h2, ".zshrc"), "utf8");
92
- expect(rc.split("# auto-model-router team").length).toBe(2);
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
- }