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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +34 -6
- package/omp-extension/remote-logic.ts +17 -1
- package/omp-extension/router-embed.ts +15 -1
- package/omp-extension/router-toast.ts +4 -1
- package/omp-extension/toast-logic.ts +97 -4
- package/package.json +1 -1
- package/src/cli/connect.ts +64 -5
- package/src/cli/refresh.ts +139 -0
- package/src/index.ts +10 -1
- package/test/remote.test.ts +76 -1
- package/test/toast-logic.test.ts +63 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.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.
|
|
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;
|
|
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`)
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
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
package/src/cli/connect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
|
|
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;
|
package/test/remote.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/toast-logic.test.ts
CHANGED
|
@@ -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
|
+
});
|