auto-model-router 0.7.3 → 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 +11 -0
- package/omp-extension/remote-logic.ts +17 -1
- package/omp-extension/router-embed.ts +15 -1
- package/omp-extension/toast-logic.ts +6 -7
- 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 +2 -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
|
@@ -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 {
|
|
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}`);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
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
|
@@ -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", () => {
|