auto-model-router 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2",
10
+ "version": "0.8.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.7.2",
17
+ "version": "0.8.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -235,7 +235,7 @@ bun tools/install.ts --no-toast --no-configure # only the required embed exten
235
235
  The installer adds:
236
236
 
237
237
  - `router-embed.ts` — **required**; runs the router in-process.
238
- - `router-toast.ts` — optional; chosen-model toasts.
238
+ - `router-toast.ts` — optional; per-turn toasts naming the model and why it was chosen.
239
239
  - `router-configure.ts` — optional; the `/router` command (configure, usage reports, status).
240
240
  - `router-digest.ts` — optional; condenses large tool results with a cheap model before an expensive one reads them (needs `digest.enabled`).
241
241
 
@@ -814,6 +814,7 @@ disk and back up the previous file to a timestamped `.bak`.
814
814
  | `AUTO_MODEL_ROUTER_URL` | Toast/base URL override (the toast reads the shared port file first). | — |
815
815
  | `AUTO_MODEL_ROUTER_API_KEY` | Client bearer for the toast poll when `server.apiKey` is set. | — |
816
816
  | `OMP_HARNESS_ID` | Per-harness toast scoping. | — |
817
+ | `AUTO_MODEL_ROUTER_TOAST` | `compact` for the one-line toast; anything else keeps the decision trail. | verbose |
817
818
 
818
819
  ---
819
820
 
@@ -1305,6 +1306,17 @@ whatever else is authenticated. A local router deliberately gets no such entry
1305
1306
  is ephemeral, so a persisted one names a dead socket next launch — but a remote's URL and
1306
1307
  key are stable. **The file then holds the member key: treat it as a secret.**
1307
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
+
1308
1320
  In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
1309
1321
  remote router serves every repo on the machine with that repo's shared context. The remote
1310
1322
  decides what to do with it: a team edition that pins a scope on the member's group
@@ -1469,11 +1481,27 @@ come from a small omp extension that polls the router's in-process ledger:
1469
1481
  // omp-extension/router-toast.ts (shipped in this repo)
1470
1482
  ```
1471
1483
 
1472
- It raises a TUI toast (`ctx.ui.notify`) like
1473
- `openrouter · meta/muse-glimmer-30b [trivial] · $0.00001` or
1474
- `ollama · glm-5.3-flash [moderate] · $0.00070` whenever a new model is chosen —
1475
- provider first, so a mixed catalog is legible at a glance. Install it by adding
1476
- the file's absolute path to omp's `extensions:` list.
1484
+ It raises a TUI toast (`ctx.ui.notify`) whenever a model is chosen, explaining
1485
+ the decision rather than just naming it — provider first, so a mixed catalog is
1486
+ legible at a glance:
1487
+
1488
+ ```
1489
+ openrouter · openai/gpt-5.2 [hard] · $0.01820
1490
+ why: failover: z-ai/glm-5.3-flash empty_completion (hit the length cap having…
1491
+ escalated from moderate
1492
+ 48.2k prompt · 12.8k compacted · 11 tools · attempt 2 · 2.1s to first token
1493
+ ```
1494
+
1495
+ The **why** lines come from the router's own decision trail, picked by how much
1496
+ they change what you would do: a failover or a policy pin explains a surprising
1497
+ model outright, a hold or a tier rescue explains why the obvious cheaper pick was
1498
+ skipped, and when nothing surprising happened the ranking rationale itself is
1499
+ shown. The last line is what the model was actually handed. An unreported cost
1500
+ falls back to the forecast (`~$…`).
1501
+
1502
+ `AUTO_MODEL_ROUTER_TOAST=compact` restores the old one-liner
1503
+ (`openrouter · meta/muse-glimmer-30b [trivial] · $0.00001`). Install the
1504
+ extension by adding the file's absolute path to omp's `extensions:` list.
1477
1505
 
1478
1506
  Because the embedded router binds a random port, the toast resolves the router
1479
1507
  base URL on every poll in this order: the embedded router's port file
@@ -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}`);
@@ -38,6 +38,9 @@ import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
38
38
  // Empty ⇒ toast every harness (single-harness default).
39
39
  const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
40
40
  const POLL_MS = 2_000;
