auto-model-router 0.9.0 → 0.11.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 +35 -4
- package/docs/AGENTDOX-BRIDGE.md +10 -0
- package/omp-extension/remote-logic.ts +7 -0
- package/omp-extension/router-embed.ts +13 -0
- package/package.json +1 -1
- package/src/cli/build-executable.ts +142 -0
- package/src/cli/config-cmd.ts +9 -4
- package/src/cli/config-wizard.ts +1 -0
- package/src/cli/connect.ts +103 -21
- package/src/cli/embedded.ts +78 -0
- package/src/cli/refresh.ts +8 -1
- 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 +7 -3
- package/src/lib.ts +1 -0
- package/src/server/turn.ts +8 -1
- package/src/wire/openai/request.ts +5 -2
- package/test/executable.test.ts +132 -0
- package/test/failover.test.ts +1 -1
- package/test/remote.test.ts +11 -4
- 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.11.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.11.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1330,10 +1330,30 @@ short-lived key rotates underneath a running session.
|
|
|
1330
1330
|
In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
|
|
1331
1331
|
remote router serves every repo on the machine with that repo's shared context. The remote
|
|
1332
1332
|
decides what to do with it: a team edition that pins a scope on the member's group
|
|
1333
|
-
overrides it, and one that pins none follows the workspace.
|
|
1334
|
-
the
|
|
1335
|
-
machine-wide
|
|
1336
|
-
|
|
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.
|
|
1342
|
+
|
|
1343
|
+
### One file to install
|
|
1344
|
+
|
|
1345
|
+
A remote that serves members can hand them this package as a single executable: the CLI
|
|
1346
|
+
compiled by `bun build --compile` for their operating system (`buildExecutable` in the
|
|
1347
|
+
library, one of `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`,
|
|
1348
|
+
`windows-x64`), with the package's own source files embedded. Nothing else is installed —
|
|
1349
|
+
no bun, no npm. omp still loads the extensions from disk and Hermes still copies its
|
|
1350
|
+
plugin, so the first `connect` from the executable writes the embedded files out under
|
|
1351
|
+
`<router home>/package/<version>/` and points every harness there; the executable itself
|
|
1352
|
+
becomes Claude Code's key helper and, with `--profile`, goes on PATH. `connect
|
|
1353
|
+
--setup-token <token>` trades a one-time onboarding token at the remote's
|
|
1354
|
+
`/setup/exchange` for the credential, so the install command carries no key at all.
|
|
1355
|
+
`remote.json` records the executable, and a refresh from inside omp keeps the helper
|
|
1356
|
+
pointed at it.
|
|
1337
1357
|
|
|
1338
1358
|
## Multiple coding harnesses, one router
|
|
1339
1359
|
|
|
@@ -1471,6 +1491,17 @@ move while the loop runs. So the router buffers the assistant's narration across
|
|
|
1471
1491
|
the loop and writes it once, together with the closing synthesis, when the
|
|
1472
1492
|
assistant actually yields back to the user.
|
|
1473
1493
|
|
|
1494
|
+
### Utility calls get neither the block nor a transcript
|
|
1495
|
+
|
|
1496
|
+
A harness drives more than the agent's conversation through this provider: omp
|
|
1497
|
+
asks for a session title and a complexity rating with `model: auto`. Those
|
|
1498
|
+
calls answer *about* the conversation, carry no tool schemas, and gain nothing
|
|
1499
|
+
from the project block, yet each one paid the whole block — measured at ~6k
|
|
1500
|
+
prompt tokens per call, 42k across one turn's seven side calls. So the tool
|
|
1501
|
+
array is the discriminator for both directions: a request with no tools is
|
|
1502
|
+
neither recorded nor injected. `context.injectWithoutTools: true` restores
|
|
1503
|
+
injection into tool-less requests for a deliberately tool-less agent.
|
|
1504
|
+
|
|
1474
1505
|
Write-backs are queued, bounded, and never awaited: agentdox is an enrichment,
|
|
1475
1506
|
not a dependency. If it is unreachable the turn routes and dispatches normally,
|
|
1476
1507
|
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,
|
|
@@ -36,6 +36,12 @@ export interface RemoteRouter {
|
|
|
36
36
|
refreshExpiresAtMs?: number;
|
|
37
37
|
/** What the remote calls this machine. */
|
|
38
38
|
device?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The compiled executable that ran `connect`, when one did. A refresh from
|
|
41
|
+
* inside omp re-writes every harness config and must point Claude Code's key
|
|
42
|
+
* helper at it, not at a `bun run` of the extracted source.
|
|
43
|
+
*/
|
|
44
|
+
executable?: string;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export function remoteFilePath(routerHome: string): string {
|
|
@@ -59,6 +65,7 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
|
59
65
|
...(typeof raw.keyExpiresAtMs === "number" ? { keyExpiresAtMs: raw.keyExpiresAtMs } : {}),
|
|
60
66
|
...(typeof raw.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: raw.refreshExpiresAtMs } : {}),
|
|
61
67
|
...(typeof raw.device === "string" && raw.device !== "" ? { device: raw.device } : {}),
|
|
68
|
+
...(typeof raw.executable === "string" && raw.executable !== "" ? { executable: raw.executable } : {}),
|
|
62
69
|
};
|
|
63
70
|
} catch {
|
|
64
71
|
return null;
|
|
@@ -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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the single-file member install: this package compiled by
|
|
3
|
+
* `bun build --compile` for one operating system, with the package's own source
|
|
4
|
+
* files embedded so `connect` can write out what omp and Hermes load from disk
|
|
5
|
+
* (see `embedded.ts`). A team server calls this once per router version and
|
|
6
|
+
* target and serves the result; nothing here runs on a member's machine.
|
|
7
|
+
*
|
|
8
|
+
* Cross-compiling needs bun's runtime for the target, which bun downloads on
|
|
9
|
+
* first use and caches; a host without network access to bun's releases can
|
|
10
|
+
* only build its own platform.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { EMBEDDED_GLOBAL, type EmbeddedPackage } from "./embedded.ts";
|
|
17
|
+
|
|
18
|
+
export const EXECUTABLE_TARGETS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "windows-x64"] as const;
|
|
19
|
+
export type ExecutableTarget = (typeof EXECUTABLE_TARGETS)[number];
|
|
20
|
+
|
|
21
|
+
export function isExecutableTarget(value: string): value is ExecutableTarget {
|
|
22
|
+
return (EXECUTABLE_TARGETS as readonly string[]).includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The target of the machine this process runs on, or null when bun has no build for it. */
|
|
26
|
+
export function hostTarget(platform = process.platform, arch = process.arch): ExecutableTarget | null {
|
|
27
|
+
const cpu = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null;
|
|
28
|
+
if (cpu === null) return null;
|
|
29
|
+
if (platform === "linux") return `linux-${cpu}`;
|
|
30
|
+
if (platform === "darwin") return `darwin-${cpu}`;
|
|
31
|
+
if (platform === "win32" && cpu === "x64") return "windows-x64";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The file name a build for `target` is served under. */
|
|
36
|
+
export function executableFileName(target: ExecutableTarget): string {
|
|
37
|
+
return `auto-model-router-${target}${target.startsWith("windows") ? ".exe" : ""}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The package entries the install needs: the CLI, the harness integrations, and the two runtime deps. */
|
|
41
|
+
const PACKAGE_ENTRIES = ["package.json", "README.md", "src", "omp-extension", "hermes-plugin", "opencode-plugin"] as const;
|
|
42
|
+
const RUNTIME_DEPS = ["yaml", "zod"] as const;
|
|
43
|
+
|
|
44
|
+
function walk(root: string, rel: string, keep: (rel: string) => boolean, out: Record<string, string>): void {
|
|
45
|
+
const abs = join(root, ...rel.split("/").filter((s) => s !== ""));
|
|
46
|
+
const st = statSync(abs);
|
|
47
|
+
if (st.isDirectory()) {
|
|
48
|
+
for (const name of readdirSync(abs)) walk(root, rel === "" ? name : `${rel}/${name}`, keep, out);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!keep(rel)) return;
|
|
52
|
+
// Everything the filters let through is text (ts, py, yaml, js, json, md). No byte-level
|
|
53
|
+
// sniffing: src/util/hash.ts holds a NUL inside a string literal and was silently dropped
|
|
54
|
+
// by one, which broke the extension on a member machine and nowhere else.
|
|
55
|
+
out[rel] = readFileSync(abs, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keepPackageFile = (rel: string): boolean => !rel.includes("__pycache__") && !rel.endsWith(".pyc") && !rel.endsWith(".test.ts");
|
|
59
|
+
const keepDepFile = (rel: string): boolean => !/(^|\/)(tests?|__tests__)\//.test(rel) && !/\.(d\.[cm]?ts|map)$/.test(rel) && !/\.test\.[cm]?[jt]sx?$/.test(rel);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every file the executable embeds, keyed by path relative to the package
|
|
63
|
+
* root. The runtime dependencies are resolved from the package's own location,
|
|
64
|
+
* so a `bun link`ed package finds them too.
|
|
65
|
+
*/
|
|
66
|
+
export function collectPackageFiles(packageDir: string): EmbeddedPackage {
|
|
67
|
+
const files: Record<string, string> = {};
|
|
68
|
+
for (const entry of PACKAGE_ENTRIES) {
|
|
69
|
+
if (existsSync(join(packageDir, entry))) walk(packageDir, entry, keepPackageFile, files);
|
|
70
|
+
}
|
|
71
|
+
for (const dep of RUNTIME_DEPS) {
|
|
72
|
+
let depDir: string;
|
|
73
|
+
try {
|
|
74
|
+
depDir = dirname(Bun.resolveSync(`${dep}/package.json`, packageDir));
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const depFiles: Record<string, string> = {};
|
|
79
|
+
walk(depDir, "", keepDepFile, depFiles);
|
|
80
|
+
for (const [rel, content] of Object.entries(depFiles)) files[`node_modules/${dep}/${rel}`] = content;
|
|
81
|
+
}
|
|
82
|
+
const version = (JSON.parse(files["package.json"] ?? "{}") as { version?: string }).version ?? "0.0.0";
|
|
83
|
+
return { version, files };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface BuildExecutableOptions {
|
|
87
|
+
packageDir: string;
|
|
88
|
+
target: ExecutableTarget;
|
|
89
|
+
/** Where the executable goes; created or replaced. */
|
|
90
|
+
outFile: string;
|
|
91
|
+
/** The bun binary to build with; this process's own when it is bun. */
|
|
92
|
+
bun?: string;
|
|
93
|
+
/** A directory for the manifest and entry module; a temp dir when absent, removed afterwards. */
|
|
94
|
+
stageDir?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type BuildExecutableResult = { ok: true; path: string; bytes: number } | { ok: false; reason: string };
|
|
98
|
+
|
|
99
|
+
function bunBinary(explicit: string | undefined): string | null {
|
|
100
|
+
if (explicit !== undefined) return explicit;
|
|
101
|
+
if (/(^|[\\/])bun(\.exe)?$/i.test(process.execPath)) return process.execPath;
|
|
102
|
+
return Bun.which("bun");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compiles the package at `packageDir` into `outFile` for `target`. The entry
|
|
107
|
+
* module sets the global `embedded.ts` reads and then loads the CLI, so the CLI
|
|
108
|
+
* itself never knows at build time whether it will be compiled.
|
|
109
|
+
*/
|
|
110
|
+
export async function buildExecutable(opts: BuildExecutableOptions): Promise<BuildExecutableResult> {
|
|
111
|
+
const bun = bunBinary(opts.bun);
|
|
112
|
+
if (bun === null) return { ok: false, reason: "bun is not available to build with" };
|
|
113
|
+
const stage = opts.stageDir ?? mkdtempSync(join(tmpdir(), "amr-exe-"));
|
|
114
|
+
mkdirSync(stage, { recursive: true });
|
|
115
|
+
try {
|
|
116
|
+
const pkg = collectPackageFiles(opts.packageDir);
|
|
117
|
+
if (pkg.files["src/index.ts"] === undefined) return { ok: false, reason: `${opts.packageDir} does not hold the router package (no src/index.ts)` };
|
|
118
|
+
writeFileSync(join(stage, "manifest.json"), JSON.stringify(pkg), "utf8");
|
|
119
|
+
const entry = join(opts.packageDir, "src", "index.ts").replaceAll("\\", "/");
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(stage, "entry.ts"),
|
|
122
|
+
[
|
|
123
|
+
`import manifest from "./manifest.json" with { type: "file" };`,
|
|
124
|
+
`(globalThis as Record<string, unknown>)[${JSON.stringify(EMBEDDED_GLOBAL)}] = { manifestPath: manifest };`,
|
|
125
|
+
`await import(${JSON.stringify(entry)});`,
|
|
126
|
+
"",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
"utf8",
|
|
129
|
+
);
|
|
130
|
+
mkdirSync(dirname(opts.outFile), { recursive: true });
|
|
131
|
+
const proc = Bun.spawn([bun, "build", "--compile", `--target=bun-${opts.target}`, join(stage, "entry.ts"), "--outfile", opts.outFile], { stdout: "pipe", stderr: "pipe", cwd: opts.packageDir });
|
|
132
|
+
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
133
|
+
if (exitCode !== 0 || !existsSync(opts.outFile)) {
|
|
134
|
+
const tail = `${stderr}\n${stdout}`.trim().split("\n").slice(-6).join("\n");
|
|
135
|
+
return { ok: false, reason: `bun build --compile for ${opts.target} failed (exit ${exitCode}): ${tail}` };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, path: opts.outFile, bytes: statSync(opts.outFile).size };
|
|
138
|
+
} finally {
|
|
139
|
+
if (opts.stageDir === undefined) rmSync(stage, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
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
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
-
import { homedir } from "node:os";
|
|
28
|
+
import { homedir, hostname } from "node:os";
|
|
29
29
|
import { dirname, join, resolve } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
32
|
+
import { SCOPE_ENV } from "../context/scope.ts";
|
|
33
|
+
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
32
34
|
import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
|
|
33
35
|
import { flagString, type CliArgs } from "./args.ts";
|
|
34
36
|
|
|
@@ -47,7 +49,7 @@ export interface ConnectOptions {
|
|
|
47
49
|
packageDir: string;
|
|
48
50
|
/** Cost figures omp shows for the remote's virtual models, USD per million tokens. */
|
|
49
51
|
blend?: { inputPerMtok: number; outputPerMtok: number };
|
|
50
|
-
/**
|
|
52
|
+
/** 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. */
|
|
51
53
|
agentdoxScope?: string;
|
|
52
54
|
/** Short-lived credential fields from a remote that issues them; absent for a permanent key. */
|
|
53
55
|
refreshToken?: string;
|
|
@@ -57,6 +59,12 @@ export interface ConnectOptions {
|
|
|
57
59
|
keyExpiresAtMs?: number;
|
|
58
60
|
refreshExpiresAtMs?: number;
|
|
59
61
|
device?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The compiled executable running this, when one is (see embedded.ts). It
|
|
64
|
+
* becomes Claude Code's key helper and goes on PATH with --profile, and
|
|
65
|
+
* remote.json records it so a refresh from omp keeps pointing at it.
|
|
66
|
+
*/
|
|
67
|
+
exePath?: string;
|
|
60
68
|
platform: string;
|
|
61
69
|
pathHas: (bin: string) => boolean;
|
|
62
70
|
}
|
|
@@ -141,10 +149,13 @@ const MODELS_YML_END = " # END auto-model-router (remote)";
|
|
|
141
149
|
* neither problem — the URL and the key are stable — and the entry is what makes
|
|
142
150
|
* omp's main model resolvable at startup, before extensions load.
|
|
143
151
|
*
|
|
144
|
-
* `
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
152
|
+
* `X-Agentdox-Scope` on this entry reaches the MAIN model's turns, which the
|
|
153
|
+
* extensions' own registration cannot (they load after the handle is built).
|
|
154
|
+
* The file is machine-wide, so a literal slug here would label every
|
|
155
|
+
* workspace's turns with one project; by default the value is the NAME of
|
|
156
|
+
* `SCOPE_ENV`, which omp resolves from its environment per request, and the
|
|
157
|
+
* embed extension sets that variable from the workspace folder as it loads.
|
|
158
|
+
* `scope` pins a literal slug instead, for a single-project machine.
|
|
148
159
|
*/
|
|
149
160
|
export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string {
|
|
150
161
|
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
@@ -162,7 +173,7 @@ export function renderRemoteModelsYml(url: string, key: string, blend: { inputPe
|
|
|
162
173
|
" api: openai-completions",
|
|
163
174
|
` apiKey: ${key}`,
|
|
164
175
|
];
|
|
165
|
-
|
|
176
|
+
lines.push(" headers:", ` X-Agentdox-Scope: ${scope !== "" ? scope : SCOPE_ENV}`);
|
|
166
177
|
lines.push(" models:");
|
|
167
178
|
for (const m of REMOTE_MODEL_ROWS) {
|
|
168
179
|
lines.push(
|
|
@@ -201,14 +212,15 @@ export function mergeModelsYml(before: string, blockText: string): string {
|
|
|
201
212
|
return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`;
|
|
202
213
|
}
|
|
203
214
|
|
|
204
|
-
/** The `X-Agentdox-Scope` the managed block
|
|
215
|
+
/** The literal `X-Agentdox-Scope` the managed block pins, or "" when it follows the workspace (or has none). */
|
|
205
216
|
export function existingBlockScope(text: string): string {
|
|
206
217
|
const begin = text.indexOf(MODELS_YML_BEGIN);
|
|
207
218
|
if (begin < 0) return "";
|
|
208
219
|
const end = text.indexOf(MODELS_YML_END, begin);
|
|
209
220
|
const block = text.slice(begin, end < 0 ? text.length : end);
|
|
210
221
|
const m = /X-Agentdox-Scope:\s*(\S+)/.exec(block);
|
|
211
|
-
|
|
222
|
+
const value = m?.[1] ?? "";
|
|
223
|
+
return value === SCOPE_ENV ? "" : value;
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
/** True when the file already defines our provider outside a block we manage. */
|
|
@@ -252,6 +264,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
252
264
|
...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
|
|
253
265
|
...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
|
|
254
266
|
...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
|
|
267
|
+
...(o.exePath !== undefined && o.exePath !== "" ? { executable: o.exePath } : {}),
|
|
255
268
|
},
|
|
256
269
|
null,
|
|
257
270
|
2,
|
|
@@ -334,8 +347,9 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
334
347
|
const env: Record<string, unknown> = { ...((settings.env as Record<string, unknown> | undefined) ?? {}), ANTHROPIC_BASE_URL: o.url };
|
|
335
348
|
// Never leave a stale key beside the helper: the helper is the source now.
|
|
336
349
|
delete env.ANTHROPIC_API_KEY;
|
|
337
|
-
|
|
338
|
-
const
|
|
350
|
+
// The executable is its own helper; under bun the source entry is.
|
|
351
|
+
const helper = o.exePath !== undefined && o.exePath !== "" ? `"${o.exePath.replaceAll("\\", "/")}" token` : `bun run "${resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/")}" token`;
|
|
352
|
+
const next = { ...settings, env, apiKeyHelper: helper };
|
|
339
353
|
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
340
354
|
if (after !== before) {
|
|
341
355
|
if (before !== "" && !o.dryRun) writeFileSync(`${settingsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, before, "utf8");
|
|
@@ -353,11 +367,14 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
353
367
|
const [k, ...v] = line.split("=");
|
|
354
368
|
Bun.spawnSync(["setx", k!, v.join("=")], { stdout: "ignore", stderr: "ignore" });
|
|
355
369
|
}
|
|
370
|
+
// Not setx for PATH: it truncates at 1024 characters and would eat the rest.
|
|
371
|
+
if (o.exePath !== undefined && o.exePath !== "") addToUserPathWindows(dirname(o.exePath));
|
|
356
372
|
report.notes.push("user environment variables set with setx; open a new terminal");
|
|
357
373
|
} else {
|
|
358
374
|
const shell = o.env.SHELL ?? "";
|
|
359
375
|
const rc = shell.includes("zsh") ? join(o.home, ".zshrc") : join(o.home, ".bashrc");
|
|
360
|
-
const
|
|
376
|
+
const pathLine = o.exePath !== undefined && o.exePath !== "" ? [`export PATH="${dirname(o.exePath)}:$PATH"`] : [];
|
|
377
|
+
const block = `\n# auto-model-router remote (added by \`auto-model-router connect\`)\n${[...report.envLines.map((l) => `export ${l}`), ...pathLine].join("\n")}\n`;
|
|
361
378
|
const before = existsSync(rc) ? readFileSync(rc, "utf8") : "";
|
|
362
379
|
if (!before.includes("# auto-model-router remote") && !before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8");
|
|
363
380
|
else write(rc, before.replace(/\n# auto-model-router (?:remote|team)[^\n]*\n(?:export [^\n]*\n)*/, block));
|
|
@@ -369,16 +386,84 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
369
386
|
return report;
|
|
370
387
|
}
|
|
371
388
|
|
|
389
|
+
/** What a team's one-time setup token is traded for. */
|
|
390
|
+
export interface IssuedCredential {
|
|
391
|
+
key: string;
|
|
392
|
+
refreshToken: string;
|
|
393
|
+
keyExpiresAtMs?: number;
|
|
394
|
+
refreshExpiresAtMs?: number;
|
|
395
|
+
userId: string;
|
|
396
|
+
name: string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Trades a one-time setup token for this machine's credential at the team's
|
|
401
|
+
* exchange route, so the install needs nothing on the machine but this
|
|
402
|
+
* program: the token is the only secret in the install command and dies on use.
|
|
403
|
+
*/
|
|
404
|
+
export async function exchangeSetupToken(url: string, token: string, device: string, fetchImpl: typeof fetch = fetch): Promise<IssuedCredential> {
|
|
405
|
+
const res = await fetchImpl(`${url}/setup/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, device }), signal: AbortSignal.timeout(15_000) });
|
|
406
|
+
if (res.status === 401) throw new Error("the setup token was refused (expired or already used); get a new one from the team's portal");
|
|
407
|
+
if (!res.ok) throw new Error(`the team's setup exchange answered ${res.status}`);
|
|
408
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
409
|
+
if (typeof body.key !== "string" || body.key === "") throw new Error("the team's setup exchange returned no key");
|
|
410
|
+
return {
|
|
411
|
+
key: body.key,
|
|
412
|
+
refreshToken: typeof body.refreshToken === "string" ? body.refreshToken : "",
|
|
413
|
+
...(typeof body.keyExpiresAtMs === "number" ? { keyExpiresAtMs: body.keyExpiresAtMs } : {}),
|
|
414
|
+
...(typeof body.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: body.refreshExpiresAtMs } : {}),
|
|
415
|
+
userId: typeof body.userId === "string" ? body.userId : "",
|
|
416
|
+
name: typeof body.name === "string" ? body.name : "",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Adds `dir` to the user's PATH on Windows, once, through the registry-backed API rather than setx. */
|
|
421
|
+
function addToUserPathWindows(dir: string): void {
|
|
422
|
+
const quoted = `'${dir.replaceAll("'", "''")}'`;
|
|
423
|
+
const script = `$d=${quoted}; $p=[Environment]::GetEnvironmentVariable('Path','User'); if ($null -eq $p) { $p='' }; if (($p -split ';') -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (($p.TrimEnd(';') + ';' + $d).TrimStart(';')), 'User') }`;
|
|
424
|
+
Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], { stdout: "ignore", stderr: "ignore" });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Where the harness integrations are read from. Under bun that is this
|
|
429
|
+
* package; in the compiled executable it is the copy written out from the
|
|
430
|
+
* executable's own embedded files.
|
|
431
|
+
*/
|
|
432
|
+
async function resolvePackageDir(): Promise<string> {
|
|
433
|
+
const embedded = await readEmbeddedPackage();
|
|
434
|
+
if (embedded === null) return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
435
|
+
const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
436
|
+
return materializePackage(expand(raw, homedir()), embedded);
|
|
437
|
+
}
|
|
438
|
+
|
|
372
439
|
export async function connectCommand(args: CliArgs): Promise<void> {
|
|
373
440
|
const url = (flagString(args, "url") ?? process.env.AUTO_MODEL_ROUTER_URL ?? "").replace(/\/+$/, "");
|
|
374
|
-
|
|
375
|
-
|
|
441
|
+
let key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
|
|
442
|
+
const setupToken = flagString(args, "setup-token") ?? "";
|
|
443
|
+
if (url === "" || (key === "" && setupToken === "")) throw new Error("connect needs --url <remote router> and either --key <its key> or --setup-token <one-time token from the team>");
|
|
376
444
|
const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
|
|
377
445
|
const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
|
|
378
|
-
const packageDir =
|
|
379
|
-
|
|
446
|
+
const packageDir = await resolvePackageDir();
|
|
447
|
+
const exePath = executablePath();
|
|
380
448
|
let name = flagString(args, "name") ?? "";
|
|
381
449
|
let userId = flagString(args, "user-id") ?? "";
|
|
450
|
+
let device = flagString(args, "device") ?? "";
|
|
451
|
+
// A remote that issues short-lived keys hands these over beside the key.
|
|
452
|
+
let refreshToken = flagString(args, "refresh-token") ?? "";
|
|
453
|
+
let keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
|
|
454
|
+
let refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
|
|
455
|
+
if (setupToken !== "") {
|
|
456
|
+
if (device === "") device = hostname();
|
|
457
|
+
const issued = await exchangeSetupToken(url, setupToken, device);
|
|
458
|
+
key = issued.key;
|
|
459
|
+
refreshToken = issued.refreshToken;
|
|
460
|
+
keyExpires = issued.keyExpiresAtMs ?? Number.NaN;
|
|
461
|
+
refreshExpires = issued.refreshExpiresAtMs ?? Number.NaN;
|
|
462
|
+
if (issued.userId !== "") userId = issued.userId;
|
|
463
|
+
if (issued.name !== "") name = issued.name;
|
|
464
|
+
console.log(`credential issued for ${name === "" ? userId : name} (device ${device})`);
|
|
465
|
+
}
|
|
466
|
+
// Verify the key against the route every router serves before touching anything.
|
|
382
467
|
try {
|
|
383
468
|
const res = await fetch(`${url}/v1/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
|
|
384
469
|
if (res.status === 401) throw new Error("the remote router rejected this key");
|
|
@@ -391,11 +476,6 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
391
476
|
// A single-project machine can label every request; a machine with several
|
|
392
477
|
// repos should leave it off and let the extensions send the workspace's own.
|
|
393
478
|
const scopeFlag = flagString(args, "scope");
|
|
394
|
-
// A remote that issues short-lived keys hands these over beside the key.
|
|
395
|
-
const refreshToken = flagString(args, "refresh-token") ?? "";
|
|
396
|
-
const keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
|
|
397
|
-
const refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
|
|
398
|
-
const device = flagString(args, "device") ?? "";
|
|
399
479
|
const report = connectRemote({
|
|
400
480
|
url,
|
|
401
481
|
key,
|
|
@@ -414,7 +494,9 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
414
494
|
...(Number.isFinite(keyExpires) ? { keyExpiresAtMs: keyExpires } : {}),
|
|
415
495
|
...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}),
|
|
416
496
|
...(device === "" ? {} : { device }),
|
|
497
|
+
...(exePath === null ? {} : { exePath }),
|
|
417
498
|
});
|
|
499
|
+
if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
|
|
418
500
|
console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
|
|
419
501
|
for (const c of report.configured) console.log(` configured ${c}`);
|
|
420
502
|
for (const s of report.skipped) console.log(` skipped ${s}`);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The router package carried inside a compiled executable.
|
|
3
|
+
*
|
|
4
|
+
* `bun build --compile` turns this CLI into one file per operating system, and
|
|
5
|
+
* that is what a team member downloads: no bun, no npm, nothing else. But the
|
|
6
|
+
* harness integrations are not the CLI — omp loads `omp-extension/*.ts` with
|
|
7
|
+
* its own runtime and Hermes copies `hermes-plugin/` — so the executable also
|
|
8
|
+
* carries the package's source files and writes them out under the router home
|
|
9
|
+
* the first time `connect` runs. The build side (`build-executable.ts`) puts a
|
|
10
|
+
* JSON manifest of those files into the executable and sets a global that names
|
|
11
|
+
* it; this side reads it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
export interface EmbeddedPackage {
|
|
18
|
+
/** The router package's version, which also names the extracted directory. */
|
|
19
|
+
version: string;
|
|
20
|
+
/** Relative path (forward slashes) → file content. Text only: the package has no binaries. */
|
|
21
|
+
files: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the build's entry module sets before the CLI loads. */
|
|
25
|
+
export interface EmbeddedHandle {
|
|
26
|
+
/** Path of the manifest inside the executable's embedded filesystem. */
|
|
27
|
+
manifestPath: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EMBEDDED_GLOBAL = "AUTO_MODEL_ROUTER_EMBEDDED";
|
|
31
|
+
|
|
32
|
+
export function embeddedHandle(): EmbeddedHandle | null {
|
|
33
|
+
const h = (globalThis as Record<string, unknown>)[EMBEDDED_GLOBAL];
|
|
34
|
+
return typeof h === "object" && h !== null && typeof (h as EmbeddedHandle).manifestPath === "string" ? (h as EmbeddedHandle) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when this process is the compiled executable rather than `bun run src/index.ts`. */
|
|
38
|
+
export function isCompiled(): boolean {
|
|
39
|
+
return embeddedHandle() !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The executable's own path when compiled, for key helpers and PATH; null under bun. */
|
|
43
|
+
export function executablePath(): string | null {
|
|
44
|
+
return isCompiled() ? process.execPath : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function readEmbeddedPackage(): Promise<EmbeddedPackage | null> {
|
|
48
|
+
const h = embeddedHandle();
|
|
49
|
+
if (h === null) return null;
|
|
50
|
+
const parsed = JSON.parse(await Bun.file(h.manifestPath).text()) as EmbeddedPackage;
|
|
51
|
+
return typeof parsed.version === "string" && typeof parsed.files === "object" && parsed.files !== null ? parsed : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MARKER = ".materialized";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Writes the embedded package under `<routerHome>/package/<version>/` and
|
|
58
|
+
* returns that directory, the `packageDir` every harness config then points
|
|
59
|
+
* at. Idempotent: a marker records the manifest's hash, so an unchanged
|
|
60
|
+
* executable writes nothing on later runs and a rebuilt one of the same
|
|
61
|
+
* version refreshes the files.
|
|
62
|
+
*/
|
|
63
|
+
export function materializePackage(routerHome: string, pkg: EmbeddedPackage): string {
|
|
64
|
+
const dir = join(routerHome, "package", pkg.version);
|
|
65
|
+
const digest = Bun.hash(JSON.stringify(pkg.files)).toString(16);
|
|
66
|
+
try {
|
|
67
|
+
if (readFileSync(join(dir, MARKER), "utf8").trim() === digest) return dir;
|
|
68
|
+
} catch {
|
|
69
|
+
/* not written yet */
|
|
70
|
+
}
|
|
71
|
+
for (const [rel, content] of Object.entries(pkg.files)) {
|
|
72
|
+
const target = join(dir, ...rel.split("/"));
|
|
73
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
74
|
+
writeFileSync(target, content, "utf8");
|
|
75
|
+
}
|
|
76
|
+
writeFileSync(join(dir, MARKER), `${digest}\n`, "utf8");
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
package/src/cli/refresh.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* its own expiry, so a session still holding it is never cut.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
16
17
|
import { homedir } from "node:os";
|
|
17
18
|
import { dirname, resolve } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -89,7 +90,12 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
89
90
|
const rh = opts.routerHome ?? routerHome();
|
|
90
91
|
const fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch, rh, opts.storeDeps ?? {});
|
|
91
92
|
const home = opts.home ?? (process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir());
|
|
92
|
-
|
|
93
|
+
// The compiled executable rewrites from its own extracted package and keeps
|
|
94
|
+
// itself as the key helper; the omp extension, running from that extracted
|
|
95
|
+
// package, learns the executable from remote.json.
|
|
96
|
+
const embedded = opts.packageDir === undefined ? await readEmbeddedPackage() : null;
|
|
97
|
+
const packageDir = opts.packageDir ?? (embedded === null ? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") : materializePackage(rh, embedded));
|
|
98
|
+
const exePath = opts.remote.executable ?? executablePath() ?? undefined;
|
|
93
99
|
connectRemote({
|
|
94
100
|
url: opts.remote.url,
|
|
95
101
|
key: fresh.key,
|
|
@@ -110,6 +116,7 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
110
116
|
// The store that already holds it keeps it; a machine never silently changes store.
|
|
111
117
|
...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
|
|
112
118
|
...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
|
|
119
|
+
...(exePath !== undefined ? { exePath } : {}),
|
|
113
120
|
// undefined keeps whatever scope the managed models.yml block already carries.
|
|
114
121
|
});
|
|
115
122
|
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
|
@@ -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 { isCompiled, readEmbeddedPackage } from "./cli/embedded.ts";
|
|
16
17
|
import { refreshCommand, tokenCommand } from "./cli/refresh.ts";
|
|
17
18
|
import { modelsCommand } from "./cli/models.ts";
|
|
18
19
|
import { reportCommand } from "./cli/report.ts";
|
|
@@ -27,7 +28,7 @@ Usage: auto-model-router <command> [options]
|
|
|
27
28
|
stats Show routed spend, per-model share, and escalation rates
|
|
28
29
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
29
30
|
export One row per day, harness and model as CSV (--json for rows)
|
|
30
|
-
connect Point this machine at a remote router (--url
|
|
31
|
+
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH)
|
|
31
32
|
refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
|
|
32
33
|
token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
|
|
33
34
|
models Show what each complexity tier would consider, and why
|
|
@@ -56,7 +57,9 @@ async function main(): Promise<number> {
|
|
|
56
57
|
const args = parseArgv(process.argv.slice(2));
|
|
57
58
|
|
|
58
59
|
if (args.flags.has("version")) {
|
|
59
|
-
|
|
60
|
+
// The compiled executable has no package.json beside it; its embedded copy answers.
|
|
61
|
+
const embedded = await readEmbeddedPackage();
|
|
62
|
+
const pkg: unknown = embedded ?? (await Bun.file(join(import.meta.dir, "..", "package.json")).json());
|
|
60
63
|
const value =
|
|
61
64
|
typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string"
|
|
62
65
|
? pkg.version
|
|
@@ -108,7 +111,8 @@ async function main(): Promise<number> {
|
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
|
|
114
|
+
// The compiled executable loads this module from its entry, so it is never import.meta.main there.
|
|
115
|
+
if (import.meta.main || isCompiled()) {
|
|
112
116
|
try {
|
|
113
117
|
const code = await main();
|
|
114
118
|
if (code !== 0) process.exit(code);
|
package/src/lib.ts
CHANGED
|
@@ -23,5 +23,6 @@ export { openDb } from "./util/sqlite.ts";
|
|
|
23
23
|
export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView } from "./cost/views.ts";
|
|
24
24
|
export { createLedger } from "./cost/ledger.ts";
|
|
25
25
|
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
26
|
+
export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
|
|
26
27
|
export type { RequestPolicy } from "./wire/types.ts";
|
|
27
28
|
export type { Ledger, LedgerEntry } from "./cost/types.ts";
|
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";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget } from "../src/cli/build-executable.ts";
|
|
6
|
+
import { connectRemote, exchangeSetupToken } from "../src/cli/connect.ts";
|
|
7
|
+
import { EMBEDDED_GLOBAL, embeddedHandle, isCompiled, materializePackage, readEmbeddedPackage } from "../src/cli/embedded.ts";
|
|
8
|
+
import { parseRemoteRouter } from "../omp-extension/remote-logic.ts";
|
|
9
|
+
|
|
10
|
+
const NL = String.fromCharCode(10);
|
|
11
|
+
|
|
12
|
+
describe("the single-file member install", () => {
|
|
13
|
+
test("the embedded package is the install: CLI, harness integrations, runtime deps; no tests, no caches", () => {
|
|
14
|
+
const pkg = collectPackageFiles(process.cwd());
|
|
15
|
+
expect(pkg.version).toBe((JSON.parse(readFileSync("package.json", "utf8")) as { version: string }).version);
|
|
16
|
+
expect(pkg.files["src/index.ts"]).toContain("connect");
|
|
17
|
+
expect(pkg.files["omp-extension/router-embed.ts"]).toBeDefined();
|
|
18
|
+
expect(pkg.files["hermes-plugin/native/__init__.py"] ?? pkg.files[Object.keys(pkg.files).find((k) => k.startsWith("hermes-plugin/")) ?? ""]).toBeDefined();
|
|
19
|
+
expect(pkg.files["node_modules/zod/package.json"]).toBeDefined();
|
|
20
|
+
expect(pkg.files["node_modules/yaml/package.json"]).toBeDefined();
|
|
21
|
+
const names = Object.keys(pkg.files);
|
|
22
|
+
// Every non-test source file, none missing: a dropped one only fails on a member machine, when the extension imports it.
|
|
23
|
+
const onDisk = readdirSync("src", { recursive: true }).map(String).filter((f) => /\.(ts|py|yaml)$/.test(f) && !f.endsWith(".test.ts")).map((f) => `src/${f.replaceAll("\\", "/")}`);
|
|
24
|
+
expect(onDisk.length).toBeGreaterThan(50);
|
|
25
|
+
for (const f of onDisk) expect(names).toContain(f);
|
|
26
|
+
expect(names.some((n) => n.endsWith(".test.ts"))).toBe(false);
|
|
27
|
+
expect(names.some((n) => n.includes("__pycache__") || n.endsWith(".pyc"))).toBe(false);
|
|
28
|
+
expect(names.some((n) => /\.d\.ts$/.test(n) && n.startsWith("node_modules/"))).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("targets and file names", () => {
|
|
32
|
+
expect(isExecutableTarget("linux-x64")).toBe(true);
|
|
33
|
+
expect(isExecutableTarget("linux-x86")).toBe(false);
|
|
34
|
+
expect(executableFileName("windows-x64")).toBe("auto-model-router-windows-x64.exe");
|
|
35
|
+
expect(executableFileName("darwin-arm64")).toBe("auto-model-router-darwin-arm64");
|
|
36
|
+
expect(hostTarget("win32", "x64")).toBe("windows-x64");
|
|
37
|
+
expect(hostTarget("darwin", "arm64")).toBe("darwin-arm64");
|
|
38
|
+
expect(hostTarget("win32", "arm64")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("under bun there is no embedded package", async () => {
|
|
42
|
+
expect(isCompiled()).toBe(false);
|
|
43
|
+
expect(embeddedHandle()).toBeNull();
|
|
44
|
+
expect(await readEmbeddedPackage()).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("materializing writes the files once, keyed by content, under the router home", () => {
|
|
48
|
+
const home = mkdtempSync(join(tmpdir(), "amr-mat-"));
|
|
49
|
+
try {
|
|
50
|
+
const pkg = { version: "9.9.9", files: { "package.json": `{"version":"9.9.9"}${NL}`, "src/index.ts": `console.log(1)${NL}`, "omp-extension/x.ts": "export {}" } };
|
|
51
|
+
const dir = materializePackage(home, pkg);
|
|
52
|
+
expect(dir).toBe(join(home, "package", "9.9.9"));
|
|
53
|
+
expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe(`console.log(1)${NL}`);
|
|
54
|
+
// Unchanged: a second call leaves a hand-edited file alone (nothing is rewritten).
|
|
55
|
+
writeFileSync(join(dir, "src", "index.ts"), "edited", "utf8");
|
|
56
|
+
expect(materializePackage(home, pkg)).toBe(dir);
|
|
57
|
+
expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe("edited");
|
|
58
|
+
// A rebuilt executable of the same version with different content refreshes it.
|
|
59
|
+
expect(materializePackage(home, { ...pkg, files: { ...pkg.files, "src/index.ts": "v2" } })).toBe(dir);
|
|
60
|
+
expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe("v2");
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("connect from the executable: it is Claude Code's key helper, goes on PATH, and remote.json names it", () => {
|
|
67
|
+
const home = mkdtempSync(join(tmpdir(), "amr-exe-connect-"));
|
|
68
|
+
const claude = join(home, ".claude");
|
|
69
|
+
mkdirSync(claude, { recursive: true });
|
|
70
|
+
const rh = join(home, ".auto-model-router");
|
|
71
|
+
const env = { HOME: home, PI_CODING_AGENT_DIR: join(home, "no-omp"), AUTO_MODEL_ROUTER_HOME: rh, HERMES_HOME: join(home, "no-hermes"), SHELL: "/bin/zsh" };
|
|
72
|
+
const exe = join(rh, "bin", "auto-model-router");
|
|
73
|
+
try {
|
|
74
|
+
const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u_ada", name: "Ada", profile: true, dryRun: false, only: ["claude"], env, home, packageDir: join(rh, "package", "1.0.0"), exePath: exe, platform: "linux", pathHas: () => false });
|
|
75
|
+
expect(r.configured.some((c) => c.startsWith("Claude Code ("))).toBe(true);
|
|
76
|
+
const s = JSON.parse(readFileSync(join(claude, "settings.json"), "utf8")) as { apiKeyHelper: string };
|
|
77
|
+
expect(s.apiKeyHelper).toBe(`"${exe.replaceAll("\\", "/")}" token`);
|
|
78
|
+
expect(parseRemoteRouter(readFileSync(join(rh, "remote.json"), "utf8"))?.executable).toBe(exe);
|
|
79
|
+
const rc = readFileSync(join(home, ".zshrc"), "utf8");
|
|
80
|
+
expect(rc).toContain(`export PATH="${join(rh, "bin")}:$PATH"`);
|
|
81
|
+
expect(rc).toContain("export AUTO_MODEL_ROUTER_URL=https://team.example");
|
|
82
|
+
// Re-running keeps one block.
|
|
83
|
+
connectRemote({ url: "https://team.example", key: "amrt_k2", userId: "u_ada", name: "Ada", profile: true, dryRun: false, only: ["claude"], env, home, packageDir: join(rh, "package", "1.0.0"), exePath: exe, platform: "linux", pathHas: () => false });
|
|
84
|
+
expect(readFileSync(join(home, ".zshrc"), "utf8").split("# auto-model-router remote").length).toBe(2);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(home, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a setup token is traded for the credential; a refused token says so", async () => {
|
|
91
|
+
const seen: { url: string; body: string }[] = [];
|
|
92
|
+
const ok = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
93
|
+
seen.push({ url: String(url), body: String(init?.body) });
|
|
94
|
+
return Response.json({ key: "amrt_new", refreshToken: "amrr_new", keyExpiresAtMs: 10, refreshExpiresAtMs: 20, userId: "u_ada", name: "Ada", teamUrl: "https://team.example" });
|
|
95
|
+
}) as unknown as typeof fetch;
|
|
96
|
+
const issued = await exchangeSetupToken("https://team.example", "amrs_t", "laptop", ok);
|
|
97
|
+
expect(issued).toEqual({ key: "amrt_new", refreshToken: "amrr_new", keyExpiresAtMs: 10, refreshExpiresAtMs: 20, userId: "u_ada", name: "Ada" });
|
|
98
|
+
expect(seen[0]?.url).toBe("https://team.example/setup/exchange");
|
|
99
|
+
expect(JSON.parse(seen[0]?.body ?? "{}")).toEqual({ token: "amrs_t", device: "laptop" });
|
|
100
|
+
const refused = (async () => Response.json({ error: "invalid_token" }, { status: 401 })) as unknown as typeof fetch;
|
|
101
|
+
await expect(exchangeSetupToken("https://team.example", "amrs_old", "laptop", refused)).rejects.toThrow("refused");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the executable builds for this host and knows its version from the embedded package", async () => {
|
|
105
|
+
const target = hostTarget();
|
|
106
|
+
if (target === null) return;
|
|
107
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-build-"));
|
|
108
|
+
try {
|
|
109
|
+
const out = join(dir, executableFileName(target));
|
|
110
|
+
const r = await buildExecutable({ packageDir: process.cwd(), target, outFile: out });
|
|
111
|
+
if (!r.ok) throw new Error(r.reason);
|
|
112
|
+
expect(existsSync(out)).toBe(true);
|
|
113
|
+
expect(r.bytes).toBeGreaterThan(10_000_000);
|
|
114
|
+
const version = Bun.spawnSync([out, "--version"], { stdout: "pipe", stderr: "pipe" }).stdout.toString().trim();
|
|
115
|
+
expect(version).toBe((JSON.parse(readFileSync("package.json", "utf8")) as { version: string }).version);
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}, 120_000);
|
|
120
|
+
|
|
121
|
+
test("the global handle is what marks a compiled process", () => {
|
|
122
|
+
const g = globalThis as Record<string, unknown>;
|
|
123
|
+
g[EMBEDDED_GLOBAL] = { manifestPath: "/$bunfs/root/manifest.json" };
|
|
124
|
+
try {
|
|
125
|
+
expect(isCompiled()).toBe(true);
|
|
126
|
+
expect(embeddedHandle()?.manifestPath).toBe("/$bunfs/root/manifest.json");
|
|
127
|
+
} finally {
|
|
128
|
+
delete g[EMBEDDED_GLOBAL];
|
|
129
|
+
}
|
|
130
|
+
expect(isCompiled()).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
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
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
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 { SCOPE_ENV } from "../src/context/scope.ts";
|
|
9
10
|
import { refreshAndRewrite, refreshCredential, RefreshError, resolveRefreshToken, shouldRefresh } from "../src/cli/refresh.ts";
|
|
10
11
|
import { loadRefreshToken, pickStore, removeRefreshToken, saveRefreshToken } from "../src/cli/credential-store.ts";
|
|
11
12
|
import { hasRefresh, refreshAccountOf } from "../omp-extension/remote-logic.ts";
|
|
@@ -114,7 +115,7 @@ describe("omp models.yml for a remote router", () => {
|
|
|
114
115
|
const NL = String.fromCharCode(10);
|
|
115
116
|
const yaml = (...lines: string[]): string => lines.join(NL) + NL;
|
|
116
117
|
|
|
117
|
-
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", () => {
|
|
118
119
|
const block = renderRemoteModelsYml("https://team.example/", "amrt_k", BLEND);
|
|
119
120
|
expect(block).toContain("baseUrl: https://team.example/v1");
|
|
120
121
|
expect(block).toContain("apiKey: amrt_k");
|
|
@@ -122,9 +123,15 @@ describe("omp models.yml for a remote router", () => {
|
|
|
122
123
|
expect(block).toContain("- id: auto-cheap");
|
|
123
124
|
expect(block).toContain("- id: auto-max");
|
|
124
125
|
expect(block).toContain("cost: { input: 1.1, output: 4.4, cacheRead: 0.11, cacheWrite: 1.375 }");
|
|
125
|
-
// Machine-wide file:
|
|
126
|
-
|
|
127
|
-
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");
|
|
128
135
|
});
|
|
129
136
|
|
|
130
137
|
test("merging keeps other providers, replaces our own block, and is idempotent", () => {
|
|
@@ -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 —
|