auto-model-router 0.16.0 → 0.17.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 +20 -0
- package/docs/AGENTDOX-BRIDGE.md +18 -1
- package/omp-extension/embed-logic.ts +91 -1
- package/omp-extension/remote-logic.ts +4 -1
- package/omp-extension/router-embed.ts +16 -4
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +5 -2
- package/src/cli/connect.ts +6 -2
- package/src/context/scope.ts +63 -0
- package/src/server/advise.ts +1 -0
- package/src/server/digest.ts +1 -0
- package/src/wire/openai/request.ts +7 -1
- package/src/wire/types.ts +9 -0
- package/test/anthropic-wire.test.ts +6 -0
- package/test/embed-logic.test.ts +86 -1
- package/test/escalate.test.ts +1 -0
- package/test/failover.test.ts +1 -0
- package/test/models-yml.test.ts +10 -0
- package/test/remote.test.ts +8 -1
- package/test/scope.test.ts +54 -1
- package/test/turn.test.ts +1 -0
- package/tools/replay.ts +1 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.17.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.17.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1343,6 +1343,9 @@ the embed extension sets it from the workspace as it loads. If the variable is u
|
|
|
1343
1343
|
sends the name itself, which the router does not accept as a scope (a scope is a
|
|
1344
1344
|
lowercase slug) and falls back to its default. `--scope <slug>` pins one project for the
|
|
1345
1345
|
whole machine instead; a refresh keeps a pin, and `connect --scope ""` removes it.
|
|
1346
|
+
`X-Agentdox-Origin` rides the same way under `AUTO_MODEL_ROUTER_ORIGIN`, set from the
|
|
1347
|
+
workspace's git remote; a pinned scope does not pin it, because the repository a turn
|
|
1348
|
+
came from is still the workspace's. See [the origin fingerprint](#the-origin-fingerprint).
|
|
1346
1349
|
|
|
1347
1350
|
### One file to install
|
|
1348
1351
|
|
|
@@ -1541,6 +1544,23 @@ a team posts exactly what it always did, so its block is byte-identical; an olde
|
|
|
1541
1544
|
agentdox ignores the keys it does not know. `context.layers: false` is the kill
|
|
1542
1545
|
switch — the headers are still parsed but nothing new goes to agentdox.
|
|
1543
1546
|
|
|
1547
|
+
### The origin fingerprint
|
|
1548
|
+
|
|
1549
|
+
The scope is the folder's name, and folder names collide: two unrelated repositories
|
|
1550
|
+
checked out as `api` would share one project, and one repository cloned into `~/work/api`
|
|
1551
|
+
and `~/src/backend-api` would split into two. So the embed extension also sends
|
|
1552
|
+
`X-Agentdox-Origin`: the workspace's git remote `origin`, normalised to `<host>/<path>` —
|
|
1553
|
+
lowercase, no scheme, credentials, port, `.git` or trailing slash — so
|
|
1554
|
+
`https://github.com/DrewAppling/omp-router.git` and `git@github.com:drewappling/omp-router.git`
|
|
1555
|
+
are both `github.com/drewappling/omp-router`. It is read from `.git/config` (or through a
|
|
1556
|
+
worktree's `.git` file) with the filesystem alone, never by running git, and a workspace
|
|
1557
|
+
outside a repository or with a local-path remote sends none. Like the scope it reaches the
|
|
1558
|
+
main model through an environment variable, `AUTO_MODEL_ROUTER_ORIGIN`, that the managed
|
|
1559
|
+
`models.yml` entry names and the extension sets. A team edition uses it to find the
|
|
1560
|
+
project a workspace belongs to whatever the folder is called; a router on its own has no
|
|
1561
|
+
project registry and ignores it — the header is parsed and validated (a raw URL or the
|
|
1562
|
+
variable's name is dropped) and nothing else changes.
|
|
1563
|
+
|
|
1544
1564
|
### It does not cost you a cache miss per turn
|
|
1545
1565
|
|
|
1546
1566
|
The context block sits at the front of the prompt, so re-fetching it every turn
|
package/docs/AGENTDOX-BRIDGE.md
CHANGED
|
@@ -32,7 +32,8 @@ unchanged re-assembly keeps the same bytes and the cache survives.
|
|
|
32
32
|
| `src/context/store.ts` | `context_blocks` (content-addressed) + `agentdox_sessions` |
|
|
33
33
|
| `src/context/index.ts` | `createBridgeFromConfig` — returns an inert bridge when unconfigured |
|
|
34
34
|
| `src/server/turn.ts` | Resolve → inject → pin → record. Two `log.debug("agentdox …")` lines |
|
|
35
|
-
| `src/wire/openai/request.ts` | `injectContextBlock` + `x-agentdox
|
|
35
|
+
| `src/wire/openai/request.ts` | `injectContextBlock` + the `x-agentdox-*` header parsing (the Anthropic path delegates to it) |
|
|
36
|
+
| `src/context/scope.ts` | The slug and origin rules, and the env-var names the managed provider entries carry |
|
|
36
37
|
| `src/util/sqlite.ts` | `USER_VERSION` 11, `MIGRATE_V11` |
|
|
37
38
|
| `test/context-bridge.test.ts` | 14 tests: every refresh trigger, restart survival, degradation |
|
|
38
39
|
| `tools/agentdox-e2e.ts` | Live check against a running agentdox server |
|
|
@@ -41,6 +42,22 @@ Injection appends to the **last system message** rather than inserting one — i
|
|
|
41
42
|
shift every `cacheBreakpointMessageIndices` entry the core computed, and appending lands the
|
|
42
43
|
block inside the prefix `planCacheBreakpoints` already marks.
|
|
43
44
|
|
|
45
|
+
### The request headers
|
|
46
|
+
|
|
47
|
+
Every one is optional; absent or invalid ⇒ empty, and an empty value sends nothing new to
|
|
48
|
+
agentdox, so a router without a front door behaves as it always did.
|
|
49
|
+
|
|
50
|
+
| Header | Since | Value | Who sends it | What the router does |
|
|
51
|
+
| --- | --- | --- | --- | --- |
|
|
52
|
+
| `X-Agentdox-Scope` | 0.2 | a project slug (`isScopeSlug`) | the embed extension, from the workspace folder; the managed `models.yml` entry names `AUTO_MODEL_ROUTER_SCOPE` and the extension sets it | selects the project whose context is injected and whose sessions the turn is recorded into; empty ⇒ `context.defaultScope` |
|
|
53
|
+
| `X-Agentdox-Group` | 0.16.0 | a slug | a front door (the team edition), per request | the group's brief and top memory render **first**; sent to `assemble` as `group` |
|
|
54
|
+
| `X-Agentdox-Personal` | 0.16.0 | a slug | a front door, per request | the member's own thread renders **last**; sent as `personal`. When either layer is named the harness id goes as `user` too |
|
|
55
|
+
| `X-Agentdox-Origin` | 0.17.0 | `<host>/<path>` of the git remote (`isOrigin`) | the embed extension, from `.git/config`; `models.yml` names `AUTO_MODEL_ROUTER_ORIGIN` | parsed and validated only (`NormRequest.agentdoxOrigin`); the bridge never reads it — a front door with a project registry resolves the project from it |
|
|
56
|
+
|
|
57
|
+
omp resolves a header value that names an environment variable per request, and sends the
|
|
58
|
+
literal name when the variable is unset. Both names are uppercase and neither rule accepts
|
|
59
|
+
uppercase, so a sentinel can never become a project or a fingerprint.
|
|
60
|
+
|
|
44
61
|
## 3. Running it
|
|
45
62
|
|
|
46
63
|
```bash
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { normalizeOrigin } from "../src/context/scope.ts";
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* The provider id registered into omp. Kept stable so a `models.yml` that
|
|
@@ -58,6 +60,13 @@ export interface EmbedConfig {
|
|
|
58
60
|
* models never loses the project's memory/docs/brief.
|
|
59
61
|
*/
|
|
60
62
|
agentdoxScope?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The workspace's repository fingerprint (its git remote `origin`,
|
|
65
|
+
* normalised) sent as `X-Agentdox-Origin`. A front door with a project
|
|
66
|
+
* registry uses it to find the project whatever the folder is called; a
|
|
67
|
+
* router alone ignores it.
|
|
68
|
+
*/
|
|
69
|
+
agentdoxOrigin?: string;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
/**
|
|
@@ -79,6 +88,81 @@ export function deriveAgentdoxScope(cwd: string): string {
|
|
|
79
88
|
.replace(/^-+|-+$/g, "");
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
/** The first `url` under `[remote "origin"]` in a git config, or "" when there is none. */
|
|
92
|
+
function originUrlOf(config: string): string {
|
|
93
|
+
let inOrigin = false;
|
|
94
|
+
for (const line of config.split(/\r?\n/)) {
|
|
95
|
+
const t = line.trim();
|
|
96
|
+
if (t.startsWith("[")) {
|
|
97
|
+
inOrigin = /^\[remote\s+"origin"\]$/i.test(t);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!inOrigin) continue;
|
|
101
|
+
const m = /^url\s*=\s*(.+)$/i.exec(t);
|
|
102
|
+
if (m !== null) return m[1]!.trim().replace(/^"(.*)"$/, "$1");
|
|
103
|
+
}
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Derives the workspace's repository fingerprint: the git remote `origin` of
|
|
109
|
+
* the repository holding `cwd`, normalised by `normalizeOrigin`.
|
|
110
|
+
*
|
|
111
|
+
* The scope (`deriveAgentdoxScope`) is the FOLDER's name, which two unrelated
|
|
112
|
+
* repositories can share and one repository cloned twice does not. The origin
|
|
113
|
+
* is what a project registry needs to tell them apart, so it travels beside
|
|
114
|
+
* the scope; the scope itself is untouched by this.
|
|
115
|
+
*
|
|
116
|
+
* Walks up from `cwd` to the filesystem root looking for `.git`. A directory
|
|
117
|
+
* holds `config` directly; a file (a worktree or a submodule) names the real
|
|
118
|
+
* git dir with `gitdir: <path>`, relative to the folder holding the file, and
|
|
119
|
+
* a worktree's own dir keeps the shared config one hop further in `commondir`.
|
|
120
|
+
* Read with the filesystem only — never git itself, which may be absent, slow,
|
|
121
|
+
* or prompt — and total: any failure, including no repository at all, is "".
|
|
122
|
+
* `readFile` is injectable so the walk can be tested on hand-built files.
|
|
123
|
+
*/
|
|
124
|
+
export function deriveWorkspaceOrigin(cwd: string, readFile: (path: string) => string = (p) => readFileSync(p, "utf8")): string {
|
|
125
|
+
const read = (path: string): string | null => {
|
|
126
|
+
try {
|
|
127
|
+
return readFile(path);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
if (cwd.trim() === "") return "";
|
|
134
|
+
let dir = resolve(cwd);
|
|
135
|
+
for (;;) {
|
|
136
|
+
const dotGit = join(dir, ".git");
|
|
137
|
+
// A directory: the config sits inside it. (Reading a directory throws.)
|
|
138
|
+
let config = read(join(dotGit, "config"));
|
|
139
|
+
if (config === null) {
|
|
140
|
+
// A file: `gitdir: <path>`; that dir has its own config (a
|
|
141
|
+
// submodule) or points at the shared one through `commondir`.
|
|
142
|
+
const pointer = read(dotGit);
|
|
143
|
+
const m = pointer === null ? null : /^gitdir:\s*(.+?)\s*$/m.exec(pointer);
|
|
144
|
+
if (m !== null) {
|
|
145
|
+
const gitDir = resolve(dir, m[1]!);
|
|
146
|
+
config = read(join(gitDir, "config"));
|
|
147
|
+
if (config === null) {
|
|
148
|
+
const common = read(join(gitDir, "commondir"));
|
|
149
|
+
if (common !== null) config = read(join(resolve(gitDir, common.trim()), "config"));
|
|
150
|
+
}
|
|
151
|
+
// A `.git` file that leads nowhere is still the repository's
|
|
152
|
+
// boundary: nothing above it is this workspace's origin.
|
|
153
|
+
return config === null ? "" : normalizeOrigin(originUrlOf(config));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (config !== null) return normalizeOrigin(originUrlOf(config));
|
|
157
|
+
const parent = dirname(dir);
|
|
158
|
+
if (parent === dir) return "";
|
|
159
|
+
dir = parent;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
82
166
|
/**
|
|
83
167
|
* Resolves the desired bind port: an explicit `AUTO_MODEL_ROUTER_PORT` when set
|
|
84
168
|
* and valid, else 0 so the OS assigns a free ephemeral port.
|
|
@@ -189,6 +273,8 @@ export function buildProviderConfig(
|
|
|
189
273
|
},
|
|
190
274
|
/** omp's workspace directory, used to derive a scope when none is configured. */
|
|
191
275
|
cwd?: string,
|
|
276
|
+
/** The workspace's repository fingerprint (`deriveWorkspaceOrigin`); "" or absent sends none. */
|
|
277
|
+
origin?: string,
|
|
192
278
|
): EmbedConfig {
|
|
193
279
|
const host = cfg.server.host === "0.0.0.0" || cfg.server.host === "::" ? "127.0.0.1" : cfg.server.host;
|
|
194
280
|
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
@@ -224,6 +310,10 @@ export function buildProviderConfig(
|
|
|
224
310
|
const derived = deriveAgentdoxScope(cwd ?? "");
|
|
225
311
|
const scope = derived !== "" ? derived : cfg.context.defaultScope;
|
|
226
312
|
if (scope !== "") out.agentdoxScope = scope;
|
|
313
|
+
// The repository beside the folder: one value for every clone of it,
|
|
314
|
+
// whatever each is called. Only a registry can use it, so it is sent
|
|
315
|
+
// only where the scope is.
|
|
316
|
+
if (origin !== undefined && origin !== "") out.agentdoxOrigin = origin;
|
|
227
317
|
}
|
|
228
318
|
return out;
|
|
229
319
|
}
|
|
@@ -112,7 +112,7 @@ export const REMOTE_MODELS: readonly { id: string; name: string }[] = [
|
|
|
112
112
|
* the session and subagent tags, and the virtual models. Costs are USD per
|
|
113
113
|
* million tokens, like the embedded config.
|
|
114
114
|
*/
|
|
115
|
-
export function remoteProviderRegistration(remote: RemoteRouter, sessionId: string, subagent: boolean, blend: { inputPerMtok: number; outputPerMtok: number }, agentdoxScope = ""): {
|
|
115
|
+
export function remoteProviderRegistration(remote: RemoteRouter, sessionId: string, subagent: boolean, blend: { inputPerMtok: number; outputPerMtok: number }, agentdoxScope = "", agentdoxOrigin = ""): {
|
|
116
116
|
baseUrl: string;
|
|
117
117
|
api: string;
|
|
118
118
|
apiKey: string;
|
|
@@ -126,6 +126,9 @@ export function remoteProviderRegistration(remote: RemoteRouter, sessionId: stri
|
|
|
126
126
|
// router, so this is sent whatever the local config says; the remote decides what to do with
|
|
127
127
|
// it (a team that pins a scope for the group overrides it, and one that pins none follows it).
|
|
128
128
|
if (agentdoxScope !== "") headers["X-Agentdox-Scope"] = agentdoxScope;
|
|
129
|
+
// The repository behind that folder: the same value from every clone, so a remote with a
|
|
130
|
+
// project registry finds the project when two folders share a name or one repo has two.
|
|
131
|
+
if (agentdoxOrigin !== "") headers["X-Agentdox-Origin"] = agentdoxOrigin;
|
|
129
132
|
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
130
133
|
return {
|
|
131
134
|
baseUrl: `${remote.url}/v1`,
|
|
@@ -33,8 +33,8 @@ import type { RouterConfig } from "../src/config/types.ts";
|
|
|
33
33
|
|
|
34
34
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
35
35
|
|
|
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";
|
|
36
|
+
import { EMBED_DUMMY_API_KEY, EMBED_PROVIDER_ID, buildProviderConfig, deriveAgentdoxScope, deriveWorkspaceOrigin, embedPortPath, modelsYmlPort, probeEmbed, readEmbedPort, resolveEmbedPort, writeEmbedPort } from "./embed-logic.ts";
|
|
37
|
+
import { ORIGIN_ENV, SCOPE_ENV } from "../src/context/scope.ts";
|
|
38
38
|
|
|
39
39
|
// The workspace's scope for the MAIN model. omp builds that handle from
|
|
40
40
|
// models.yml before this file loads, so its X-Agentdox-Scope cannot come from
|
|
@@ -43,9 +43,17 @@ import { SCOPE_ENV } from "../src/context/scope.ts";
|
|
|
43
43
|
// and this module runs inside omp's process — so setting it here reaches
|
|
44
44
|
// every turn of this session, main and side roles alike. A workspace that
|
|
45
45
|
// derives no scope (no folder name) leaves whatever the shell set.
|
|
46
|
+
//
|
|
47
|
+
// The workspace's ORIGIN (its git remote, normalised) rides the same way under
|
|
48
|
+
// ORIGIN_ENV: the folder name is the scope, the repository is the fingerprint
|
|
49
|
+
// a team uses to find the project when folder names collide. Read once — the
|
|
50
|
+
// remote does not change during a session — and shared with the providers
|
|
51
|
+
// registered below. A workspace outside a repository sets nothing.
|
|
52
|
+
const workspaceOrigin = deriveWorkspaceOrigin(process.cwd());
|
|
46
53
|
{
|
|
47
54
|
const workspaceScope = deriveAgentdoxScope(process.cwd());
|
|
48
55
|
if (workspaceScope !== "") process.env[SCOPE_ENV] = workspaceScope;
|
|
56
|
+
if (workspaceOrigin !== "") process.env[ORIGIN_ENV] = workspaceOrigin;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** omp's models.yml as text, or "" when it does not exist / cannot be read. */
|
|
@@ -97,7 +105,7 @@ function trackProcessExit(): void {
|
|
|
97
105
|
function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig, sessionId: string, subagent: boolean): void {
|
|
98
106
|
// cwd is omp's workspace, which is what the agentdox scope is derived from
|
|
99
107
|
// when none is configured explicitly.
|
|
100
|
-
const providerConfig = buildProviderConfig(port, cfg, process.cwd());
|
|
108
|
+
const providerConfig = buildProviderConfig(port, cfg, process.cwd(), workspaceOrigin);
|
|
101
109
|
const headers: Record<string, string> = {};
|
|
102
110
|
if (providerConfig.harnessId !== undefined && providerConfig.harnessId !== "") {
|
|
103
111
|
headers["X-Omp-Harness"] = providerConfig.harnessId;
|
|
@@ -112,6 +120,10 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
|
|
|
112
120
|
if (providerConfig.agentdoxScope !== undefined && providerConfig.agentdoxScope !== "") {
|
|
113
121
|
headers["X-Agentdox-Scope"] = providerConfig.agentdoxScope;
|
|
114
122
|
}
|
|
123
|
+
// The repository behind that folder, for a front door that keeps a registry.
|
|
124
|
+
if (providerConfig.agentdoxOrigin !== undefined && providerConfig.agentdoxOrigin !== "") {
|
|
125
|
+
headers["X-Agentdox-Origin"] = providerConfig.agentdoxOrigin;
|
|
126
|
+
}
|
|
115
127
|
pi.registerProvider(EMBED_PROVIDER_ID, {
|
|
116
128
|
baseUrl: providerConfig.baseUrl,
|
|
117
129
|
api: "openai-completions",
|
|
@@ -190,7 +202,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
190
202
|
writeEmbedLog(`remote credential refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
191
203
|
}
|
|
192
204
|
}
|
|
193
|
-
pi.registerProvider(EMBED_PROVIDER_ID, remoteProviderRegistration(remote, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend, deriveAgentdoxScope(process.cwd())));
|
|
205
|
+
pi.registerProvider(EMBED_PROVIDER_ID, remoteProviderRegistration(remote, sessionId, !ctx.hasUI, cfg.ledger.fallbackBlend, deriveAgentdoxScope(process.cwd()), workspaceOrigin));
|
|
194
206
|
pi.setLabel(`auto-model-router remote (${remote.url.replace(/^https?:\/\//, "")})`);
|
|
195
207
|
writeEmbedLog(`remote mode url=${remote.url} user=${remote.userId} session=${sessionId}`);
|
|
196
208
|
return;
|
package/package.json
CHANGED
package/src/cli/config-cmd.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* keys survive.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { SCOPE_ENV } from "../context/scope.ts";
|
|
18
|
+
import { ORIGIN_ENV, SCOPE_ENV } from "../context/scope.ts";
|
|
19
19
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { dirname, join } from "node:path";
|
|
@@ -65,7 +65,9 @@ export interface SpliceResult {
|
|
|
65
65
|
* the embed extension sets from the workspace folder, so the MAIN model's turns
|
|
66
66
|
* carry the repository's own scope rather than one for the whole machine. When
|
|
67
67
|
* the variable is unset the router ignores the literal and falls back to its
|
|
68
|
-
* own `context.defaultScope`.
|
|
68
|
+
* own `context.defaultScope`. The origin (`ORIGIN_ENV`, set by the extension
|
|
69
|
+
* from the workspace's git remote) rides the same way; a router on its own
|
|
70
|
+
* ignores it, a front door with a project registry does not.
|
|
69
71
|
*/
|
|
70
72
|
function providerHeaders(cfg: RouterConfig): Record<string, string> {
|
|
71
73
|
const headers: Record<string, string> = {};
|
|
@@ -74,6 +76,7 @@ function providerHeaders(cfg: RouterConfig): Record<string, string> {
|
|
|
74
76
|
}
|
|
75
77
|
if (cfg.context.enabled) {
|
|
76
78
|
headers["X-Agentdox-Scope"] = SCOPE_ENV;
|
|
79
|
+
headers["X-Agentdox-Origin"] = ORIGIN_ENV;
|
|
77
80
|
}
|
|
78
81
|
return headers;
|
|
79
82
|
}
|
package/src/cli/connect.ts
CHANGED
|
@@ -29,7 +29,7 @@ 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";
|
|
32
|
+
import { ORIGIN_ENV, SCOPE_ENV } from "../context/scope.ts";
|
|
33
33
|
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
34
34
|
import { fetchSkills, installSkills, type SkillsBundle, type SkillsInstallReport, type SkillsTarget } from "./skills.ts";
|
|
35
35
|
import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
|
|
@@ -200,6 +200,10 @@ const MODELS_YML_END = " # END auto-model-router (remote)";
|
|
|
200
200
|
* `SCOPE_ENV`, which omp resolves from its environment per request, and the
|
|
201
201
|
* embed extension sets that variable from the workspace folder as it loads.
|
|
202
202
|
* `scope` pins a literal slug instead, for a single-project machine.
|
|
203
|
+
*
|
|
204
|
+
* `X-Agentdox-Origin` always names `ORIGIN_ENV`, which the extension sets from
|
|
205
|
+
* the workspace's git remote: a pinned scope says which project's context to
|
|
206
|
+
* draw on, and the origin still says which repository the turn came from.
|
|
203
207
|
*/
|
|
204
208
|
export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string {
|
|
205
209
|
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
@@ -217,7 +221,7 @@ export function renderRemoteModelsYml(url: string, key: string, blend: { inputPe
|
|
|
217
221
|
" api: openai-completions",
|
|
218
222
|
` apiKey: ${key}`,
|
|
219
223
|
];
|
|
220
|
-
lines.push(" headers:", ` X-Agentdox-Scope: ${scope !== "" ? scope : SCOPE_ENV}`);
|
|
224
|
+
lines.push(" headers:", ` X-Agentdox-Scope: ${scope !== "" ? scope : SCOPE_ENV}`, ` X-Agentdox-Origin: ${ORIGIN_ENV}`);
|
|
221
225
|
lines.push(" models:");
|
|
222
226
|
for (const m of REMOTE_MODEL_ROWS) {
|
|
223
227
|
lines.push(
|
package/src/context/scope.ts
CHANGED
|
@@ -27,3 +27,66 @@ export function acceptScope(raw: string | null | undefined): string {
|
|
|
27
27
|
const s = (raw ?? "").trim();
|
|
28
28
|
return isScopeSlug(s) ? s : "";
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The workspace's ORIGIN travels the same road as the scope: `connect` and
|
|
33
|
+
* `config --write` put this NAME in the managed provider entry, the embed
|
|
34
|
+
* extension sets the variable from the workspace's git remote as it loads, and
|
|
35
|
+
* omp resolves it per request. Where the scope names a FOLDER, the origin
|
|
36
|
+
* names the REPOSITORY, so a front door with a project registry (the team
|
|
37
|
+
* edition) can tell two unrelated `api` folders apart and recognise one repo
|
|
38
|
+
* cloned into two differently named folders. A router on its own has no such
|
|
39
|
+
* registry and ignores it.
|
|
40
|
+
*/
|
|
41
|
+
export const ORIGIN_ENV = "AUTO_MODEL_ROUTER_ORIGIN";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A repository fingerprint: `<host>/<path>` — lowercase, no scheme, no
|
|
45
|
+
* credentials, no port, no trailing `.git`, no trailing slash — so the same
|
|
46
|
+
* repository cloned over https and over ssh yields one value. Uppercase never
|
|
47
|
+
* passes, so `ORIGIN_ENV` itself, which omp sends verbatim when the variable is
|
|
48
|
+
* unset, can never become a fingerprint.
|
|
49
|
+
*/
|
|
50
|
+
export function isOrigin(value: string): boolean {
|
|
51
|
+
return /^[a-z0-9][a-z0-9.-]{0,127}(\/[a-z0-9._~-]{1,64}){1,8}$/.test(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reduces a git remote URL to its fingerprint, or "" when it names no host —
|
|
56
|
+
* a local path (`/srv/repo`, `C:\repo`) or `file://` — or does not reduce to a
|
|
57
|
+
* valid origin. Every form git accepts for `origin` is covered: `scheme://`
|
|
58
|
+
* with optional credentials and port, and the scp-like `[user@]host:path`.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeOrigin(url: string): string {
|
|
61
|
+
const raw = url.trim();
|
|
62
|
+
let host: string;
|
|
63
|
+
let path: string;
|
|
64
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(raw);
|
|
65
|
+
if (scheme !== null) {
|
|
66
|
+
if (scheme[1]!.toLowerCase() === "file") return "";
|
|
67
|
+
const rest = raw.slice(scheme[0].length);
|
|
68
|
+
const slash = rest.search(/[/\\]/);
|
|
69
|
+
host = slash < 0 ? rest : rest.slice(0, slash);
|
|
70
|
+
path = slash < 0 ? "" : rest.slice(slash + 1);
|
|
71
|
+
} else {
|
|
72
|
+
// scp-like `[user@]host:path`. A Windows drive (`C:\repo`, `C:/repo`)
|
|
73
|
+
// has the same shape with a one-letter "host" and is a local path.
|
|
74
|
+
const scp = /^(?:[^@/\\:]+@)?([^/\\:]+):(.*)$/.exec(raw);
|
|
75
|
+
if (scp === null || /^[a-z]$/i.test(scp[1]!)) return "";
|
|
76
|
+
host = scp[1]!;
|
|
77
|
+
path = scp[2]!;
|
|
78
|
+
}
|
|
79
|
+
// Credentials first (a password may contain digits and a colon), then the port.
|
|
80
|
+
host = host.slice(host.lastIndexOf("@") + 1).replace(/:\d+$/, "");
|
|
81
|
+
const segments = path.split(/[/\\]/).filter((s) => s !== "");
|
|
82
|
+
const last = segments.length - 1;
|
|
83
|
+
if (last >= 0) segments[last] = segments[last]!.replace(/\.git$/i, "");
|
|
84
|
+
const value = [host, ...segments.filter((s) => s !== "")].join("/").toLowerCase();
|
|
85
|
+
return isOrigin(value) ? value : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The origin to trust from a request header: a fingerprint, or "" for anything else. */
|
|
89
|
+
export function acceptOrigin(raw: string | null | undefined): string {
|
|
90
|
+
const s = (raw ?? "").trim();
|
|
91
|
+
return isOrigin(s) ? s : "";
|
|
92
|
+
}
|
package/src/server/advise.ts
CHANGED
package/src/server/digest.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { acceptScope } from "../../context/scope.ts";
|
|
1
|
+
import { acceptOrigin, acceptScope } from "../../context/scope.ts";
|
|
2
2
|
import type {
|
|
3
3
|
RequestPolicy,
|
|
4
4
|
CompactionEdit,
|
|
@@ -341,6 +341,11 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
341
341
|
const agentdoxGroup = acceptScope(headers.get("x-agentdox-group"));
|
|
342
342
|
const agentdoxPersonal = acceptScope(headers.get("x-agentdox-personal"));
|
|
343
343
|
|
|
344
|
+
// The workspace's repository fingerprint, for a front door that keeps a
|
|
345
|
+
// project registry. Same sentinel rule as the scope: omp sends the env-var
|
|
346
|
+
// NAME when the variable is unset, and a name is never a fingerprint.
|
|
347
|
+
const agentdoxOrigin = acceptOrigin(headers.get("x-agentdox-origin"));
|
|
348
|
+
|
|
344
349
|
// Subagent marker from the embed extension (sessions without a UI).
|
|
345
350
|
const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
|
|
346
351
|
|
|
@@ -409,6 +414,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
409
414
|
agentdoxScope,
|
|
410
415
|
agentdoxGroup,
|
|
411
416
|
agentdoxPersonal,
|
|
417
|
+
agentdoxOrigin,
|
|
412
418
|
isSubagent,
|
|
413
419
|
...(policy === undefined ? {} : { policy }),
|
|
414
420
|
requestedModel,
|
package/src/wire/types.ts
CHANGED
|
@@ -109,6 +109,15 @@ export interface NormRequest {
|
|
|
109
109
|
* renders LAST. Empty when absent or not a slug ⇒ no personal layer.
|
|
110
110
|
*/
|
|
111
111
|
agentdoxPersonal: string;
|
|
112
|
+
/**
|
|
113
|
+
* The workspace's repository fingerprint from the `X-Agentdox-Origin`
|
|
114
|
+
* request header (`<host>/<path>` of its git remote `origin`, see
|
|
115
|
+
* `src/context/scope.ts`). Validated here, in one place, for a front door
|
|
116
|
+
* with a project registry — the team edition — which uses it to find the
|
|
117
|
+
* project two same-named folders are really about. The router's own bridge
|
|
118
|
+
* has no registry and never reads it. Empty when absent or not a fingerprint.
|
|
119
|
+
*/
|
|
120
|
+
agentdoxOrigin: string;
|
|
112
121
|
/** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
|
|
113
122
|
isSubagent: boolean;
|
|
114
123
|
/**
|
|
@@ -154,6 +154,12 @@ describe("parseMessagesRequest", () => {
|
|
|
154
154
|
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-group": "Not A Slug" })).agentdoxGroup).toBe("");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
test("the origin fingerprint reaches the request on the Anthropic path too", () => {
|
|
158
|
+
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "user-agent": "claude-cli/2.1.263" })).agentdoxOrigin).toBe("");
|
|
159
|
+
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "github.com/drewappling/omp-router" })).agentdoxOrigin).toBe("github.com/drewappling/omp-router");
|
|
160
|
+
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "https://github.com/a/b" })).agentdoxOrigin).toBe("");
|
|
161
|
+
});
|
|
162
|
+
|
|
157
163
|
test("a request captured from Claude Code 2.1: system inside messages, JSON user_id, adaptive thinking with effort, 23 custom tools", () => {
|
|
158
164
|
const fixture = JSON.parse(readFileSync("test/fixtures/harness/claude-code.json", "utf8")) as { headers: Record<string, string>; body: Record<string, unknown> };
|
|
159
165
|
const norm = parseMessagesRequest(fixture.body, new Headers(fixture.headers));
|
package/test/embed-logic.test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
buildProviderConfig,
|
|
8
8
|
deriveAgentdoxScope,
|
|
9
|
+
deriveWorkspaceOrigin,
|
|
9
10
|
EMBED_PORT_FILE,
|
|
10
11
|
EMBED_PROVIDER_ID,
|
|
11
12
|
embedPortPath,
|
|
@@ -207,3 +208,87 @@ describe("agentdox scope", () => {
|
|
|
207
208
|
expect(buildProviderConfig(1234, cfg, "/x/ashlands").agentdoxScope).toBeUndefined();
|
|
208
209
|
});
|
|
209
210
|
});
|
|
211
|
+
|
|
212
|
+
describe("workspace origin", () => {
|
|
213
|
+
const CONFIG = ['[core]', '\trepositoryformatversion = 0', '[remote "origin"]', '\turl = https://github.com/DrewAppling/omp-router.git', '\tfetch = +refs/heads/*:refs/remotes/origin/*', '[branch "main"]', '\tremote = origin', ''].join("\n");
|
|
214
|
+
let root: string;
|
|
215
|
+
beforeAll(() => {
|
|
216
|
+
root = mkdtempSync(join(tmpdir(), "amr-origin-"));
|
|
217
|
+
});
|
|
218
|
+
afterAll(() => {
|
|
219
|
+
rmSync(root, { recursive: true, force: true });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a plain repository: the remote from .git/config, also from a subdirectory", () => {
|
|
223
|
+
const repo = join(root, "repo");
|
|
224
|
+
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
225
|
+
writeFileSync(join(repo, ".git", "config"), CONFIG);
|
|
226
|
+
mkdirSync(join(repo, "src", "deep"), { recursive: true });
|
|
227
|
+
expect(deriveWorkspaceOrigin(repo)).toBe("github.com/drewappling/omp-router");
|
|
228
|
+
expect(deriveWorkspaceOrigin(join(repo, "src", "deep"))).toBe("github.com/drewappling/omp-router");
|
|
229
|
+
// The scope is still the folder, untouched by any of this.
|
|
230
|
+
expect(deriveAgentdoxScope(repo)).toBe("repo");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", () => {
|
|
234
|
+
// The main checkout holds the config; the worktree's own dir only points at it.
|
|
235
|
+
const main = join(root, "main");
|
|
236
|
+
mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
|
|
237
|
+
writeFileSync(join(main, ".git", "config"), CONFIG.replace("github.com/DrewAppling/omp-router.git", "gitlab.example.com:2222/team/api/").replace("https://", "ssh://git@"));
|
|
238
|
+
writeFileSync(join(main, ".git", "worktrees", "wt", "commondir"), "../..\n");
|
|
239
|
+
const wt = join(root, "wt");
|
|
240
|
+
mkdirSync(wt, { recursive: true });
|
|
241
|
+
writeFileSync(join(wt, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`);
|
|
242
|
+
expect(deriveWorkspaceOrigin(wt)).toBe("gitlab.example.com/team/api");
|
|
243
|
+
// A submodule-style pointer: the named dir has its own config, relative to the .git file.
|
|
244
|
+
const sub = join(root, "sub");
|
|
245
|
+
mkdirSync(join(sub, "modules", "lib"), { recursive: true });
|
|
246
|
+
mkdirSync(join(sub, "lib"), { recursive: true });
|
|
247
|
+
writeFileSync(join(sub, "modules", "lib", "config"), CONFIG.replace("DrewAppling/omp-router", "org/lib"));
|
|
248
|
+
writeFileSync(join(sub, "lib", ".git"), "gitdir: ../modules/lib\n");
|
|
249
|
+
expect(deriveWorkspaceOrigin(join(sub, "lib"))).toBe("github.com/org/lib");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", () => {
|
|
253
|
+
const bare = join(root, "bare");
|
|
254
|
+
mkdirSync(join(bare, ".git"), { recursive: true });
|
|
255
|
+
writeFileSync(join(bare, ".git", "config"), "[core]\n\tbare = false\n");
|
|
256
|
+
expect(deriveWorkspaceOrigin(bare)).toBe("");
|
|
257
|
+
const local = join(root, "local");
|
|
258
|
+
mkdirSync(join(local, ".git"), { recursive: true });
|
|
259
|
+
writeFileSync(join(local, ".git", "config"), '[remote "origin"]\n\turl = /srv/git/local.git\n');
|
|
260
|
+
expect(deriveWorkspaceOrigin(local)).toBe("");
|
|
261
|
+
// A `.git` file that leads nowhere is the boundary: the walk stops there.
|
|
262
|
+
const dangling = join(root, "repo", "dangling");
|
|
263
|
+
mkdirSync(dangling, { recursive: true });
|
|
264
|
+
writeFileSync(join(dangling, ".git"), "gitdir: /nowhere/at/all\n");
|
|
265
|
+
expect(deriveWorkspaceOrigin(dangling)).toBe("");
|
|
266
|
+
expect(deriveWorkspaceOrigin(join(root, "not-a-repo", "missing"))).toBe("");
|
|
267
|
+
expect(deriveWorkspaceOrigin("")).toBe("");
|
|
268
|
+
expect(
|
|
269
|
+
deriveWorkspaceOrigin("/anywhere", () => {
|
|
270
|
+
throw new Error("disk on fire");
|
|
271
|
+
}),
|
|
272
|
+
).toBe("");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("the first url under [remote \"origin\"] wins; other remotes do not count", () => {
|
|
276
|
+
const read = () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n[remote "origin"]\n\turl = git@github.com:me/thing.git\n\turl = https://github.com/me/second.git\n';
|
|
277
|
+
expect(deriveWorkspaceOrigin("/x/repo", read)).toBe("github.com/me/thing");
|
|
278
|
+
expect(deriveWorkspaceOrigin("/x/repo", () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n')).toBe("");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("buildProviderConfig carries the origin beside the scope, only where the scope goes", () => {
|
|
282
|
+
const base = {
|
|
283
|
+
server: { host: "127.0.0.1" },
|
|
284
|
+
profiles: [],
|
|
285
|
+
ledger: { fallbackBlend: { inputPerMtok: 1, outputPerMtok: 1 } },
|
|
286
|
+
};
|
|
287
|
+
const on = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands");
|
|
288
|
+
expect(on.agentdoxScope).toBe("ashlands");
|
|
289
|
+
expect(on.agentdoxOrigin).toBe("github.com/me/ashlands");
|
|
290
|
+
expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "").agentdoxOrigin).toBeUndefined();
|
|
291
|
+
expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands").agentdoxOrigin).toBeUndefined();
|
|
292
|
+
expect(buildProviderConfig(1234, { ...base, context: { enabled: false, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands").agentdoxOrigin).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
});
|
package/test/escalate.test.ts
CHANGED
package/test/failover.test.ts
CHANGED
|
@@ -98,6 +98,7 @@ function mkReq(): NormRequest {
|
|
|
98
98
|
agentdoxScope: "",
|
|
99
99
|
agentdoxGroup: "",
|
|
100
100
|
agentdoxPersonal: "",
|
|
101
|
+
agentdoxOrigin: "",
|
|
101
102
|
isSubagent: false,
|
|
102
103
|
requestedModel: "auto",
|
|
103
104
|
messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
|
package/test/models-yml.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { parse as parseYaml } from "yaml";
|
|
3
3
|
|
|
4
4
|
import { loadConfig } from "../src/config/load.ts";
|
|
5
|
+
import { ORIGIN_ENV, SCOPE_ENV } from "../src/context/scope.ts";
|
|
5
6
|
import {
|
|
6
7
|
BEGIN_GUARD,
|
|
7
8
|
END_GUARD,
|
|
@@ -68,6 +69,15 @@ describe("renderProviderBlock", () => {
|
|
|
68
69
|
expect(BLOCK).toContain("auth: none");
|
|
69
70
|
expect(BLOCK).toContain("/v1");
|
|
70
71
|
});
|
|
72
|
+
|
|
73
|
+
test("with the bridge on, the scope and origin headers name the variables the extension sets", () => {
|
|
74
|
+
// The file is machine-wide, so neither can be a literal: omp resolves a
|
|
75
|
+
// header value that names an env var per request (src/context/scope.ts).
|
|
76
|
+
const bridged = renderProviderBlock(loadConfig({ overrides: { context: { enabled: true, baseUrl: "http://agentdox:3003", token: "t" } } }), null);
|
|
77
|
+
expect(bridged).toContain(`X-Agentdox-Scope: ${SCOPE_ENV}`);
|
|
78
|
+
expect(bridged).toContain(`X-Agentdox-Origin: ${ORIGIN_ENV}`);
|
|
79
|
+
expect(BLOCK).not.toContain("X-Agentdox-");
|
|
80
|
+
});
|
|
71
81
|
});
|
|
72
82
|
|
|
73
83
|
describe("spliceProviderBlock", () => {
|
package/test/remote.test.ts
CHANGED
|
@@ -6,7 +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
|
+
import { ORIGIN_ENV, SCOPE_ENV } from "../src/context/scope.ts";
|
|
10
10
|
import { refreshAndRewrite, refreshCredential, RefreshError, resolveRefreshToken, shouldRefresh } from "../src/cli/refresh.ts";
|
|
11
11
|
import { loadRefreshToken, pickStore, removeRefreshToken, saveRefreshToken } from "../src/cli/credential-store.ts";
|
|
12
12
|
import { hasRefresh, refreshAccountOf } from "../omp-extension/remote-logic.ts";
|
|
@@ -31,6 +31,9 @@ describe("remote-logic", () => {
|
|
|
31
31
|
// the machine with the right context; the remote may still override it.
|
|
32
32
|
expect(reg.headers["X-Agentdox-Scope"]).toBeUndefined();
|
|
33
33
|
expect(remoteProviderRegistration(t, "", false, { inputPerMtok: 1, outputPerMtok: 4 }, "omp-router").headers).toEqual({ "X-Agentdox-Scope": "omp-router" });
|
|
34
|
+
// The repository fingerprint rides beside the scope, and only when there is one.
|
|
35
|
+
expect(remoteProviderRegistration(t, "", false, { inputPerMtok: 1, outputPerMtok: 4 }, "omp-router", "github.com/drewappling/omp-router").headers).toEqual({ "X-Agentdox-Scope": "omp-router", "X-Agentdox-Origin": "github.com/drewappling/omp-router" });
|
|
36
|
+
expect(remoteProviderRegistration(t, "", false, { inputPerMtok: 1, outputPerMtok: 4 }, "", "github.com/drewappling/omp-router").headers).toEqual({ "X-Agentdox-Origin": "github.com/drewappling/omp-router" });
|
|
34
37
|
const dir = mkdtempSync(join(tmpdir(), "amr-remote-"));
|
|
35
38
|
expect(readRemoteRouter(dir)).toBeNull();
|
|
36
39
|
writeFileSync(join(dir, "remote.json"), JSON.stringify({ url: "https://t", key: "k" }));
|
|
@@ -126,12 +129,16 @@ describe("omp models.yml for a remote router", () => {
|
|
|
126
129
|
// Machine-wide file: the header names the env var the extension sets per
|
|
127
130
|
// workspace, so the MAIN model's turns carry each repo's own scope.
|
|
128
131
|
expect(block).toContain(`X-Agentdox-Scope: ${SCOPE_ENV}`);
|
|
132
|
+
// The origin names its own variable the same way, on every block.
|
|
133
|
+
expect(block).toContain(`X-Agentdox-Origin: ${ORIGIN_ENV}`);
|
|
129
134
|
expect(existingBlockScope(mergeModelsYml("", block))).toBe("");
|
|
130
135
|
// --scope pins one slug for the whole machine.
|
|
131
136
|
const pinned = renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router");
|
|
132
137
|
expect(pinned).toContain("X-Agentdox-Scope: omp-router");
|
|
133
138
|
expect(pinned).not.toContain(SCOPE_ENV);
|
|
134
139
|
expect(existingBlockScope(mergeModelsYml("", pinned))).toBe("omp-router");
|
|
140
|
+
// A pinned scope does not pin an origin: the repository is still the workspace's.
|
|
141
|
+
expect(pinned).toContain(`X-Agentdox-Origin: ${ORIGIN_ENV}`);
|
|
135
142
|
});
|
|
136
143
|
|
|
137
144
|
test("merging keeps other providers, replaces our own block, and is idempotent", () => {
|
package/test/scope.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { SCOPE_ENV, acceptScope, isScopeSlug } from "../src/context/scope.ts";
|
|
2
|
+
import { ORIGIN_ENV, SCOPE_ENV, acceptOrigin, acceptScope, isOrigin, isScopeSlug, normalizeOrigin } from "../src/context/scope.ts";
|
|
3
3
|
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
4
4
|
|
|
5
5
|
describe("the scope a request may name", () => {
|
|
@@ -49,3 +49,56 @@ describe("the scope a request may name", () => {
|
|
|
49
49
|
expect(parse({ "x-agentdox-group": "group.g1" }).agentdoxGroup).toBe("group.g1");
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("the origin a request may name", () => {
|
|
54
|
+
test("normalizeOrigin reduces every remote form to host/path, and a local path to nothing", () => {
|
|
55
|
+
// The same repository over https and ssh is one fingerprint.
|
|
56
|
+
expect(normalizeOrigin("https://github.com/DrewAppling/omp-router.git")).toBe("github.com/drewappling/omp-router");
|
|
57
|
+
expect(normalizeOrigin("git@github.com:drewappling/omp-router.git")).toBe("github.com/drewappling/omp-router");
|
|
58
|
+
expect(normalizeOrigin("ssh://git@gitlab.example.com:2222/team/api/")).toBe("gitlab.example.com/team/api");
|
|
59
|
+
// Credentials and the port never reach the value.
|
|
60
|
+
expect(normalizeOrigin("https://user:p4ss@host.example:8443/a/b.git")).toBe("host.example/a/b");
|
|
61
|
+
expect(normalizeOrigin("host.example:repo")).toBe("host.example/repo");
|
|
62
|
+
expect(normalizeOrigin(" https://github.com/a/b/.git ")).toBe("github.com/a/b");
|
|
63
|
+
// No host, no fingerprint: a local clone says nothing about the project.
|
|
64
|
+
expect(normalizeOrigin("file:///e/projects/x")).toBe("");
|
|
65
|
+
expect(normalizeOrigin("/local/path")).toBe("");
|
|
66
|
+
expect(normalizeOrigin("C:\\path")).toBe("");
|
|
67
|
+
expect(normalizeOrigin("C:\\Users\\drew\\repo")).toBe("");
|
|
68
|
+
expect(normalizeOrigin("C:/path")).toBe("");
|
|
69
|
+
expect(normalizeOrigin("https://github.com/")).toBe("");
|
|
70
|
+
expect(normalizeOrigin("")).toBe("");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("isOrigin: host/path only; the env-var sentinel never passes", () => {
|
|
74
|
+
expect(isOrigin("github.com/drewappling/omp-router")).toBe(true);
|
|
75
|
+
expect(isOrigin("gitlab.example.com/team/sub/api")).toBe(true);
|
|
76
|
+
// omp sends the header's literal value when the variable it names is
|
|
77
|
+
// unset: the NAME must never become a fingerprint.
|
|
78
|
+
expect(isOrigin(ORIGIN_ENV)).toBe(false);
|
|
79
|
+
expect(isOrigin("github.com")).toBe(false);
|
|
80
|
+
expect(isOrigin("GitHub.com/a/b")).toBe(false);
|
|
81
|
+
expect(isOrigin("https://github.com/a/b")).toBe(false);
|
|
82
|
+
expect(isOrigin("github.com/a/b/")).toBe(false);
|
|
83
|
+
expect(isOrigin("")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("acceptOrigin trims and rejects", () => {
|
|
87
|
+
expect(acceptOrigin(" github.com/a/b ")).toBe("github.com/a/b");
|
|
88
|
+
expect(acceptOrigin(ORIGIN_ENV)).toBe("");
|
|
89
|
+
expect(acceptOrigin("https://github.com/a/b")).toBe("");
|
|
90
|
+
expect(acceptOrigin(null)).toBe("");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the wire parser reads X-Agentdox-Origin by that rule and nothing else changes", () => {
|
|
94
|
+
const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
|
|
95
|
+
const parse = (h: Record<string, string>) => parseChatRequest(body, new Headers(h));
|
|
96
|
+
expect(parse({ "x-agentdox-scope": "omp-router" }).agentdoxOrigin).toBe("");
|
|
97
|
+
const both = parse({ "x-agentdox-scope": "omp-router", "x-agentdox-origin": "github.com/drewappling/omp-router" });
|
|
98
|
+
expect(both.agentdoxScope).toBe("omp-router");
|
|
99
|
+
expect(both.agentdoxOrigin).toBe("github.com/drewappling/omp-router");
|
|
100
|
+
// The sentinel and a raw URL are dropped, never passed through.
|
|
101
|
+
expect(parse({ "x-agentdox-origin": ORIGIN_ENV }).agentdoxOrigin).toBe("");
|
|
102
|
+
expect(parse({ "x-agentdox-origin": "https://github.com/a/b" }).agentdoxOrigin).toBe("");
|
|
103
|
+
});
|
|
104
|
+
});
|
package/test/turn.test.ts
CHANGED
|
@@ -98,6 +98,7 @@ function mkReq(): NormRequest {
|
|
|
98
98
|
agentdoxScope: "",
|
|
99
99
|
agentdoxGroup: "",
|
|
100
100
|
agentdoxPersonal: "",
|
|
101
|
+
agentdoxOrigin: "",
|
|
101
102
|
isSubagent: false,
|
|
102
103
|
requestedModel: "auto",
|
|
103
104
|
messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
|
package/tools/replay.ts
CHANGED