41
+ // The toast explains the choice by default: model, tier, cost, why it was picked
42
+ // and what it was handed. `AUTO_MODEL_ROUTER_TOAST=compact` restores the one-liner.
43
+ const VERBOSE = (process.env.AUTO_MODEL_ROUTER_TOAST ?? "").toLowerCase() !== "compact";
41
44
 
42
45
  export default function (pi: ExtensionAPI): void {
43
46
  pi.setLabel("auto-model-router toast");
@@ -91,7 +94,7 @@ export default function (pi: ExtensionAPI): void {
91
94
  const entries = body.entries;
92
95
  if (!Array.isArray(entries) || entries.length === 0) return;
93
96
 
94
- for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId)) {
97
+ for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId, VERBOSE)) {
95
98
  ctx.ui.notify(t.text, "info");
96
99
  }
97
100
  lastSeenId = newestId(entries) ?? lastSeenId;
@@ -88,6 +88,20 @@ export interface ToastDecision {
88
88
  * default. Lets the toast scope to a single interactive session.
89
89
  */
90
90
  ompSessionId?: string;
91
+ /** The router's own decision trail, already written for people. */
92
+ reasons?: string[];
93
+ /** Classifier inputs; only a few are worth surfacing. */
94
+ features?: { promptTokens?: number; toolCount?: number; turnDepth?: number; isToolResultContinuation?: boolean } | null;
95
+ /** Attempt index within the turn; >0 means this served after an escalation. */
96
+ attempt?: number;
97
+ /** Prompt tokens compaction removed before dispatch. */
98
+ promptTokensSaved?: number;
99
+ /** What the router expected this to cost, before the upstream reported. */
100
+ predictedUsd?: number;
101
+ latencyMs?: number;
102
+ ttftMs?: number | null;
103
+ task?: string | null;
104
+ classificationSource?: string;
91
105
  }
92
106
 
