auto-model-router 0.8.1 → 0.10.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 -8
- package/docs/AGENTDOX-BRIDGE.md +10 -0
- package/omp-extension/remote-logic.ts +27 -1
- package/omp-extension/router-embed.ts +13 -0
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +9 -4
- package/src/cli/config-wizard.ts +1 -0
- package/src/cli/connect.ts +59 -16
- package/src/cli/credential-store.ts +157 -0
- package/src/cli/refresh.ts +20 -7
- package/src/config/defaults.ts +8 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +7 -0
- package/src/context/scope.ts +29 -0
- package/src/index.ts +1 -1
- package/src/server/turn.ts +8 -1
- package/src/wire/openai/request.ts +5 -2
- package/test/failover.test.ts +1 -1
- package/test/remote.test.ts +115 -11
- package/test/scope.test.ts +31 -0
- package/test/turn.test.ts +76 -1
|
@@ -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.10.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.10.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1312,18 +1312,33 @@ refresh token beside the key (`--refresh-token`, `--key-expires`, `--refresh-exp
|
|
|
1312
1312
|
new key a day before expiry, at session start, and re-writes every config `connect`
|
|
1313
1313
|
wrote; `auto-model-router refresh` does the same by hand (`--force` to do it early), and
|
|
1314
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
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1315
|
+
the shape a harness key-helper wants. The remote keeps the old key valid until its own
|
|
1316
|
+
expiry, so a session still holding it is never cut. A refresh token presented twice means
|
|
1317
|
+
the credential was copied: the remote revokes that device, and the machine onboards again.
|
|
1318
|
+
|
|
1319
|
+
The refresh token, the long-lived secret, does not sit in a file: `connect` puts it in the
|
|
1320
|
+
operating system's credential store — DPAPI on Windows (ciphertext in
|
|
1321
|
+
`<router home>/refresh.dpapi`, decryptable only by that Windows user on that machine),
|
|
1322
|
+
the login keychain on macOS, the Secret Service on Linux — and `remote.json` only names
|
|
1323
|
+
which store holds it. `<router home>/refresh.token`, owner-readable only, is the fallback
|
|
1324
|
+
when no store is usable (a CI box), and the note at the end of `connect` says when that
|
|
1325
|
+
happened. **Claude Code** gets its settings file written instead of an environment: the
|
|
1326
|
+
`env` block carries `ANTHROPIC_BASE_URL`, and `apiKeyHelper` runs
|
|
1327
|
+
`auto-model-router token`, so no key is in its environment or on disk for it and a
|
|
1328
|
+
short-lived key rotates underneath a running session.
|
|
1319
1329
|
|
|
1320
1330
|
In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
|
|
1321
1331
|
remote router serves every repo on the machine with that repo's shared context. The remote
|
|
1322
1332
|
decides what to do with it: a team edition that pins a scope on the member's group
|
|
1323
|
-
overrides it, and one that pins none follows the workspace.
|
|
1324
|
-
the
|
|
1325
|
-
machine-wide
|
|
1326
|
-
|
|
1333
|
+
overrides it, and one that pins none follows the workspace. The roles the extensions
|
|
1334
|
+
register carry the header directly. The **main** model's handle comes from `models.yml`,
|
|
1335
|
+
which is machine-wide and resolved before extensions load, so `connect` writes that
|
|
1336
|
+
entry's header value as the *name* of an environment variable,
|
|
1337
|
+
`AUTO_MODEL_ROUTER_SCOPE`; omp resolves it from the environment on every request, and
|
|
1338
|
+
the embed extension sets it from the workspace as it loads. If the variable is unset omp
|
|
1339
|
+
sends the name itself, which the router does not accept as a scope (a scope is a
|
|
1340
|
+
lowercase slug) and falls back to its default. `--scope <slug>` pins one project for the
|
|
1341
|
+
whole machine instead; a refresh keeps a pin, and `connect --scope ""` removes it.
|
|
1327
1342
|
|
|
1328
1343
|
## Multiple coding harnesses, one router
|
|
1329
1344
|
|
|
@@ -1461,6 +1476,17 @@ move while the loop runs. So the router buffers the assistant's narration across
|
|
|
1461
1476
|
the loop and writes it once, together with the closing synthesis, when the
|
|
1462
1477
|
assistant actually yields back to the user.
|
|
1463
1478
|
|
|
1479
|
+
### Utility calls get neither the block nor a transcript
|
|
1480
|
+
|
|
1481
|
+
A harness drives more than the agent's conversation through this provider: omp
|
|
1482
|
+
asks for a session title and a complexity rating with `model: auto`. Those
|
|
1483
|
+
calls answer *about* the conversation, carry no tool schemas, and gain nothing
|
|
1484
|
+
from the project block, yet each one paid the whole block — measured at ~6k
|
|
1485
|
+
prompt tokens per call, 42k across one turn's seven side calls. So the tool
|
|
1486
|
+
array is the discriminator for both directions: a request with no tools is
|
|
1487
|
+
neither recorded nor injected. `context.injectWithoutTools: true` restores
|
|
1488
|
+
injection into tool-less requests for a deliberately tool-less agent.
|
|
1489
|
+
|
|
1464
1490
|
Write-backs are queued, bounded, and never awaited: agentdox is an enrichment,
|
|
1465
1491
|
not a dependency. If it is unreachable the turn routes and dispatches normally,
|
|
1466
1492
|
and a pinned block keeps being served.
|
package/docs/AGENTDOX-BRIDGE.md
CHANGED
|
@@ -169,6 +169,16 @@ scope now correct the bridge degrades to **inert** for other projects. Correct a
|
|
|
169
169
|
means the bridge only helps projects the configured token actually grants. A multi-scope token
|
|
170
170
|
would fix that, at the cost of one credential reaching every project.
|
|
171
171
|
|
|
172
|
+
## 6b. FIXED — the same discriminator gates injection (0.10)
|
|
173
|
+
|
|
174
|
+
Recording skipped tool-less calls from 0.2.11, but injection did not: every scoped
|
|
175
|
+
request received the block, so omp's title and rating calls each carried ~6k tokens of
|
|
176
|
+
project context they could not use. Measured 2026-09-08 on one omp turn: seven side
|
|
177
|
+
calls, 23,412 chars each, 42,417 prompt tokens of context in total. `turn.ts` now
|
|
178
|
+
resolves the block only when `req.tools.length > 0`, the same test recording uses, and
|
|
179
|
+
leaves the conversation's pin untouched otherwise. `context.injectWithoutTools` (default
|
|
180
|
+
off) is the escape hatch for an agent that deliberately ships no tools.
|
|
181
|
+
|
|
172
182
|
## 7. Also worth doing
|
|
173
183
|
|
|
174
184
|
- **Context pollution from test turns.** `context_assemble` includes recent session messages,
|
|
@@ -22,8 +22,16 @@ export interface RemoteRouter {
|
|
|
22
22
|
userId: string;
|
|
23
23
|
name: string;
|
|
24
24
|
joinedAtMs: number;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Present only in a remote.json written before the credential store existed:
|
|
27
|
+
* the token inline. New files name the store instead (`refreshTokenStore`) and
|
|
28
|
+
* the token is read from it when a refresh happens (src/cli/refresh.ts).
|
|
29
|
+
*/
|
|
26
30
|
refreshToken?: string;
|
|
31
|
+
/** Which OS store holds the refresh token: dpapi (Windows), keychain (macOS), secret-service (Linux) or file. */
|
|
32
|
+
refreshTokenStore?: "dpapi" | "keychain" | "secret-service" | "file";
|
|
33
|
+
/** The account the store files it under (`<userId>@<remote host>`). */
|
|
34
|
+
refreshAccount?: string;
|
|
27
35
|
keyExpiresAtMs?: number;
|
|
28
36
|
refreshExpiresAtMs?: number;
|
|
29
37
|
/** What the remote calls this machine. */
|
|
@@ -46,6 +54,8 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
|
46
54
|
name: typeof raw.name === "string" ? raw.name : "",
|
|
47
55
|
joinedAtMs: typeof raw.joinedAtMs === "number" ? raw.joinedAtMs : 0,
|
|
48
56
|
...(typeof raw.refreshToken === "string" && raw.refreshToken !== "" ? { refreshToken: raw.refreshToken } : {}),
|
|
57
|
+
...(raw.refreshTokenStore === "dpapi" || raw.refreshTokenStore === "keychain" || raw.refreshTokenStore === "secret-service" || raw.refreshTokenStore === "file" ? { refreshTokenStore: raw.refreshTokenStore } : {}),
|
|
58
|
+
...(typeof raw.refreshAccount === "string" && raw.refreshAccount !== "" ? { refreshAccount: raw.refreshAccount } : {}),
|
|
49
59
|
...(typeof raw.keyExpiresAtMs === "number" ? { keyExpiresAtMs: raw.keyExpiresAtMs } : {}),
|
|
50
60
|
...(typeof raw.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: raw.refreshExpiresAtMs } : {}),
|
|
51
61
|
...(typeof raw.device === "string" && raw.device !== "" ? { device: raw.device } : {}),
|
|
@@ -55,6 +65,22 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
|
55
65
|
}
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
/** True when this machine can trade for a new key: a refresh token inline, or a store that holds one. */
|
|
69
|
+
export function hasRefresh(remote: RemoteRouter): boolean {
|
|
70
|
+
return (remote.refreshToken !== undefined && remote.refreshToken !== "") || remote.refreshTokenStore !== undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The account a remote user's refresh token is filed under in the OS store. */
|
|
74
|
+
export function refreshAccountOf(url: string, userId: string): string {
|
|
75
|
+
let host = url;
|
|
76
|
+
try {
|
|
77
|
+
host = new URL(url).host;
|
|
78
|
+
} catch {
|
|
79
|
+
/* keep the raw url */
|
|
80
|
+
}
|
|
81
|
+
return `${userId === "" ? "member" : userId}@${host}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
export function readRemoteRouter(routerHome: string): RemoteRouter | null {
|
|
59
85
|
for (const path of [remoteFilePath(routerHome), join(routerHome, LEGACY_FILE)]) {
|
|
60
86
|
if (!existsSync(path)) continue;
|
|
@@ -34,6 +34,19 @@ import type { RouterConfig } from "../src/config/types.ts";
|
|
|
34
34
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
35
35
|
|
|
36
36
|
import { EMBED_DUMMY_API_KEY, EMBED_PROVIDER_ID, buildProviderConfig, deriveAgentdoxScope, embedPortPath, modelsYmlPort, probeEmbed, readEmbedPort, resolveEmbedPort, writeEmbedPort } from "./embed-logic.ts";
|
|
37
|
+
import { SCOPE_ENV } from "../src/context/scope.ts";
|
|
38
|
+
|
|
39
|
+
// The workspace's scope for the MAIN model. omp builds that handle from
|
|
40
|
+
// models.yml before this file loads, so its X-Agentdox-Scope cannot come from
|
|
41
|
+
// the provider we register; instead the managed entry names SCOPE_ENV as the
|
|
42
|
+
// header's value, omp resolves that from the environment on every request,
|
|
43
|
+
// and this module runs inside omp's process — so setting it here reaches
|
|
44
|
+
// every turn of this session, main and side roles alike. A workspace that
|
|
45
|
+
// derives no scope (no folder name) leaves whatever the shell set.
|
|
46
|
+
{
|
|
47
|
+
const workspaceScope = deriveAgentdoxScope(process.cwd());
|
|
48
|
+
if (workspaceScope !== "") process.env[SCOPE_ENV] = workspaceScope;
|
|
49
|
+
}
|
|
37
50
|
|
|
38
51
|
/** omp's models.yml as text, or "" when it does not exist / cannot be read. */
|
|
39
52
|
function readModelsYml(): string {
|
package/package.json
CHANGED
package/src/cli/config-cmd.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* keys survive.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { SCOPE_ENV } from "../context/scope.ts";
|
|
18
19
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
20
|
import { homedir } from "node:os";
|
|
20
21
|
import { dirname, join } from "node:path";
|
|
@@ -59,16 +60,20 @@ export interface SpliceResult {
|
|
|
59
60
|
*/
|
|
60
61
|
/**
|
|
61
62
|
* Headers omp attaches to every request through this provider. Both are
|
|
62
|
-
* optional: absent harness id ⇒ single-harness defaults
|
|
63
|
-
*
|
|
63
|
+
* optional: absent harness id ⇒ single-harness defaults; the agentdox scope
|
|
64
|
+
* names `SCOPE_ENV`, which omp resolves from its environment per request and
|
|
65
|
+
* the embed extension sets from the workspace folder, so the MAIN model's turns
|
|
66
|
+
* carry the repository's own scope rather than one for the whole machine. When
|
|
67
|
+
* the variable is unset the router ignores the literal and falls back to its
|
|
68
|
+
* own `context.defaultScope`.
|
|
64
69
|
*/
|
|
65
70
|
function providerHeaders(cfg: RouterConfig): Record<string, string> {
|
|
66
71
|
const headers: Record<string, string> = {};
|
|
67
72
|
if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") {
|
|
68
73
|
headers["X-Omp-Harness"] = cfg.server.harnessId;
|
|
69
74
|
}
|
|
70
|
-
if (cfg.context.enabled
|
|
71
|
-
headers["X-Agentdox-Scope"] =
|
|
75
|
+
if (cfg.context.enabled) {
|
|
76
|
+
headers["X-Agentdox-Scope"] = SCOPE_ENV;
|
|
72
77
|
}
|
|
73
78
|
return headers;
|
|
74
79
|
}
|
package/src/cli/config-wizard.ts
CHANGED
|
@@ -301,6 +301,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
301
301
|
{ path: "context.sessionLimit", label: "Session items", kind: "number", min: 0 },
|
|
302
302
|
{ path: "context.briefChars", label: "Brief chars", kind: "number", min: 0 },
|
|
303
303
|
{ path: "context.recordTurns", label: "Record turns back", kind: "boolean" },
|
|
304
|
+
{ path: "context.injectWithoutTools", label: "Inject into tool-less calls", kind: "boolean" },
|
|
304
305
|
{ path: "context.maxQueue", label: "Write-back queue", kind: "number", min: 1 },
|
|
305
306
|
],
|
|
306
307
|
},
|
package/src/cli/connect.ts
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* $HERMES_HOME/plugins and .env points them at the remote
|
|
15
15
|
* Codex ~/.codex/config.toml gains the auto-model-router provider
|
|
16
16
|
* Aider ~/.aider.conf.yml gains the base URL, key and model
|
|
17
|
-
* Claude Code
|
|
17
|
+
* Claude Code ~/.claude/settings.json gains the base URL (its `env` block) and
|
|
18
|
+
* `apiKeyHelper` running `auto-model-router token`, so no key
|
|
19
|
+
* sits in its environment or on disk for it
|
|
18
20
|
*
|
|
19
21
|
* Every write is idempotent and announced. `--profile` persists the
|
|
20
22
|
* environment lines (shell rc on POSIX, user environment on Windows).
|
|
@@ -26,7 +28,9 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileS
|
|
|
26
28
|
import { homedir } from "node:os";
|
|
27
29
|
import { dirname, join, resolve } from "node:path";
|
|
28
30
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
31
|
+
import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
32
|
+
import { SCOPE_ENV } from "../context/scope.ts";
|
|
33
|
+
import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
|
|
30
34
|
import { flagString, type CliArgs } from "./args.ts";
|
|
31
35
|
|
|
32
36
|
export interface ConnectOptions {
|
|
@@ -44,10 +48,13 @@ export interface ConnectOptions {
|
|
|
44
48
|
packageDir: string;
|
|
45
49
|
/** Cost figures omp shows for the remote's virtual models, USD per million tokens. */
|
|
46
50
|
blend?: { inputPerMtok: number; outputPerMtok: number };
|
|
47
|
-
/**
|
|
51
|
+
/** Pins `X-Agentdox-Scope` in omp's models.yml entry to one slug, machine-wide. Without it the entry follows the workspace (see renderRemoteModelsYml). Undefined keeps what the managed block already has. */
|
|
48
52
|
agentdoxScope?: string;
|
|
49
53
|
/** Short-lived credential fields from a remote that issues them; absent for a permanent key. */
|
|
50
54
|
refreshToken?: string;
|
|
55
|
+
/** Which store takes the refresh token; picked from the platform when absent. Tests inject a backend. */
|
|
56
|
+
store?: StoreKind;
|
|
57
|
+
storeDeps?: StoreDeps;
|
|
51
58
|
keyExpiresAtMs?: number;
|
|
52
59
|
refreshExpiresAtMs?: number;
|
|
53
60
|
device?: string;
|
|
@@ -135,10 +142,13 @@ const MODELS_YML_END = " # END auto-model-router (remote)";
|
|
|
135
142
|
* neither problem — the URL and the key are stable — and the entry is what makes
|
|
136
143
|
* omp's main model resolvable at startup, before extensions load.
|
|
137
144
|
*
|
|
138
|
-
* `
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
145
|
+
* `X-Agentdox-Scope` on this entry reaches the MAIN model's turns, which the
|
|
146
|
+
* extensions' own registration cannot (they load after the handle is built).
|
|
147
|
+
* The file is machine-wide, so a literal slug here would label every
|
|
148
|
+
* workspace's turns with one project; by default the value is the NAME of
|
|
149
|
+
* `SCOPE_ENV`, which omp resolves from its environment per request, and the
|
|
150
|
+
* embed extension sets that variable from the workspace folder as it loads.
|
|
151
|
+
* `scope` pins a literal slug instead, for a single-project machine.
|
|
142
152
|
*/
|
|
143
153
|
export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string {
|
|
144
154
|
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
@@ -156,7 +166,7 @@ export function renderRemoteModelsYml(url: string, key: string, blend: { inputPe
|
|
|
156
166
|
" api: openai-completions",
|
|
157
167
|
` apiKey: ${key}`,
|
|
158
168
|
];
|
|
159
|
-
|
|
169
|
+
lines.push(" headers:", ` X-Agentdox-Scope: ${scope !== "" ? scope : SCOPE_ENV}`);
|
|
160
170
|
lines.push(" models:");
|
|
161
171
|
for (const m of REMOTE_MODEL_ROWS) {
|
|
162
172
|
lines.push(
|
|
@@ -195,14 +205,15 @@ export function mergeModelsYml(before: string, blockText: string): string {
|
|
|
195
205
|
return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`;
|
|
196
206
|
}
|
|
197
207
|
|
|
198
|
-
/** The `X-Agentdox-Scope` the managed block
|
|
208
|
+
/** The literal `X-Agentdox-Scope` the managed block pins, or "" when it follows the workspace (or has none). */
|
|
199
209
|
export function existingBlockScope(text: string): string {
|
|
200
210
|
const begin = text.indexOf(MODELS_YML_BEGIN);
|
|
201
211
|
if (begin < 0) return "";
|
|
202
212
|
const end = text.indexOf(MODELS_YML_END, begin);
|
|
203
213
|
const block = text.slice(begin, end < 0 ? text.length : end);
|
|
204
214
|
const m = /X-Agentdox-Scope:\s*(\S+)/.exec(block);
|
|
205
|
-
|
|
215
|
+
const value = m?.[1] ?? "";
|
|
216
|
+
return value === SCOPE_ENV ? "" : value;
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
/** True when the file already defines our provider outside a block we manage. */
|
|
@@ -223,6 +234,16 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
223
234
|
const rh = routerHomeOf(o);
|
|
224
235
|
report.remoteFile = remoteFilePath(rh);
|
|
225
236
|
const previous = existsSync(report.remoteFile) ? (JSON.parse(readFileSync(report.remoteFile, "utf8")) as Record<string, unknown>) : {};
|
|
237
|
+
// The refresh token is the long-lived secret: it goes to the OS credential store, and
|
|
238
|
+
// remote.json only says which one. The access key stays in the file: it is short-lived,
|
|
239
|
+
// and the extensions need it without a subprocess on every poll.
|
|
240
|
+
let refreshTokenStore: StoreKind | undefined;
|
|
241
|
+
const refreshAccount = refreshAccountOf(o.url, o.userId);
|
|
242
|
+
if (o.refreshToken !== undefined && o.refreshToken !== "" && !o.dryRun) {
|
|
243
|
+
const wanted = o.store ?? pickStore(o.platform, o.pathHas);
|
|
244
|
+
refreshTokenStore = saveRefreshToken(rh, refreshAccount, o.refreshToken, wanted, o.storeDeps ?? { pathHas: o.pathHas });
|
|
245
|
+
if (refreshTokenStore !== wanted) report.notes.push(`the ${wanted} credential store was not usable; the refresh token is in ${join(rh, "refresh.token")} (owner-readable only)`);
|
|
246
|
+
} else if (o.refreshToken !== undefined && o.refreshToken !== "") refreshTokenStore = o.store ?? pickStore(o.platform, o.pathHas);
|
|
226
247
|
write(
|
|
227
248
|
report.remoteFile,
|
|
228
249
|
`${JSON.stringify(
|
|
@@ -232,7 +253,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
232
253
|
userId: o.userId,
|
|
233
254
|
name: o.name,
|
|
234
255
|
joinedAtMs: typeof previous.joinedAtMs === "number" ? previous.joinedAtMs : Date.now(),
|
|
235
|
-
...(
|
|
256
|
+
...(refreshTokenStore !== undefined ? { refreshTokenStore, refreshAccount } : {}),
|
|
236
257
|
...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
|
|
237
258
|
...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
|
|
238
259
|
...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
|
|
@@ -300,11 +321,33 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
300
321
|
report.configured.push(`Aider (${aiderConf})`);
|
|
301
322
|
} else report.skipped.push("Aider (not found)");
|
|
302
323
|
|
|
303
|
-
// 6. Claude Code:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
324
|
+
// 6. Claude Code: its settings file carries the base URL (the `env` block) and a key
|
|
325
|
+
// helper, a command it runs for the key — so the key is never in its environment or
|
|
326
|
+
// on disk for it. Without a refresh token the helper still works (it prints the key it
|
|
327
|
+
// holds); the helper is what lets a short-lived key rotate underneath a running session.
|
|
328
|
+
const claudeDir = join(o.home, ".claude");
|
|
329
|
+
if (wants(o, "claude") && (o.pathHas("claude") || existsSync(claudeDir))) {
|
|
330
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
331
|
+
let settings: Record<string, unknown> = {};
|
|
332
|
+
const before = existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : "";
|
|
333
|
+
try {
|
|
334
|
+
settings = before === "" ? {} : (JSON.parse(before) as Record<string, unknown>);
|
|
335
|
+
} catch {
|
|
336
|
+
report.notes.push(`${settingsPath} is not valid JSON; left alone — set env.ANTHROPIC_BASE_URL and apiKeyHelper by hand`);
|
|
337
|
+
settings = {};
|
|
338
|
+
}
|
|
339
|
+
const env: Record<string, unknown> = { ...((settings.env as Record<string, unknown> | undefined) ?? {}), ANTHROPIC_BASE_URL: o.url };
|
|
340
|
+
// Never leave a stale key beside the helper: the helper is the source now.
|
|
341
|
+
delete env.ANTHROPIC_API_KEY;
|
|
342
|
+
const entry = resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/");
|
|
343
|
+
const next = { ...settings, env, apiKeyHelper: `bun run "${entry}" token` };
|
|
344
|
+
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
345
|
+
if (after !== before) {
|
|
346
|
+
if (before !== "" && !o.dryRun) writeFileSync(`${settingsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, before, "utf8");
|
|
347
|
+
write(settingsPath, after);
|
|
348
|
+
}
|
|
349
|
+
report.configured.push(`Claude Code (${settingsPath}: env.ANTHROPIC_BASE_URL + apiKeyHelper; open a new session)`);
|
|
350
|
+
} else report.skipped.push("Claude Code (not on PATH and no ~/.claude)");
|
|
308
351
|
report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
|
|
309
352
|
report.envLines = [...new Set(report.envLines)];
|
|
310
353
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the refresh token lives on a member's machine.
|
|
3
|
+
*
|
|
4
|
+
* The access key has to sit in harness config files (a harness needs a literal
|
|
5
|
+
* bearer), and it is short-lived. The refresh token is the long-lived secret,
|
|
6
|
+
* so it goes to the operating system's credential store instead of a file:
|
|
7
|
+
*
|
|
8
|
+
* Windows DPAPI (CurrentUser scope): the token is encrypted so that only
|
|
9
|
+
* this Windows user on this machine can decrypt it, and the
|
|
10
|
+
* ciphertext is kept in `<router home>/refresh.dpapi`. Built in;
|
|
11
|
+
* no module to install. The plaintext passes to PowerShell through
|
|
12
|
+
* an environment variable, never an argument.
|
|
13
|
+
* macOS the login keychain, through `security` (service
|
|
14
|
+
* `auto-model-router`, one account per remote user).
|
|
15
|
+
* Linux the Secret Service through `secret-tool` when it is installed.
|
|
16
|
+
* file `<router home>/refresh.token`, owner-readable only — the fallback
|
|
17
|
+
* when none of the above works, and the choice on CI boxes.
|
|
18
|
+
*
|
|
19
|
+
* `remote.json` records which store holds it (`refreshTokenStore`) and the
|
|
20
|
+
* account name; it never holds the token itself once a store other than
|
|
21
|
+
* `file` is in use. A remote.json written before this existed may still carry
|
|
22
|
+
* the token inline; reading honours that until the next refresh moves it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export type StoreKind = "dpapi" | "keychain" | "secret-service" | "file";
|
|
30
|
+
|
|
31
|
+
const SERVICE = "auto-model-router";
|
|
32
|
+
|
|
33
|
+
/** The store this platform offers, given which tools are on PATH. */
|
|
34
|
+
export function pickStore(platform: string, pathHas: (bin: string) => boolean): StoreKind {
|
|
35
|
+
if (platform === "win32") return pathHas("powershell") || pathHas("pwsh") ? "dpapi" : "file";
|
|
36
|
+
if (platform === "darwin") return pathHas("security") ? "keychain" : "file";
|
|
37
|
+
if (platform === "linux") return pathHas("secret-tool") ? "secret-service" : "file";
|
|
38
|
+
return "file";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const powershell = (pathHas: (bin: string) => boolean): string => (pathHas("pwsh") ? "pwsh" : "powershell");
|
|
42
|
+
|
|
43
|
+
function dpapiProtect(secret: string, pathHas: (bin: string) => boolean): string {
|
|
44
|
+
const r = spawnSync(
|
|
45
|
+
powershell(pathHas),
|
|
46
|
+
["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Security; [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($env:AMR_SECRET), $null, 'CurrentUser'))"],
|
|
47
|
+
{ encoding: "utf8", env: { ...process.env, AMR_SECRET: secret } },
|
|
48
|
+
);
|
|
49
|
+
if (r.status !== 0 || r.stdout.trim() === "") throw new Error(`DPAPI protect failed: ${r.stderr.trim() || r.status}`);
|
|
50
|
+
return r.stdout.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dpapiUnprotect(blob: string, pathHas: (bin: string) => boolean): string {
|
|
54
|
+
const r = spawnSync(
|
|
55
|
+
powershell(pathHas),
|
|
56
|
+
["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Security; [Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($env:AMR_BLOB), $null, 'CurrentUser'))"],
|
|
57
|
+
{ encoding: "utf8", env: { ...process.env, AMR_BLOB: blob } },
|
|
58
|
+
);
|
|
59
|
+
if (r.status !== 0) throw new Error(`DPAPI unprotect failed: ${r.stderr.trim() || r.status}`);
|
|
60
|
+
return r.stdout.replace(/\r?\n$/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const filePath = (routerHome: string): string => join(routerHome, "refresh.token");
|
|
64
|
+
const dpapiPath = (routerHome: string): string => join(routerHome, "refresh.dpapi");
|
|
65
|
+
|
|
66
|
+
export interface StoreDeps {
|
|
67
|
+
pathHas?: (bin: string) => boolean;
|
|
68
|
+
/** Injected in tests to stand in for the platform tools. */
|
|
69
|
+
backend?: { save(account: string, secret: string): void; load(account: string): string | null; remove(account: string): void };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Saves the refresh token and returns the store that took it. A store that
|
|
74
|
+
* fails (keychain locked, tool missing) falls back to the file, so a member is
|
|
75
|
+
* never left without a refresh token; the caller records what was used.
|
|
76
|
+
*/
|
|
77
|
+
export function saveRefreshToken(routerHome: string, account: string, secret: string, kind: StoreKind, deps: StoreDeps = {}): StoreKind {
|
|
78
|
+
const pathHas = deps.pathHas ?? ((bin) => Bun.which(bin) !== null);
|
|
79
|
+
mkdirSync(routerHome, { recursive: true });
|
|
80
|
+
try {
|
|
81
|
+
if (deps.backend !== undefined) {
|
|
82
|
+
deps.backend.save(account, secret);
|
|
83
|
+
return kind;
|
|
84
|
+
}
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case "dpapi":
|
|
87
|
+
writeFileSync(dpapiPath(routerHome), `${dpapiProtect(secret, pathHas)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
88
|
+
rmSync(filePath(routerHome), { force: true });
|
|
89
|
+
return "dpapi";
|
|
90
|
+
case "keychain": {
|
|
91
|
+
const r = spawnSync("security", ["add-generic-password", "-U", "-a", account, "-s", SERVICE, "-w", secret], { encoding: "utf8" });
|
|
92
|
+
if (r.status !== 0) throw new Error(r.stderr.trim());
|
|
93
|
+
rmSync(filePath(routerHome), { force: true });
|
|
94
|
+
return "keychain";
|
|
95
|
+
}
|
|
96
|
+
case "secret-service": {
|
|
97
|
+
const r = spawnSync("secret-tool", ["store", `--label=${SERVICE} ${account}`, "service", SERVICE, "account", account], { encoding: "utf8", input: secret });
|
|
98
|
+
if (r.status !== 0) throw new Error(r.stderr.trim());
|
|
99
|
+
rmSync(filePath(routerHome), { force: true });
|
|
100
|
+
return "secret-service";
|
|
101
|
+
}
|
|
102
|
+
case "file":
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// fall through to the file
|
|
107
|
+
}
|
|
108
|
+
writeFileSync(filePath(routerHome), `${secret}\n`, { encoding: "utf8", mode: 0o600 });
|
|
109
|
+
try {
|
|
110
|
+
chmodSync(filePath(routerHome), 0o600);
|
|
111
|
+
} catch {
|
|
112
|
+
/* Windows */
|
|
113
|
+
}
|
|
114
|
+
return "file";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The refresh token from the store `remote.json` names, or null when it is gone. */
|
|
118
|
+
export function loadRefreshToken(routerHome: string, account: string, kind: StoreKind, deps: StoreDeps = {}): string | null {
|
|
119
|
+
const pathHas = deps.pathHas ?? ((bin) => Bun.which(bin) !== null);
|
|
120
|
+
try {
|
|
121
|
+
if (deps.backend !== undefined) return deps.backend.load(account);
|
|
122
|
+
switch (kind) {
|
|
123
|
+
case "dpapi": {
|
|
124
|
+
const p = dpapiPath(routerHome);
|
|
125
|
+
if (!existsSync(p)) return null;
|
|
126
|
+
return dpapiUnprotect(readFileSync(p, "utf8").trim(), pathHas);
|
|
127
|
+
}
|
|
128
|
+
case "keychain": {
|
|
129
|
+
const r = spawnSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w"], { encoding: "utf8" });
|
|
130
|
+
return r.status === 0 ? r.stdout.replace(/\r?\n$/, "") : null;
|
|
131
|
+
}
|
|
132
|
+
case "secret-service": {
|
|
133
|
+
const r = spawnSync("secret-tool", ["lookup", "service", SERVICE, "account", account], { encoding: "utf8" });
|
|
134
|
+
return r.status === 0 && r.stdout !== "" ? r.stdout.replace(/\r?\n$/, "") : null;
|
|
135
|
+
}
|
|
136
|
+
case "file": {
|
|
137
|
+
const p = filePath(routerHome);
|
|
138
|
+
return existsSync(p) ? readFileSync(p, "utf8").trim() : null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Forgets the token everywhere it might be. */
|
|
148
|
+
export function removeRefreshToken(routerHome: string, account: string, deps: StoreDeps = {}): void {
|
|
149
|
+
rmSync(filePath(routerHome), { force: true });
|
|
150
|
+
rmSync(dpapiPath(routerHome), { force: true });
|
|
151
|
+
if (deps.backend !== undefined) {
|
|
152
|
+
deps.backend.remove(account);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (process.platform === "darwin") spawnSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE], { encoding: "utf8" });
|
|
156
|
+
if (process.platform === "linux") spawnSync("secret-tool", ["clear", "service", SERVICE, "account", account], { encoding: "utf8" });
|
|
157
|
+
}
|
package/src/cli/refresh.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { dirname, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { readRemoteRouter, type RemoteRouter } from "../../omp-extension/remote-logic.ts";
|
|
19
|
+
import { hasRefresh, readRemoteRouter, refreshAccountOf, type RemoteRouter } from "../../omp-extension/remote-logic.ts";
|
|
20
|
+
import { loadRefreshToken, type StoreDeps } from "./credential-store.ts";
|
|
20
21
|
import { routerHome } from "../../omp-extension/router-url.ts";
|
|
21
22
|
import type { CliArgs } from "./args.ts";
|
|
22
23
|
import { connectRemote } from "./connect.ts";
|
|
@@ -34,7 +35,7 @@ export interface RefreshedCredential {
|
|
|
34
35
|
|
|
35
36
|
/** True when the credential can and should be traded now: it has a refresh token and its key is near or past expiry. */
|
|
36
37
|
export function shouldRefresh(remote: RemoteRouter, nowMs = Date.now()): boolean {
|
|
37
|
-
if (remote
|
|
38
|
+
if (!hasRefresh(remote)) return false;
|
|
38
39
|
if (remote.keyExpiresAtMs === undefined) return false;
|
|
39
40
|
return remote.keyExpiresAtMs - nowMs <= REFRESH_AHEAD_MS;
|
|
40
41
|
}
|
|
@@ -49,13 +50,21 @@ export class RefreshError extends Error {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** The refresh token: inline from an older remote.json, else from the OS store remote.json names. */
|
|
54
|
+
export function resolveRefreshToken(remote: RemoteRouter, routerHome: string, storeDeps: StoreDeps = {}): string | null {
|
|
55
|
+
if (remote.refreshToken !== undefined && remote.refreshToken !== "") return remote.refreshToken;
|
|
56
|
+
if (remote.refreshTokenStore === undefined) return null;
|
|
57
|
+
return loadRefreshToken(routerHome, remote.refreshAccount ?? refreshAccountOf(remote.url, remote.userId), remote.refreshTokenStore, storeDeps);
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
/** 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
|
-
|
|
61
|
+
export async function refreshCredential(remote: RemoteRouter, fetchImpl: typeof fetch = fetch, routerHomeDir: string = routerHome(), storeDeps: StoreDeps = {}): Promise<RefreshedCredential> {
|
|
62
|
+
const token = resolveRefreshToken(remote, routerHomeDir, storeDeps);
|
|
63
|
+
if (token === null || token === "") throw new RefreshError("no_refresh_token", "this machine holds no refresh token (or its credential store no longer has it); onboard it again with a setup token");
|
|
55
64
|
const res = await fetchImpl(`${remote.url}/auth/refresh`, {
|
|
56
65
|
method: "POST",
|
|
57
66
|
headers: { "content-type": "application/json" },
|
|
58
|
-
body: JSON.stringify({ refreshToken:
|
|
67
|
+
body: JSON.stringify({ refreshToken: token }),
|
|
59
68
|
signal: AbortSignal.timeout(15_000),
|
|
60
69
|
});
|
|
61
70
|
const body = (await res.json().catch(() => null)) as { key?: unknown; keyExpiresAtMs?: unknown; refreshToken?: unknown; refreshExpiresAtMs?: unknown; device?: unknown; error?: { code?: string; message?: string } } | null;
|
|
@@ -76,8 +85,9 @@ export async function refreshCredential(remote: RemoteRouter, fetchImpl: typeof
|
|
|
76
85
|
* harness configs `connect` manages. Returns the fresh credential. `home` and
|
|
77
86
|
* `packageDir` are injectable for tests.
|
|
78
87
|
*/
|
|
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
|
|
88
|
+
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; routerHome?: string; storeDeps?: StoreDeps }): Promise<RefreshedCredential> {
|
|
89
|
+
const rh = opts.routerHome ?? routerHome();
|
|
90
|
+
const fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch, rh, opts.storeDeps ?? {});
|
|
81
91
|
const home = opts.home ?? (process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir());
|
|
82
92
|
const packageDir = opts.packageDir ?? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
83
93
|
connectRemote({
|
|
@@ -97,6 +107,9 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
97
107
|
packageDir,
|
|
98
108
|
platform: opts.platform ?? process.platform,
|
|
99
109
|
pathHas: opts.pathHas ?? ((bin) => Bun.which(bin) !== null),
|
|
110
|
+
// The store that already holds it keeps it; a machine never silently changes store.
|
|
111
|
+
...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
|
|
112
|
+
...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
|
|
100
113
|
// undefined keeps whatever scope the managed models.yml block already carries.
|
|
101
114
|
});
|
|
102
115
|
return fresh;
|
package/src/config/defaults.ts
CHANGED
|
@@ -263,6 +263,14 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
263
263
|
// the query-relevant memory/docs tail. 0 omits the brief.
|
|
264
264
|
briefChars: 12_000,
|
|
265
265
|
recordTurns: true,
|
|
266
|
+
// A request with no tool schemas is a harness utility call — omp asks for
|
|
267
|
+
// a session title or a complexity rating through the same provider — and
|
|
268
|
+
// it answers ABOUT the conversation, so the project block cannot help it.
|
|
269
|
+
// Recording already skips those calls (turn.ts); injection did not, and
|
|
270
|
+
// one omp turn measured seven side calls at ~6k tokens of context each,
|
|
271
|
+
// 42k prompt tokens for nothing. Off: the block goes only to turns that
|
|
272
|
+
// ship tools. On: every scoped turn gets it, as before 0.10.
|
|
273
|
+
injectWithoutTools: false,
|
|
266
274
|
maxQueue: 64,
|
|
267
275
|
},
|
|
268
276
|
compaction: {
|
package/src/config/schema.ts
CHANGED
|
@@ -192,6 +192,7 @@ const context = z.strictObject({
|
|
|
192
192
|
sessionLimit: z.number().int().nonnegative().optional(),
|
|
193
193
|
briefChars: z.number().int().nonnegative().optional(),
|
|
194
194
|
recordTurns: z.boolean().optional(),
|
|
195
|
+
injectWithoutTools: z.boolean().optional(),
|
|
195
196
|
maxQueue: z.number().int().positive().optional(),
|
|
196
197
|
});
|
|
197
198
|
|
package/src/config/types.ts
CHANGED
|
@@ -769,6 +769,13 @@ export interface ContextConfig {
|
|
|
769
769
|
briefChars: number;
|
|
770
770
|
/** Write settled turns back to agentdox sessions, tagged with the served model. */
|
|
771
771
|
recordTurns: boolean;
|
|
772
|
+
/**
|
|
773
|
+
* Inject the block into turns that carry NO tool schemas too. Those are
|
|
774
|
+
* harness utility calls (titles, ratings), which recording already skips;
|
|
775
|
+
* default off, because each one paid the whole block for an answer that is
|
|
776
|
+
* about the conversation, not part of it.
|
|
777
|
+
*/
|
|
778
|
+
injectWithoutTools: boolean;
|
|
772
779
|
/** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */
|
|
773
780
|
maxQueue: number;
|
|
774
781
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agentdox scope a request names, and where omp's main model gets it from.
|
|
3
|
+
*
|
|
4
|
+
* omp resolves its MAIN model from `models.yml` at startup, before any extension
|
|
5
|
+
* loads, so the `X-Agentdox-Scope` header on that provider entry cannot be set
|
|
6
|
+
* by the extension per workspace — but omp resolves a header VALUE that names
|
|
7
|
+
* an environment variable from the environment on every request, and the
|
|
8
|
+
* extension runs inside omp's process. So `connect` (and the local sync) write
|
|
9
|
+
* the header's value as the NAME below, and the extension sets that variable
|
|
10
|
+
* from the workspace folder when it loads. One machine-wide file, one scope
|
|
11
|
+
* per repository.
|
|
12
|
+
*/
|
|
13
|
+
export const SCOPE_ENV = "AUTO_MODEL_ROUTER_SCOPE";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A scope is an agentdox project slug: lowercase, starting alphanumeric. The
|
|
17
|
+
* shape matters because omp sends the header's literal value when the variable
|
|
18
|
+
* it names is unset — `AUTO_MODEL_ROUTER_SCOPE` itself — and that must never
|
|
19
|
+
* become a project. Uppercase never passes, so the sentinel cannot.
|
|
20
|
+
*/
|
|
21
|
+
export function isScopeSlug(value: string): boolean {
|
|
22
|
+
return /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The scope to trust from a request header: a slug, or "" for anything else. */
|
|
26
|
+
export function acceptScope(raw: string | null | undefined): string {
|
|
27
|
+
const s = (raw ?? "").trim();
|
|
28
|
+
return isScopeSlug(s) ? s : "";
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ Usage: auto-model-router <command> [options]
|
|
|
27
27
|
stats Show routed spend, per-model share, and escalation rates
|
|
28
28
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
29
29
|
export One row per day, harness and model as CSV (--json for rows)
|
|
30
|
-
connect Point this machine at a remote router (--url, --key[, --refresh-token]; --scope
|
|
30
|
+
connect Point this machine at a remote router (--url, --key[, --refresh-token]; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment)
|
|
31
31
|
refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
|
|
32
32
|
token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
|
|
33
33
|
models Show what each complexity tier would consider, and why
|
package/src/server/turn.ts
CHANGED
|
@@ -136,6 +136,12 @@ export async function runTurn(
|
|
|
136
136
|
// Request header wins; the configured default covers harnesses that send none.
|
|
137
137
|
const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
|
|
138
138
|
const doxActive = bridge.enabled && doxScope !== "";
|
|
139
|
+
// Injection shares recording's discriminator (explained at the record call
|
|
140
|
+
// below): a tool-less harness utility call answers ABOUT the conversation
|
|
141
|
+
// and gains nothing from the project block, yet paid its full ~6k tokens on
|
|
142
|
+
// every title and rating — 42k prompt tokens across one omp turn's seven
|
|
143
|
+
// side calls. `context.injectWithoutTools` restores the old behaviour.
|
|
144
|
+
const doxInject = doxActive && (req.tools.length > 0 || config.context.injectWithoutTools);
|
|
139
145
|
|
|
140
146
|
// The trigger list is the source of truth for enabled signals, except
|
|
141
147
|
// length_stop, which rides on its own toggle (escalation.escalateOnLengthStop).
|
|
@@ -209,7 +215,7 @@ export async function runTurn(
|
|
|
209
215
|
// turn's prefix is already cold — a model switch or a retry — so the
|
|
210
216
|
// injected bytes stay identical while the cache is worth keeping.
|
|
211
217
|
let contextBlock: string | undefined;
|
|
212
|
-
if (
|
|
218
|
+
if (doxInject) {
|
|
213
219
|
const pin = await bridge.resolve({
|
|
214
220
|
scope: doxScope,
|
|
215
221
|
conversationKey: req.conversationKey,
|
|
@@ -233,6 +239,7 @@ export async function runTurn(
|
|
|
233
239
|
log.debug("agentdox context", {
|
|
234
240
|
active: doxActive,
|
|
235
241
|
scope: doxScope === "" ? "(none)" : doxScope,
|
|
242
|
+
utilityCall: doxActive && !doxInject,
|
|
236
243
|
injected: contextBlock !== undefined,
|
|
237
244
|
chars: contextBlock?.length ?? 0,
|
|
238
245
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { acceptScope } from "../../context/scope.ts";
|
|
1
2
|
import type {
|
|
2
3
|
RequestPolicy,
|
|
3
4
|
CompactionEdit,
|
|
@@ -329,8 +330,10 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
329
330
|
const ompSessionId = (headers.get("x-omp-session") ?? "").trim();
|
|
330
331
|
|
|
331
332
|
// agentdox project scope. Selects whose shared context is injected; absent
|
|
332
|
-
//
|
|
333
|
-
|
|
333
|
+
// or not a slug (omp sends the literal env-var NAME when the variable that
|
|
334
|
+
// models.yml names is unset — see src/context/scope.ts) ⇒ the server falls
|
|
335
|
+
// back to its configured default scope.
|
|
336
|
+
const agentdoxScope = acceptScope(headers.get("x-agentdox-scope"));
|
|
334
337
|
|
|
335
338
|
// Subagent marker from the embed extension (sessions without a UI).
|
|
336
339
|
const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
|
package/test/failover.test.ts
CHANGED
|
@@ -73,7 +73,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
73
73
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
|
|
74
74
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
75
75
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
76
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
|
|
76
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
|
|
77
77
|
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
79
|
report: { baselines: [], dailySummary: false },
|
package/test/remote.test.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
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
8
|
import { existingBlockScope, hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { SCOPE_ENV } from "../src/context/scope.ts";
|
|
10
|
+
import { refreshAndRewrite, refreshCredential, RefreshError, resolveRefreshToken, shouldRefresh } from "../src/cli/refresh.ts";
|
|
11
|
+
import { loadRefreshToken, pickStore, removeRefreshToken, saveRefreshToken } from "../src/cli/credential-store.ts";
|
|
12
|
+
import { hasRefresh, refreshAccountOf } from "../omp-extension/remote-logic.ts";
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Remote mode: remote.json puts the omp extensions on a router elsewhere,
|
|
@@ -78,7 +81,8 @@ describe("connect", () => {
|
|
|
78
81
|
expect(readFileSync(join(home, ".codex", "config.toml"), "utf8")).toContain("[model_providers.auto-model-router]");
|
|
79
82
|
expect(readFileSync(join(home, ".aider.conf.yml"), "utf8")).toContain("openai-api-base: https://team.example/v1");
|
|
80
83
|
expect(r1.configured.join("\n")).toMatch(/omp[\s\S]*Hermes[\s\S]*Codex[\s\S]*Aider[\s\S]*Claude Code/);
|
|
81
|
-
|
|
84
|
+
// Claude Code is configured through its settings file now, so nothing ANTHROPIC_* rides in the environment.
|
|
85
|
+
expect(r1.envLines).toEqual(["AUTO_MODEL_ROUTER_URL=https://team.example", "AUTO_MODEL_ROUTER_API_KEY=amrt_key"]);
|
|
82
86
|
// Running again changes nothing.
|
|
83
87
|
const snapshot = [ompCfg, readFileSync(join(home, ".codex", "config.toml"), "utf8"), readFileSync(join(home, ".aider.conf.yml"), "utf8")];
|
|
84
88
|
connectRemote(o);
|
|
@@ -99,7 +103,8 @@ describe("connect", () => {
|
|
|
99
103
|
connectRemote(o2);
|
|
100
104
|
const rc = readFileSync(join(h2, ".zshrc"), "utf8");
|
|
101
105
|
expect(rc.split("# auto-model-router remote").length).toBe(2);
|
|
102
|
-
expect(rc).toContain("export
|
|
106
|
+
expect(rc).toContain("export AUTO_MODEL_ROUTER_URL=https://team.example");
|
|
107
|
+
expect(rc).not.toContain("ANTHROPIC_");
|
|
103
108
|
rmSync(home, { recursive: true, force: true });
|
|
104
109
|
rmSync(h2, { recursive: true, force: true });
|
|
105
110
|
});
|
|
@@ -110,7 +115,7 @@ describe("omp models.yml for a remote router", () => {
|
|
|
110
115
|
const NL = String.fromCharCode(10);
|
|
111
116
|
const yaml = (...lines: string[]): string => lines.join(NL) + NL;
|
|
112
117
|
|
|
113
|
-
test("the block names the remote, the key and the three virtual models;
|
|
118
|
+
test("the block names the remote, the key and the three virtual models; the scope follows the workspace unless pinned", () => {
|
|
114
119
|
const block = renderRemoteModelsYml("https://team.example/", "amrt_k", BLEND);
|
|
115
120
|
expect(block).toContain("baseUrl: https://team.example/v1");
|
|
116
121
|
expect(block).toContain("apiKey: amrt_k");
|
|
@@ -118,9 +123,15 @@ describe("omp models.yml for a remote router", () => {
|
|
|
118
123
|
expect(block).toContain("- id: auto-cheap");
|
|
119
124
|
expect(block).toContain("- id: auto-max");
|
|
120
125
|
expect(block).toContain("cost: { input: 1.1, output: 4.4, cacheRead: 0.11, cacheWrite: 1.375 }");
|
|
121
|
-
// Machine-wide file:
|
|
122
|
-
|
|
123
|
-
expect(
|
|
126
|
+
// Machine-wide file: the header names the env var the extension sets per
|
|
127
|
+
// workspace, so the MAIN model's turns carry each repo's own scope.
|
|
128
|
+
expect(block).toContain(`X-Agentdox-Scope: ${SCOPE_ENV}`);
|
|
129
|
+
expect(existingBlockScope(mergeModelsYml("", block))).toBe("");
|
|
130
|
+
// --scope pins one slug for the whole machine.
|
|
131
|
+
const pinned = renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router");
|
|
132
|
+
expect(pinned).toContain("X-Agentdox-Scope: omp-router");
|
|
133
|
+
expect(pinned).not.toContain(SCOPE_ENV);
|
|
134
|
+
expect(existingBlockScope(mergeModelsYml("", pinned))).toBe("omp-router");
|
|
124
135
|
});
|
|
125
136
|
|
|
126
137
|
test("merging keeps other providers, replaces our own block, and is idempotent", () => {
|
|
@@ -203,16 +214,21 @@ describe("short-lived remote credentials", () => {
|
|
|
203
214
|
const { connectRemote } = await import("../src/cli/connect.ts");
|
|
204
215
|
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
216
|
const before = JSON.parse(readFileSync(join(routerHome, "remote.json"), "utf8")) as Record<string, unknown>;
|
|
206
|
-
|
|
217
|
+
// The refresh token is in the store (the file here: no platform tool on PATH); remote.json only names it.
|
|
218
|
+
expect(before).toMatchObject({ key: "amrt_old", keyExpiresAtMs: 1, device: "laptop", refreshTokenStore: "file", refreshAccount: "u_ada@team.example" });
|
|
219
|
+
expect(before.refreshToken).toBeUndefined();
|
|
220
|
+
expect(readFileSync(join(routerHome, "refresh.token"), "utf8").trim()).toBe("amrr_r1");
|
|
207
221
|
const models0 = readFileSync(join(agent, "models.yml"), "utf8");
|
|
208
222
|
expect(models0).toContain("apiKey: amrt_old");
|
|
209
223
|
expect(existingBlockScope(models0)).toBe("omp-router");
|
|
210
224
|
// Then a refresh, which knows nothing about the scope.
|
|
211
225
|
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 });
|
|
226
|
+
const fresh = await refreshAndRewrite({ remote: parseRemoteRouter(readFileSync(join(routerHome, "remote.json"), "utf8"))!, fetchImpl, home, packageDir: process.cwd(), env, platform: "linux", pathHas: () => false, routerHome });
|
|
213
227
|
expect(fresh.key).toBe("amrt_new");
|
|
214
228
|
const after = JSON.parse(readFileSync(join(routerHome, "remote.json"), "utf8")) as Record<string, unknown>;
|
|
215
|
-
expect(after).toMatchObject({ key: "amrt_new",
|
|
229
|
+
expect(after).toMatchObject({ key: "amrt_new", keyExpiresAtMs: 50, refreshExpiresAtMs: 90, device: "laptop", joinedAtMs: before.joinedAtMs, refreshTokenStore: "file" });
|
|
230
|
+
expect(after.refreshToken).toBeUndefined();
|
|
231
|
+
expect(readFileSync(join(routerHome, "refresh.token"), "utf8").trim()).toBe("amrr_r2");
|
|
216
232
|
const models1 = readFileSync(join(agent, "models.yml"), "utf8");
|
|
217
233
|
expect(models1).toContain("apiKey: amrt_new");
|
|
218
234
|
expect(models1).not.toContain("amrt_old");
|
|
@@ -222,3 +238,91 @@ describe("short-lived remote credentials", () => {
|
|
|
222
238
|
}
|
|
223
239
|
});
|
|
224
240
|
});
|
|
241
|
+
|
|
242
|
+
describe("the refresh token lives in the OS credential store", () => {
|
|
243
|
+
const NL = String.fromCharCode(10);
|
|
244
|
+
// A fake backend stands in for DPAPI / the keychain / secret-service.
|
|
245
|
+
const vault = new Map<string, string>();
|
|
246
|
+
const backend = { save: (a: string, s: string) => void vault.set(a, s), load: (a: string) => vault.get(a) ?? null, remove: (a: string) => void vault.delete(a) };
|
|
247
|
+
|
|
248
|
+
test("the store is picked from the platform and its tools; the file is the fallback everywhere", () => {
|
|
249
|
+
expect(pickStore("win32", (b) => b === "powershell")).toBe("dpapi");
|
|
250
|
+
expect(pickStore("win32", () => false)).toBe("file");
|
|
251
|
+
expect(pickStore("darwin", (b) => b === "security")).toBe("keychain");
|
|
252
|
+
expect(pickStore("linux", (b) => b === "secret-tool")).toBe("secret-service");
|
|
253
|
+
expect(pickStore("linux", () => false)).toBe("file");
|
|
254
|
+
expect(refreshAccountOf("https://team.example:8790/", "u_ada")).toBe("u_ada@team.example:8790");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("save/load through a store, and the file fallback keeps the token owner-readable", () => {
|
|
258
|
+
const home = mkdtempSync(join(tmpdir(), "amr-store-"));
|
|
259
|
+
try {
|
|
260
|
+
expect(saveRefreshToken(home, "u@t", "amrr_x", "keychain", { backend })).toBe("keychain");
|
|
261
|
+
expect(loadRefreshToken(home, "u@t", "keychain", { backend })).toBe("amrr_x");
|
|
262
|
+
expect(existsSync(join(home, "refresh.token"))).toBe(false); // nothing on disk
|
|
263
|
+
expect(saveRefreshToken(home, "u@t", "amrr_f", "file")).toBe("file");
|
|
264
|
+
expect(loadRefreshToken(home, "u@t", "file")).toBe("amrr_f");
|
|
265
|
+
removeRefreshToken(home, "u@t", { backend });
|
|
266
|
+
expect(loadRefreshToken(home, "u@t", "keychain", { backend })).toBeNull();
|
|
267
|
+
expect(existsSync(join(home, "refresh.token"))).toBe(false);
|
|
268
|
+
} finally {
|
|
269
|
+
rmSync(home, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("connect files the token in the store and remote.json only names it; refresh reads it back; an older inline token still works", async () => {
|
|
274
|
+
const home = mkdtempSync(join(tmpdir(), "amr-store2-"));
|
|
275
|
+
const agent = join(home, ".omp", "agent");
|
|
276
|
+
mkdirSync(agent, { recursive: true });
|
|
277
|
+
writeFileSync(join(agent, "config.yml"), "extensions: []" + NL);
|
|
278
|
+
const rh = join(home, ".auto-model-router");
|
|
279
|
+
const env = { HOME: home, PI_CODING_AGENT_DIR: agent, AUTO_MODEL_ROUTER_HOME: rh, HERMES_HOME: join(home, "no-hermes") };
|
|
280
|
+
try {
|
|
281
|
+
const { connectRemote } = await import("../src/cli/connect.ts");
|
|
282
|
+
connectRemote({ url: "https://team.example", key: "amrt_k1", userId: "u_ada", name: "Ada", refreshToken: "amrr_r1", keyExpiresAtMs: 1, refreshExpiresAtMs: 2, device: "laptop", store: "keychain", storeDeps: { backend }, profile: false, dryRun: false, only: ["omp"], env, home, packageDir: process.cwd(), platform: "darwin", pathHas: () => false });
|
|
283
|
+
const written = readFileSync(join(rh, "remote.json"), "utf8");
|
|
284
|
+
expect(written).not.toContain("amrr_r1");
|
|
285
|
+
const remote = parseRemoteRouter(written)!;
|
|
286
|
+
expect(remote).toMatchObject({ refreshTokenStore: "keychain", refreshAccount: "u_ada@team.example" });
|
|
287
|
+
expect(remote.refreshToken).toBeUndefined();
|
|
288
|
+
expect(hasRefresh(remote)).toBe(true);
|
|
289
|
+
expect(resolveRefreshToken(remote, rh, { backend })).toBe("amrr_r1");
|
|
290
|
+
// The refresh trades the stored token and files the new one in the same store.
|
|
291
|
+
const seen: string[] = [];
|
|
292
|
+
const fetchImpl = (async (_u: string | URL | Request, init?: RequestInit) => {
|
|
293
|
+
seen.push(String(init?.body));
|
|
294
|
+
return Response.json({ key: "amrt_k2", keyExpiresAtMs: 50, refreshToken: "amrr_r2", refreshExpiresAtMs: 90 });
|
|
295
|
+
}) as unknown as typeof fetch;
|
|
296
|
+
await refreshAndRewrite({ remote, fetchImpl, home, packageDir: process.cwd(), env, platform: "darwin", pathHas: () => false, routerHome: rh, storeDeps: { backend } });
|
|
297
|
+
expect(seen[0]).toBe(JSON.stringify({ refreshToken: "amrr_r1" }));
|
|
298
|
+
expect(vault.get("u_ada@team.example")).toBe("amrr_r2");
|
|
299
|
+
expect(readFileSync(join(rh, "remote.json"), "utf8")).not.toContain("amrr_r2");
|
|
300
|
+
// An older remote.json with the token inline is honoured until its next refresh.
|
|
301
|
+
const legacy = parseRemoteRouter(JSON.stringify({ url: "https://t", key: "k", refreshToken: "amrr_inline" }))!;
|
|
302
|
+
expect(resolveRefreshToken(legacy, rh)).toBe("amrr_inline");
|
|
303
|
+
} finally {
|
|
304
|
+
rmSync(home, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("connect writes Claude Code's settings: base URL in env, a key helper instead of a key, other settings kept", async () => {
|
|
309
|
+
const home = mkdtempSync(join(tmpdir(), "amr-claude-"));
|
|
310
|
+
const claude = join(home, ".claude");
|
|
311
|
+
mkdirSync(claude, { recursive: true });
|
|
312
|
+
writeFileSync(join(claude, "settings.json"), JSON.stringify({ theme: "dark", env: { ANTHROPIC_API_KEY: "sk-old", FOO: "bar" } }, null, 2) + NL);
|
|
313
|
+
const env = { HOME: home, PI_CODING_AGENT_DIR: join(home, "no-omp"), AUTO_MODEL_ROUTER_HOME: join(home, ".auto-model-router"), HERMES_HOME: join(home, "no-hermes") };
|
|
314
|
+
try {
|
|
315
|
+
const { connectRemote } = await import("../src/cli/connect.ts");
|
|
316
|
+
const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u_ada", name: "Ada", profile: false, dryRun: false, only: ["claude"], env, home, packageDir: "/pkg", platform: "linux", pathHas: () => false });
|
|
317
|
+
expect(r.configured.some((c) => c.startsWith("Claude Code ("))).toBe(true);
|
|
318
|
+
const s = JSON.parse(readFileSync(join(claude, "settings.json"), "utf8")) as { theme: string; env: Record<string, string>; apiKeyHelper: string };
|
|
319
|
+
expect(s.theme).toBe("dark");
|
|
320
|
+
expect(s.env).toEqual({ FOO: "bar", ANTHROPIC_BASE_URL: "https://team.example" }); // the stale key is gone
|
|
321
|
+
expect(s.apiKeyHelper).toMatch(/^bun run ".*\/pkg\/src\/index\.ts" token$/); // an absolute path, drive letter and all on Windows
|
|
322
|
+
expect(r.envLines.some((l) => l.startsWith("ANTHROPIC_API_KEY="))).toBe(false);
|
|
323
|
+
expect(readdirSync(claude).some((f) => f.startsWith("settings.json.") && f.endsWith(".bak"))).toBe(true); // the previous file was kept
|
|
324
|
+
} finally {
|
|
325
|
+
rmSync(home, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SCOPE_ENV, acceptScope, isScopeSlug } from "../src/context/scope.ts";
|
|
3
|
+
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
4
|
+
|
|
5
|
+
describe("the scope a request may name", () => {
|
|
6
|
+
test("a slug passes; the env-var sentinel and other non-slugs do not", () => {
|
|
7
|
+
expect(isScopeSlug("omp-router")).toBe(true);
|
|
8
|
+
expect(isScopeSlug("ashlands")).toBe(true);
|
|
9
|
+
expect(isScopeSlug("my.app_v2")).toBe(true);
|
|
10
|
+
// omp sends the header's literal value when the variable it names is
|
|
11
|
+
// unset: the NAME must never become a project.
|
|
12
|
+
expect(isScopeSlug(SCOPE_ENV)).toBe(false);
|
|
13
|
+
expect(isScopeSlug("")).toBe(false);
|
|
14
|
+
expect(isScopeSlug("-leading")).toBe(false);
|
|
15
|
+
expect(isScopeSlug("Has Spaces")).toBe(false);
|
|
16
|
+
expect(isScopeSlug("a".repeat(129))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("acceptScope trims and rejects", () => {
|
|
20
|
+
expect(acceptScope(" omp-router ")).toBe("omp-router");
|
|
21
|
+
expect(acceptScope(SCOPE_ENV)).toBe("");
|
|
22
|
+
expect(acceptScope(null)).toBe("");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("the wire parser drops a non-slug X-Agentdox-Scope", () => {
|
|
26
|
+
const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
|
|
27
|
+
const parse = (scope: string) => parseChatRequest(body, new Headers({ "x-agentdox-scope": scope }));
|
|
28
|
+
expect(parse("omp-router").agentdoxScope).toBe("omp-router");
|
|
29
|
+
expect(parse(SCOPE_ENV).agentdoxScope).toBe("");
|
|
30
|
+
});
|
|
31
|
+
});
|
package/test/turn.test.ts
CHANGED
|
@@ -73,7 +73,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
73
73
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
|
|
74
74
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
75
75
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
76
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
|
|
76
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
|
|
77
77
|
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
79
|
report: { baselines: [], dailySummary: false },
|
|
@@ -660,6 +660,81 @@ describe("agentdox write-back sees the shape of the turn", () => {
|
|
|
660
660
|
});
|
|
661
661
|
});
|
|
662
662
|
|
|
663
|
+
describe("agentdox injection sees the shape of the turn", () => {
|
|
664
|
+
const AGENT_TOOL = { name: "read", description: "read a file", schemaBytes: 128 };
|
|
665
|
+
|
|
666
|
+
/** A bridge that always has a block, counting how often it is asked. */
|
|
667
|
+
function mkServingBridge(): { bridge: ContextBridge; resolves: number[] } {
|
|
668
|
+
const resolves: number[] = [];
|
|
669
|
+
return {
|
|
670
|
+
resolves,
|
|
671
|
+
bridge: {
|
|
672
|
+
enabled: true,
|
|
673
|
+
resolve: () => {
|
|
674
|
+
resolves.push(1);
|
|
675
|
+
return Promise.resolve({ block: "<project-context>ctx</project-context>", version: "v1", fetchedAtMs: 1 });
|
|
676
|
+
},
|
|
677
|
+
recordTurn: () => {},
|
|
678
|
+
flush: () => Promise.resolve(),
|
|
679
|
+
pruneBlocks: () => 0,
|
|
680
|
+
close: () => {},
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function oneDispatch() {
|
|
686
|
+
const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: null })]);
|
|
687
|
+
const { upstream } = mkUpstream([
|
|
688
|
+
{ kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("high"), finishChunk("stop"), usageChunk({}, 0.0001)] },
|
|
689
|
+
]);
|
|
690
|
+
const { ledger } = mkLedger();
|
|
691
|
+
const { store } = mkConversations();
|
|
692
|
+
const { sink, errors } = mkSink();
|
|
693
|
+
return { router, upstream, ledger, store, sink, errors };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
test("a harness utility call gets no context block", async () => {
|
|
697
|
+
// Measured on one omp turn: seven tool-less side calls (title, ratings)
|
|
698
|
+
// each received the whole ~6k-token block — 42k prompt tokens for
|
|
699
|
+
// answers ABOUT the conversation. The tool array is the discriminator,
|
|
700
|
+
// exactly as for recording.
|
|
701
|
+
const { router, upstream, ledger, store, sink, errors } = oneDispatch();
|
|
702
|
+
const { bridge, resolves } = mkServingBridge();
|
|
703
|
+
const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [] };
|
|
704
|
+
|
|
705
|
+
await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
|
|
706
|
+
|
|
707
|
+
expect(errors).toHaveLength(0);
|
|
708
|
+
expect(resolves).toHaveLength(0);
|
|
709
|
+
expect(store.load(req.conversationKey).contextVersion).toBeNull();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("an agent turn with tools is injected", async () => {
|
|
713
|
+
const { router, upstream, ledger, store, sink, errors } = oneDispatch();
|
|
714
|
+
const { bridge, resolves } = mkServingBridge();
|
|
715
|
+
const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [AGENT_TOOL] };
|
|
716
|
+
|
|
717
|
+
await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
|
|
718
|
+
|
|
719
|
+
expect(errors).toHaveLength(0);
|
|
720
|
+
expect(resolves).toHaveLength(1);
|
|
721
|
+
expect(store.load(req.conversationKey).contextVersion).toBe("v1");
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("context.injectWithoutTools restores injection into tool-less calls", async () => {
|
|
725
|
+
const { router, upstream, ledger, store, sink, errors } = oneDispatch();
|
|
726
|
+
const { bridge, resolves } = mkServingBridge();
|
|
727
|
+
const base = mkConfig({ enabled: false });
|
|
728
|
+
const config: RouterConfig = { ...base, context: { ...base.context, injectWithoutTools: true } };
|
|
729
|
+
const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [] };
|
|
730
|
+
|
|
731
|
+
await runTurn(req, sink, { config, router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
|
|
732
|
+
|
|
733
|
+
expect(errors).toHaveLength(0);
|
|
734
|
+
expect(resolves).toHaveLength(1);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
663
738
|
describe("spend reaches the conversation total however the dispatch ends", () => {
|
|
664
739
|
test("a dispatch that dies mid-stream still books what it was billed", async () => {
|
|
665
740
|
// Live data: 152 aborted dispatches billed $0.9985 — 30% of all spend —
|