auto-model-router 0.7.3 → 0.8.1

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.7.3",
10
+ "version": "0.8.1",
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.7.3",
17
+ "version": "0.8.1",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1306,6 +1306,17 @@ whatever else is authenticated. A local router deliberately gets no such entry
1306
1306
  is ephemeral, so a persisted one names a dead socket next launch — but a remote's URL and
1307
1307
  key are stable. **The file then holds the member key: treat it as a secret.**
1308
1308
 
1309
+ **Short-lived keys.** A remote that issues them (the team edition does) hands `connect` a
1310
+ refresh token beside the key (`--refresh-token`, `--key-expires`, `--refresh-expires`,
1311
+ `--device`), all kept in `remote.json`. The omp extension trades the refresh token for a
1312
+ new key a day before expiry, at session start, and re-writes every config `connect`
1313
+ wrote; `auto-model-router refresh` does the same by hand (`--force` to do it early), and
1314
+ `auto-model-router token` prints a key that is good right now, refreshing first if needed —
1315
+ the shape a harness key-helper wants (Claude Code's `apiKeyHelper`). The remote keeps the
1316
+ old key valid until its own expiry, so a session still holding it is never cut. A refresh
1317
+ token presented twice means the credential was copied: the remote revokes that device, and
1318
+ the machine onboards again.
1319
+
1309
1320
  In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
1310
1321
  remote router serves every repo on the machine with that repo's shared context. The remote
1311
1322
  decides what to do with it: a team edition that pins a scope on the member's group
@@ -22,6 +22,12 @@ export interface RemoteRouter {
22
22
  userId: string;
23
23
  name: string;
24
24
  joinedAtMs: number;
25
+ /** Present when the remote issues short-lived keys: trades for the next key (see src/cli/refresh.ts). */
26
+ refreshToken?: string;
27
+ keyExpiresAtMs?: number;
28
+ refreshExpiresAtMs?: number;
29
+ /** What the remote calls this machine. */
30
+ device?: string;
25
31
  }
26
32
 
27
33
  export function remoteFilePath(routerHome: string): string {
@@ -33,7 +39,17 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
33
39
  try {
34
40
  const raw = JSON.parse(text) as Record<string, unknown>;
35
41
  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 };
42
+ return {
43
+ url: raw.url.replace(/\/+$/, ""),
44
+ key: raw.key,
45
+ userId: typeof raw.userId === "string" ? raw.userId : "",
46
+ name: typeof raw.name === "string" ? raw.name : "",
47
+ joinedAtMs: typeof raw.joinedAtMs === "number" ? raw.joinedAtMs : 0,
48
+ ...(typeof raw.refreshToken === "string" && raw.refreshToken !== "" ? { refreshToken: raw.refreshToken } : {}),
49
+ ...(typeof raw.keyExpiresAtMs === "number" ? { keyExpiresAtMs: raw.keyExpiresAtMs } : {}),
50
+ ...(typeof raw.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: raw.refreshExpiresAtMs } : {}),
51
+ ...(typeof raw.device === "string" && raw.device !== "" ? { device: raw.device } : {}),
52
+ };
37
53
  } catch {
38
54
  return null;
39
55
  }
@@ -27,6 +27,7 @@ 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
29
  import { readRemoteRouter, remoteProviderRegistration } from "./remote-logic.ts";
30
+ import { refreshAndRewrite, shouldRefresh } from "../src/cli/refresh.ts";
30
31
  import type { StartedServer } from "../src/server/http.ts";
31
32
  import type { RouterConfig } from "../src/config/types.ts";
32
33
 