93
107
  export interface ToastMessage {
@@ -110,10 +124,88 @@ export function providerOf(slug: string): { provider: string; model: string } {
110
124
  return { provider: "openrouter", model: slug };
111
125
  }
112
126
 
113
- export function toToastText(d: ToastDecision): string {
127
+ /**
128
+ * Which parts of the decision trail earn a line in a toast.
129
+ *
130
+ * The router writes many reasons per turn; a toast has room for two. These
131
+ * patterns are ordered by how much they change what the reader would do:
132
+ * a failover or a policy pin explains a surprising model outright, a hold or a
133
+ * rescue explains why the obvious cheaper pick was skipped, and the rest is
134
+ * ordinary ranking that the tier already conveys.
135
+ */
136
+ const REASON_PRIORITY: readonly RegExp[] = [
137
+ /failover|escalat/i,
138
+ /policy|pin(ned)?|allow|deny/i,
139
+ /held|sticky|hysteresis|switch margin/i,
140
+ /budget raised|reasons before it answers/i,
141
+ // The RESCUE wording only: "cheapest above the quality floor" is ordinary
142
+ // ranking, and matching a bare "floor" would push it above real surprises.
143
+ /tier rescue|relaxed|adaptive (floor|ceiling)/i,
144
+ /cache|warm/i,
145
+ /compact/i,
146
+ /explor/i,
147
+ ];
148
+
149
+ const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
150
+
151
+ /** Up to `limit` reasons, most explanatory first, each trimmed for one line. */
152
+ export function whyReasons(reasons: readonly string[] | undefined, limit = 2): string[] {
153
+ if (reasons === undefined || reasons.length === 0) return [];
154
+ const picked: string[] = [];
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.
158
+ for (const re of REASON_PRIORITY) {
159
+ const hit = reasons.find((r) => re.test(r));
160
+ if (hit === undefined) continue;
161
+ picked.push(clip(hit.replace(/\s+/g, " ").trim(), 90));
162
+ if (picked.length >= limit) break;
163
+ }
164
+ // Nothing matched a pattern: the first reason is the ranking rationale itself.
165
+ if (picked.length === 0 && reasons[0] !== undefined) picked.push(clip(reasons[0].replace(/\s+/g, " ").trim(), 90));
166
+ return picked;
167
+ }
168
+
169
+ const tokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 100) / 10}k` : String(n));
170
+
171
+ /** The turn's shape in a few words: what the model was actually handed. */
172
+ export function factsOf(d: ToastDecision): string[] {
173
+ const out: string[] = [];
174
+ const f = d.features ?? undefined;
175
+ if (f?.promptTokens !== undefined && f.promptTokens > 0) out.push(`${tokens(f.promptTokens)} prompt`);
176
+ if (d.promptTokensSaved !== undefined && d.promptTokensSaved > 0) out.push(`${tokens(d.promptTokensSaved)} compacted`);
177
+ if (f?.toolCount !== undefined && f.toolCount > 0) out.push(`${f.toolCount} tools`);
178
+ if (f?.isToolResultContinuation === true) out.push("tool continuation");
179
+ if (d.attempt !== undefined && d.attempt > 0) out.push(`attempt ${d.attempt + 1}`);
180
+ if (d.task !== undefined && d.task !== null && d.task !== "" && d.task !== "coding") out.push(d.task);
181
+ if (d.ttftMs !== undefined && d.ttftMs !== null && d.ttftMs > 0) out.push(`${(d.ttftMs / 1000).toFixed(1)}s to first token`);
182
+ else if (d.latencyMs !== undefined && d.latencyMs > 0) out.push(`${(d.latencyMs / 1000).toFixed(1)}s`);
183
+ return out;
184
+ }
185
+
186
+ /** `$0.00042`, or the prediction when the upstream reported nothing. */
187
+ function costOf(d: ToastDecision): string {
188
+ if (d.reportedUsd !== null && d.reportedUsd !== undefined) return `$${d.reportedUsd.toFixed(5)}`;
189
+ if (d.predictedUsd !== undefined && d.predictedUsd > 0) return `~$${d.predictedUsd.toFixed(5)}`;
190
+ return "";
191
+ }
192
+
193
+ /**
194
+ * The toast body. `verbose` (the default) adds the decision trail and the
195
+ * turn's shape under the headline; `compact` is the original single line, for
196
+ * anyone who wants the model name and nothing else.
197
+ */
198
+ export function toToastText(d: ToastDecision, verbose = true): string {
114
199
  const { provider, model } = providerOf(d.servedSlug ?? d.slug);
115
- const cost = d.reportedUsd === null ? "" : ` \u00b7 $${d.reportedUsd.toFixed(5)}`;
116
- return `${provider} \u00b7 ${model} [${d.tier}]${cost}`;
200
+ const cost = costOf(d);
201
+ const head = `${provider} \u00b7 ${model} [${d.tier}]${cost === "" ? "" : ` \u00b7 ${cost}`}`;
202
+ if (!verbose) return head;
203
+ const lines = [head];
204
+ const why = whyReasons(d.reasons);
205
+ for (const [i, r] of why.entries()) lines.push(`${i === 0 ? "why: " : " "}${r}`);
206
+ const facts = factsOf(d);
207
+ if (facts.length > 0) lines.push(facts.join(" \u00b7 "));
208
+ return lines.join("\n");
117
209
  }
118
210
 
119
211
  /**
@@ -129,6 +221,7 @@ export function selectToasts(
129
221
  lastSeenId: string | null,
130
222
  harnessId = "",
131
223
  ompSessionId = "",
224
+ verbose = true,
132
225
  ): ToastMessage[] {
133
226
  if (lastSeenId === null) return [];
134
227
  // `entries` is newest-first. Entries strictly newer than lastSeenId are the
@@ -143,7 +236,7 @@ export function selectToasts(
143
236
  if (d.wasted) continue;
144
237
  if (harnessId !== "" && d.harnessId !== harnessId) continue;
145
238
  if (ompSessionId !== "" && d.ompSessionId !== ompSessionId) continue;
146
- out.push({ model: d.servedSlug ?? d.slug, tier: d.tier, costUsd: d.reportedUsd, text: toToastText(d) });
239
+ out.push({ model: d.servedSlug ?? d.slug, tier: d.tier, costUsd: d.reportedUsd, text: toToastText(d, verbose) });
147
240
  }
148
241
  return out;
149
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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
+ });
@@ -7,8 +7,10 @@ import {
7
7
  newestId,
8
8
  providerOf,
9
9
  resolveRouterUrl,
10
+ factsOf,
10
11
  selectToasts,
11
12
  toToastText,
13
+ whyReasons,
12
14
  type ToastDecision,
13
15
  } from "../omp-extension/toast-logic.ts";
14
16
 
@@ -199,3 +201,64 @@ describe("toToastText", () => {
199
201
  expect(providerOf("z-ai/glm-5.3-flash")).toEqual({ provider: "openrouter", model: "z-ai/glm-5.3-flash" });
200
202
  });
201
203
  });
204
+
205
+ describe("verbose toast", () => {
206
+ const FULL = dec({
207
+ slug: "ollama/gpt-oss:20b",
208
+ servedSlug: "ollama/gpt-oss:20b",
209
+ tier: "trivial",
210
+ reportedUsd: 0.000308,
211
+ reasons: [
212
+ "policy: pinned to ollama/gpt-oss:20b",
213
+ "completion budget raised 12 to 512: ollama/gpt-oss:20b reasons before it answers",
214
+ "cheapest above the quality floor",
215
+ ],
216
+ features: { promptTokens: 22899, toolCount: 11, isToolResultContinuation: true },
217
+ attempt: 1,
218
+ promptTokensSaved: 12800,
219
+ ttftMs: 2100,
220
+ });
221
+
222
+ test("the headline keeps its shape and the body explains the choice", () => {
223
+ const lines = toToastText(FULL).split(String.fromCharCode(10));
224
+ expect(lines[0]).toBe("ollama · gpt-oss:20b [trivial] · $0.00031");
225
+ expect(lines[1]).toBe("why: policy: pinned to ollama/gpt-oss:20b");
226
+ expect(lines[2]).toContain("completion budget raised");
227
+ expect(lines[3]).toBe("22.9k prompt · 12.8k compacted · 11 tools · tool continuation · attempt 2 · 2.1s to first token");
228
+ });
229
+
230
+ test("compact is the old single line", () => {
231
+ expect(toToastText(FULL, false)).toBe("ollama · gpt-oss:20b [trivial] · $0.00031");
232
+ expect(toToastText(FULL, false).includes(String.fromCharCode(10))).toBe(false);
233
+ });
234
+
235
+ test("a surprise outranks ordinary ranking, and ranking shows when nothing surprised", () => {
236
+ // "cheapest above the quality floor" is ordinary: a failover and a hold win.
237
+ expect(whyReasons(["cheapest above the quality floor", "failover: x/y empty_completion; retrying a/b"])[0]).toContain("failover");
238
+ expect(whyReasons(["cheapest above the quality floor", "held from the previous turn: switch margin not cleared"])[0]).toContain("held");
239
+ // Nothing notable: the ranking rationale itself is the answer.
240
+ expect(whyReasons(["cheapest above the quality floor"])).toEqual(["cheapest above the quality floor"]);
241
+ expect(whyReasons([])).toEqual([]);
242
+ expect(whyReasons(undefined)).toEqual([]);
243
+ // At most two lines, however many reasons the router recorded.
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"]);
247
+ });
248
+
249
+ test("an unreported cost falls back to the prediction, and thin decisions stay short", () => {
250
+ expect(toToastText(dec({ reportedUsd: null, predictedUsd: 0.00042, reasons: [], features: null }))).toBe("openrouter · meta/muse-glimmer-30b [trivial] · ~$0.00042");
251
+ expect(toToastText(dec({ reportedUsd: null, features: null }))).toBe("openrouter · meta/muse-glimmer-30b [trivial]");
252
+ });
253
+
254
+ test("facts skip what a reader does not need", () => {
255
+ expect(factsOf(dec({ features: { promptTokens: 0, toolCount: 0 }, attempt: 0, task: "coding" }))).toEqual([]);
256
+ expect(factsOf(dec({ features: { promptTokens: 900 }, task: "vision" }))).toEqual(["900 prompt", "vision"]);
257
+ });
258
+
259
+ test("selectToasts renders compact when asked", () => {
260
+ const entries = [dec({ id: "d2", slug: "x/b", reasons: ["failover: nope"] }), dec({ id: "d1" })];
261
+ expect(selectToasts(entries, "d1", "", "", false)[0]?.text.includes(String.fromCharCode(10))).toBe(false);
262
+ expect(selectToasts(entries, "d1")[0]?.text.includes(String.fromCharCode(10))).toBe(true);
263
+ });
264
+ });