@@ -161,8 +162,21 @@ export default function (pi: ExtensionAPI): void {
161
162
  // Remote mode (`auto-model-router connect`): a router elsewhere is the
162
163
  // router. Register it as the provider with its key and bind nothing
163
164
  // locally; the other extensions find it through remote.json.
164
- const remote = readRemoteRouter(routerHome());
165
+ let remote = readRemoteRouter(routerHome());
165
166
  if (remote !== null) {
167
+ // A short-lived key is traded a day ahead of its expiry, and every config
168
+ // re-written, so no session ever starts on a dead key. The remote keeps the
169
+ // old key valid until its own expiry, so this session's main handle (which
170
+ // omp resolved from models.yml before we loaded) is not cut either way.
171
+ if (shouldRefresh(remote)) {
172
+ try {
173
+ const fresh = await refreshAndRewrite({ remote });
174
+ remote = { ...remote, key: fresh.key, refreshToken: fresh.refreshToken, keyExpiresAtMs: fresh.keyExpiresAtMs, refreshExpiresAtMs: fresh.refreshExpiresAtMs };
175
+ writeEmbedLog(`remote credential refreshed; key valid until ${new Date(fresh.keyExpiresAtMs).toISOString()}`);
176
+ } catch (err) {
177
+ writeEmbedLog(`remote credential refresh failed: ${err instanceof Error ? err.message : String(err)}`);
178
+ }
179
+ }
166
180
  pi.registerProvider(EMBED_PROVIDER_ID, remoteProviderRegistration(remote, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend, deriveAgentdoxScope(process.cwd())));
167
181
  pi.setLabel(`auto-model-router remote (${remote.url.replace(/^https?:\/\//, "")})`);
168
182
  writeEmbedLog(`remote mode url=${remote.url} user=${remote.userId} session=${sessionId}`);
@@ -152,14 +152,13 @@ const clip = (text: string, max: number): string => (text.length > max ? `${text
152
152
  export function whyReasons(reasons: readonly string[] | undefined, limit = 2): string[] {
153
153
  if (reasons === undefined || reasons.length === 0) return [];
154
154
  const picked: string[] = [];
155
- const seen = new Set<number>();
155
+ // One line per category: the router often records the same fact twice from
156
+ // different angles ("policy: pinned to x" beside "pinned to x by session
157
+ // override"), and a toast that says it twice has wasted half its room.
156
158
  for (const re of REASON_PRIORITY) {
157
- for (let i = 0; i < reasons.length && picked.length < limit; i++) {
158
- const r = reasons[i];
159
- if (r === undefined || seen.has(i) || !re.test(r)) continue;
160
- seen.add(i);
161
- picked.push(clip(r.replace(/\s+/g, " ").trim(), 90));
162
- }
159
+ const hit = reasons.find((r) => re.test(r));
160
+ if (hit === undefined) continue;
161
+ picked.push(clip(hit.replace(/\s+/g, " ").trim(), 90));
163
162
  if (picked.length >= limit) break;
164
163
  }
165
164
  // Nothing matched a pattern: the first reason is the ranking rationale itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
package/src/cli/args.ts CHANGED
@@ -30,6 +30,8 @@ const BOOLEAN_FLAGS: Record<string, true> = {
30
30
 
31
31
  const COMMANDS: Record<string, true> = {
32
32
  serve: true,
33
+ refresh: true,
34
+ token: true,
33
35
  stats: true,
34
36
  report: true,
35
37
  export: true,
@@ -44,8 +44,13 @@ export interface ConnectOptions {
44
44
  packageDir: string;
45
45
  /** Cost figures omp shows for the remote's virtual models, USD per million tokens. */
46
46
  blend?: { inputPerMtok: number; outputPerMtok: number };
47
- /** Adds `X-Agentdox-Scope` to omp's models.yml entry. Machine-wide: only for a single-project machine. */
47
+ /** Adds `X-Agentdox-Scope` to omp's models.yml entry. Machine-wide: only for a single-project machine. Undefined keeps what the managed block already has. */
48
48
  agentdoxScope?: string;
49
+ /** Short-lived credential fields from a remote that issues them; absent for a permanent key. */
50
+ refreshToken?: string;
51
+ keyExpiresAtMs?: number;
52
+ refreshExpiresAtMs?: number;
53
+ device?: string;
49
54
  platform: string;
50
55
  pathHas: (bin: string) => boolean;
51
56
  }
@@ -190,6 +195,16 @@ export function mergeModelsYml(before: string, blockText: string): string {
190
195
  return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`;
191
196
  }
192
197
 
198
+ /** The `X-Agentdox-Scope` the managed block carries, or "" when none. */
199
+ export function existingBlockScope(text: string): string {
200
+ const begin = text.indexOf(MODELS_YML_BEGIN);
201
+ if (begin < 0) return "";
202
+ const end = text.indexOf(MODELS_YML_END, begin);
203
+ const block = text.slice(begin, end < 0 ? text.length : end);
204
+ const m = /X-Agentdox-Scope:\s*(\S+)/.exec(block);
205
+ return m?.[1] ?? "";
206
+ }
207
+
193
208
  /** True when the file already defines our provider outside a block we manage. */
194
209
  export function hasForeignRouterProvider(text: string): boolean {
195
210
  if (text.includes(MODELS_YML_BEGIN)) return false;
@@ -207,7 +222,25 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
207
222
  // 1. remote.json: what puts the omp extensions into remote mode.
208
223
  const rh = routerHomeOf(o);
209
224
  report.remoteFile = remoteFilePath(rh);
210
- write(report.remoteFile, `${JSON.stringify({ url: o.url, key: o.key, userId: o.userId, name: o.name, joinedAtMs: Date.now() }, null, 2)}\n`);
225
+ const previous = existsSync(report.remoteFile) ? (JSON.parse(readFileSync(report.remoteFile, "utf8")) as Record<string, unknown>) : {};
226
+ write(
227
+ report.remoteFile,
228
+ `${JSON.stringify(
229
+ {
230
+ url: o.url,
231
+ key: o.key,
232
+ userId: o.userId,
233
+ name: o.name,
234
+ joinedAtMs: typeof previous.joinedAtMs === "number" ? previous.joinedAtMs : Date.now(),
235
+ ...(o.refreshToken !== undefined && o.refreshToken !== "" ? { refreshToken: o.refreshToken } : {}),
236
+ ...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
237
+ ...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
238
+ ...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
239
+ },
240
+ null,
241
+ 2,
242
+ )}\n`,
243
+ );
211
244
 
212
245
  // 2. omp
213
246
  const agentDir = ompAgentDir(o);
@@ -225,7 +258,9 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
225
258
  report.notes.push(`${modelsPath} already defines an auto-model-router provider by hand; left alone — remove it to let connect manage the remote entry`);
226
259
  report.configured.push(`omp (${cfgPath}; extensions only)`);
227
260
  } else {
228
- const modelsAfter = mergeModelsYml(modelsBefore, renderRemoteModelsYml(o.url, o.key, o.blend ?? { inputPerMtok: 1.1, outputPerMtok: 4.4 }, o.agentdoxScope ?? ""));
261
+ // A refresh re-writes the block without knowing the scope: keep the one already there.
262
+ const scope = o.agentdoxScope ?? existingBlockScope(modelsBefore);
263
+ const modelsAfter = mergeModelsYml(modelsBefore, renderRemoteModelsYml(o.url, o.key, o.blend ?? { inputPerMtok: 1.1, outputPerMtok: 4.4 }, scope));
229
264
  if (modelsAfter !== modelsBefore) {
230
265
  // Never overwrite another provider's work without a way back.
231
266
  if (modelsBefore !== "" && !o.dryRun) writeFileSync(`${modelsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, modelsBefore, "utf8");
@@ -292,6 +327,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
292
327
  }
293
328
  } else report.notes.push("add the environment lines to your shell profile, or re-run with --profile");
294
329
  report.notes.push("omp's models.yml now carries the member key; treat that file as a secret");
330
+ if (o.refreshToken !== undefined && o.refreshToken !== "") report.notes.push("the key is short-lived: omp refreshes it at session start; `auto-model-router refresh` does it by hand, and `auto-model-router token` prints a current key for a harness key-helper");
295
331
  return report;
296
332
  }
297
333
 
@@ -316,8 +352,31 @@ export async function connectCommand(args: CliArgs): Promise<void> {
316
352
  const home = process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir();
317
353
  // A single-project machine can label every request; a machine with several
318
354
  // repos should leave it off and let the extensions send the workspace's own.
319
- const agentdoxScope = flagString(args, "scope") ?? "";
320
- 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, agentdoxScope });
355
+ const scopeFlag = flagString(args, "scope");
356
+ // A remote that issues short-lived keys hands these over beside the key.
357
+ const refreshToken = flagString(args, "refresh-token") ?? "";
358
+ const keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
359
+ const refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
360
+ const device = flagString(args, "device") ?? "";
361
+ const report = connectRemote({
362
+ url,
363
+ key,
364
+ userId,
365
+ name,
366
+ profile: args.flags.has("profile"),
367
+ dryRun: args.flags.has("dry-run"),
368
+ only,
369
+ env: process.env,
370
+ home,
371
+ packageDir,
372
+ platform: process.platform,
373
+ pathHas,
374
+ ...(scopeFlag === undefined ? {} : { agentdoxScope: scopeFlag }),
375
+ ...(refreshToken === "" ? {} : { refreshToken }),
376
+ ...(Number.isFinite(keyExpires) ? { keyExpiresAtMs: keyExpires } : {}),
377
+ ...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}),
378
+ ...(device === "" ? {} : { device }),
379
+ });
321
380
  console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
322
381
  for (const c of report.configured) console.log(` configured ${c}`);
323
382
  for (const s of report.skipped) console.log(` skipped ${s}`);
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Short-lived remote credentials.
3
+ *
4
+ * A remote router (the team edition) may hand a machine an access key that
5
+ * expires plus a refresh token that trades for the next one. `remote.json`
6
+ * carries all of it; `auto-model-router refresh` trades early and re-writes
7
+ * every harness config `connect` wrote, and `auto-model-router token` prints a
8
+ * key that is good right now (refreshing first when needed), which is what a
9
+ * harness that can run a command for its key — Claude Code's `apiKeyHelper` —
10
+ * wants. The omp extension refreshes on its own at session start.
11
+ *
12
+ * A refresh a day early costs nothing: the remote keeps the old key valid until
13
+ * its own expiry, so a session still holding it is never cut.
14
+ */
15
+
16
+ import { homedir } from "node:os";
17
+ import { dirname, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { readRemoteRouter, type RemoteRouter } from "../../omp-extension/remote-logic.ts";
20
+ import { routerHome } from "../../omp-extension/router-url.ts";
21
+ import type { CliArgs } from "./args.ts";
22
+ import { connectRemote } from "./connect.ts";
23
+
24
+ /** How close to expiry a key is refreshed. Wide, so a machine used once a day never sees a dead key. */
25
+ export const REFRESH_AHEAD_MS = 24 * 3_600_000;
26
+
27
+ export interface RefreshedCredential {
28
+ key: string;
29
+ keyExpiresAtMs: number;
30
+ refreshToken: string;
31
+ refreshExpiresAtMs: number;
32
+ device?: string;
33
+ }
34
+
35
+ /** True when the credential can and should be traded now: it has a refresh token and its key is near or past expiry. */
36
+ export function shouldRefresh(remote: RemoteRouter, nowMs = Date.now()): boolean {
37
+ if (remote.refreshToken === undefined || remote.refreshToken === "") return false;
38
+ if (remote.keyExpiresAtMs === undefined) return false;
39
+ return remote.keyExpiresAtMs - nowMs <= REFRESH_AHEAD_MS;
40
+ }
41
+
42
+ export class RefreshError extends Error {
43
+ constructor(
44
+ public readonly code: string,
45
+ message: string,
46
+ ) {
47
+ super(message);
48
+ this.name = "RefreshError";
49
+ }
50
+ }
51
+
52
+ /** Trades the refresh token at the remote for the next credential. */
53
+ export async function refreshCredential(remote: RemoteRouter, fetchImpl: typeof fetch = fetch): Promise<RefreshedCredential> {
54
+ if (remote.refreshToken === undefined || remote.refreshToken === "") throw new RefreshError("no_refresh_token", "this machine holds no refresh token; onboard it again with a setup token");
55
+ const res = await fetchImpl(`${remote.url}/auth/refresh`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify({ refreshToken: remote.refreshToken }),
59
+ signal: AbortSignal.timeout(15_000),
60
+ });
61
+ const body = (await res.json().catch(() => null)) as { key?: unknown; keyExpiresAtMs?: unknown; refreshToken?: unknown; refreshExpiresAtMs?: unknown; device?: unknown; error?: { code?: string; message?: string } } | null;
62
+ if (!res.ok || body === null || typeof body.key !== "string" || typeof body.refreshToken !== "string") {
63
+ throw new RefreshError(body?.error?.code ?? `http_${res.status}`, body?.error?.message ?? `the remote answered ${res.status} to the refresh`);
64
+ }
65
+ return {
66
+ key: body.key,
67
+ keyExpiresAtMs: typeof body.keyExpiresAtMs === "number" ? body.keyExpiresAtMs : Date.now(),
68
+ refreshToken: body.refreshToken,
69
+ refreshExpiresAtMs: typeof body.refreshExpiresAtMs === "number" ? body.refreshExpiresAtMs : Date.now(),
70
+ ...(typeof body.device === "string" ? { device: body.device } : {}),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Refreshes and re-writes every place the key lives: remote.json and the
76
+ * harness configs `connect` manages. Returns the fresh credential. `home` and
77
+ * `packageDir` are injectable for tests.
78
+ */
79
+ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?: typeof fetch; home?: string; packageDir?: string; env?: Record<string, string | undefined>; platform?: string; pathHas?: (bin: string) => boolean }): Promise<RefreshedCredential> {
80
+ const fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch);
81
+ const home = opts.home ?? (process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir());
82
+ const packageDir = opts.packageDir ?? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
83
+ connectRemote({
84
+ url: opts.remote.url,
85
+ key: fresh.key,
86
+ userId: opts.remote.userId,
87
+ name: opts.remote.name,
88
+ refreshToken: fresh.refreshToken,
89
+ keyExpiresAtMs: fresh.keyExpiresAtMs,
90
+ refreshExpiresAtMs: fresh.refreshExpiresAtMs,
91
+ device: fresh.device ?? opts.remote.device ?? "",
92
+ profile: false,
93
+ dryRun: false,
94
+ only: [],
95
+ env: opts.env ?? process.env,
96
+ home,
97
+ packageDir,
98
+ platform: opts.platform ?? process.platform,
99
+ pathHas: opts.pathHas ?? ((bin) => Bun.which(bin) !== null),
100
+ // undefined keeps whatever scope the managed models.yml block already carries.
101
+ });
102
+ return fresh;
103
+ }
104
+
105
+ /** `auto-model-router refresh [--force]` */
106
+ export async function refreshCommand(args: CliArgs): Promise<void> {
107
+ const remote = readRemoteRouter(routerHome());
108
+ if (remote === null) throw new Error(`no remote router configured (${routerHome()}/remote.json); run connect first`);
109
+ if (!args.flags.has("force") && !shouldRefresh(remote)) {
110
+ const left = remote.keyExpiresAtMs === undefined ? "no expiry" : `${Math.max(0, Math.round((remote.keyExpiresAtMs - Date.now()) / 3_600_000))}h left`;
111
+ console.log(`the access key does not need refreshing yet (${left}); --force refreshes anyway`);
112
+ return;
113
+ }
114
+ const fresh = await refreshAndRewrite({ remote });
115
+ console.log(`refreshed: the new access key lasts until ${new Date(fresh.keyExpiresAtMs).toISOString()}; every harness config was re-written`);
116
+ }
117
+
118
+ /**
119
+ * `auto-model-router token`: a key that is good right now, on stdout and
120
+ * nothing else — the shape a harness's key-helper command expects. Refreshes
121
+ * first when the key is near expiry.
122
+ */
123
+ export async function tokenCommand(args: CliArgs): Promise<void> {
124
+ const remote = readRemoteRouter(routerHome());
125
+ if (remote === null) throw new Error(`no remote router configured (${routerHome()}/remote.json); run connect first`);
126
+ if (!args.flags.has("no-refresh") && shouldRefresh(remote)) {
127
+ try {
128
+ const fresh = await refreshAndRewrite({ remote });
129
+ process.stdout.write(`${fresh.key}\n`);
130
+ return;
131
+ } catch (err) {
132
+ // A key that is still valid beats no key: fall through and print what we hold.
133
+ if (remote.keyExpiresAtMs !== undefined && remote.keyExpiresAtMs <= Date.now()) throw err;
134
+ console.error(`warning: refresh failed (${err instanceof Error ? err.message : String(err)}); printing the current key`);
135
+ }
136
+ }
137
+ process.stdout.write(`${remote.key}\n`);
138
+ }
139
+
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { configCommand } from "./cli/config-cmd.ts";
13
13
  import { explainCommand } from "./cli/explain.ts";
14
14
  import { exportCommand } from "./cli/export.ts";
15
15
  import { connectCommand } from "./cli/connect.ts";
16
+ import { refreshCommand, tokenCommand } from "./cli/refresh.ts";
16
17
  import { modelsCommand } from "./cli/models.ts";
17
18
  import { reportCommand } from "./cli/report.ts";
18
19
  import { serveCommand } from "./cli/serve.ts";
@@ -26,7 +27,9 @@ Usage: auto-model-router <command> [options]
26
27
  stats Show routed spend, per-model share, and escalation rates
27
28
  report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
28
29
  export One row per day, harness and model as CSV (--json for rows)
29
- connect Point this machine at a remote router (--url, --key; --scope labels a single-project machine; --profile persists the environment)
30
+ connect Point this machine at a remote router (--url, --key[, --refresh-token]; --scope labels a single-project machine; --profile persists the environment)
31
+ refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
32
+ token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
30
33
  models Show what each complexity tier would consider, and why
31
34
  explain Route a saved request without dispatching it, and explain the decision
32
35
  config Interactive wizard over the router's own config.yml
@@ -84,6 +87,12 @@ async function main(): Promise<number> {
84
87
  case "join": // the first release's name
85
88
  await connectCommand(args);
86
89
  return 0;
90
+ case "refresh":
91
+ await refreshCommand(args);
92
+ return 0;
93
+ case "token":
94
+ await tokenCommand(args);
95
+ return 0;
87
96
  case "models":
88
97
  await modelsCommand(args);
89
98
  return 0;
@@ -5,7 +5,8 @@ import { join } from "node:path";
5
5
 
6
6
  import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
7
7
  import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
8
- import { hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
8
+ import { existingBlockScope, hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
9
+ import { refreshAndRewrite, refreshCredential, RefreshError, shouldRefresh } from "../src/cli/refresh.ts";
9
10
 
10
11
  /**
11
12
  * Remote mode: remote.json puts the omp extensions on a router elsewhere,
@@ -147,3 +148,77 @@ describe("omp models.yml for a remote router", () => {
147
148
  expect(hasForeignRouterProvider(yaml("providers:", " openai: {}"))).toBe(false);
148
149
  });
149
150
  });
151
+
152
+ describe("short-lived remote credentials", () => {
153
+ const NL = String.fromCharCode(10);
154
+ const remote = { url: "https://team.example", key: "amrt_old", userId: "u_ada", name: "Ada", joinedAtMs: 1, refreshToken: "amrr_r1", keyExpiresAtMs: 0, refreshExpiresAtMs: 0, device: "laptop" };
155
+
156
+ test("remote.json round-trips the credential fields, and a permanent key has none", () => {
157
+ const parsed = parseRemoteRouter(JSON.stringify(remote))!;
158
+ expect(parsed).toMatchObject({ refreshToken: "amrr_r1", keyExpiresAtMs: 0, refreshExpiresAtMs: 0, device: "laptop" });
159
+ const permanent = parseRemoteRouter(JSON.stringify({ url: "https://t", key: "k" }))!;
160
+ expect(permanent.refreshToken).toBeUndefined();
161
+ expect(shouldRefresh(permanent)).toBe(false);
162
+ });
163
+
164
+ test("a key is refreshed a day ahead of expiry, never without a refresh token", () => {
165
+ const now = 1_000_000_000_000;
166
+ const day = 24 * 3_600_000;
167
+ expect(shouldRefresh({ ...remote, keyExpiresAtMs: now + 3 * day }, now)).toBe(false);
168
+ expect(shouldRefresh({ ...remote, keyExpiresAtMs: now + day - 1 }, now)).toBe(true);
169
+ expect(shouldRefresh({ ...remote, keyExpiresAtMs: now - 1 }, now)).toBe(true); // already dead: still worth a try
170
+ expect(shouldRefresh({ ...remote, keyExpiresAtMs: now - 1, refreshToken: "" }, now)).toBe(false);
171
+ });
172
+
173
+ test("refreshCredential trades at /auth/refresh and surfaces the remote's refusal code", async () => {
174
+ const calls: { url: string; body: string }[] = [];
175
+ const ok = (async (url: string | URL | Request, init?: RequestInit) => {
176
+ calls.push({ url: String(url), body: String(init?.body) });
177
+ return Response.json({ key: "amrt_new", keyExpiresAtMs: 5, refreshToken: "amrr_r2", refreshExpiresAtMs: 9, device: "laptop" });
178
+ }) as unknown as typeof fetch;
179
+ const fresh = await refreshCredential(remote, ok);
180
+ expect(fresh).toEqual({ key: "amrt_new", keyExpiresAtMs: 5, refreshToken: "amrr_r2", refreshExpiresAtMs: 9, device: "laptop" });
181
+ expect(calls[0]).toEqual({ url: "https://team.example/auth/refresh", body: JSON.stringify({ refreshToken: "amrr_r1" }) });
182
+ const refused = (async () => Response.json({ error: { code: "refresh_reused", message: "already used" } }, { status: 401 })) as unknown as typeof fetch;
183
+ let err: RefreshError | null = null;
184
+ try {
185
+ await refreshCredential(remote, refused);
186
+ } catch (e) {
187
+ err = e as RefreshError;
188
+ }
189
+ expect(err?.code).toBe("refresh_reused");
190
+ expect(err?.message).toBe("already used");
191
+ await expect(refreshCredential({ ...remote, refreshToken: "" }, ok)).rejects.toBeInstanceOf(RefreshError);
192
+ });
193
+
194
+ test("refreshAndRewrite re-writes remote.json and the managed models.yml block, keeping its scope and join time", async () => {
195
+ const home = mkdtempSync(join(tmpdir(), "amr-refresh-"));
196
+ const agent = join(home, ".omp", "agent");
197
+ mkdirSync(agent, { recursive: true });
198
+ writeFileSync(join(agent, "config.yml"), "extensions: []" + NL);
199
+ const routerHome = join(home, ".auto-model-router");
200
+ const env = { HOME: home, PI_CODING_AGENT_DIR: agent, AUTO_MODEL_ROUTER_HOME: routerHome, HERMES_HOME: join(home, "no-hermes") };
201
+ try {
202
+ // First: a connect with a scope and a credential.
203
+ const { connectRemote } = await import("../src/cli/connect.ts");
204
+ connectRemote({ url: "https://team.example", key: "amrt_old", userId: "u_ada", name: "Ada", refreshToken: "amrr_r1", keyExpiresAtMs: 1, refreshExpiresAtMs: 2, device: "laptop", agentdoxScope: "omp-router", profile: false, dryRun: false, only: ["omp"], env, home, packageDir: process.cwd(), platform: "linux", pathHas: () => false });
205
+ const before = JSON.parse(readFileSync(join(routerHome, "remote.json"), "utf8")) as Record<string, unknown>;
206
+ expect(before).toMatchObject({ key: "amrt_old", refreshToken: "amrr_r1", keyExpiresAtMs: 1, device: "laptop" });
207
+ const models0 = readFileSync(join(agent, "models.yml"), "utf8");
208
+ expect(models0).toContain("apiKey: amrt_old");
209
+ expect(existingBlockScope(models0)).toBe("omp-router");
210
+ // Then a refresh, which knows nothing about the scope.
211
+ const fetchImpl = (async () => Response.json({ key: "amrt_new", keyExpiresAtMs: 50, refreshToken: "amrr_r2", refreshExpiresAtMs: 90 })) as unknown as typeof fetch;
212
+ const fresh = await refreshAndRewrite({ remote: parseRemoteRouter(readFileSync(join(routerHome, "remote.json"), "utf8"))!, fetchImpl, home, packageDir: process.cwd(), env, platform: "linux", pathHas: () => false });
213
+ expect(fresh.key).toBe("amrt_new");
214
+ const after = JSON.parse(readFileSync(join(routerHome, "remote.json"), "utf8")) as Record<string, unknown>;
215
+ expect(after).toMatchObject({ key: "amrt_new", refreshToken: "amrr_r2", keyExpiresAtMs: 50, refreshExpiresAtMs: 90, device: "laptop", joinedAtMs: before.joinedAtMs });
216
+ const models1 = readFileSync(join(agent, "models.yml"), "utf8");
217
+ expect(models1).toContain("apiKey: amrt_new");
218
+ expect(models1).not.toContain("amrt_old");
219
+ expect(existingBlockScope(models1)).toBe("omp-router"); // kept, not lost
220
+ } finally {
221
+ rmSync(home, { recursive: true, force: true });
222
+ }
223
+ });
224
+ });
@@ -242,6 +242,8 @@ describe("verbose toast", () => {
242
242
  expect(whyReasons(undefined)).toEqual([]);
243
243
  // At most two lines, however many reasons the router recorded.
244
244
  expect(whyReasons(["failover: a", "policy: b", "held: c", "cache warm"]).length).toBe(2);
245
+ // The same fact recorded twice from different angles takes one line, not both.
246
+ expect(whyReasons(["policy: pinned to ollama/gpt-oss:20b", "pinned to ollama/gpt-oss:20b by session override"])).toEqual(["policy: pinned to ollama/gpt-oss:20b"]);
245
247
  });
246
248
 
247
249
  test("an unreported cost falls back to the prediction, and thin decisions stay short", () => {