auto-model-router 0.16.0 → 0.18.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.
@@ -5,7 +5,7 @@ description: "Use agentdox — the shared memory, docs, and context server — t
5
5
 
6
6
  # agentdox — the standard interaction protocol
7
7
 
8
- agentdox is a shared memory + docs + conversation store at `http://localhost:3003`. It is what
8
+ agentdox is a shared memory + docs + conversation store at `http://localhost:8790/agentdox`. It is what
9
9
  stops you rediscovering the same project facts every session.
10
10
 
11
11
  **Follow this protocol identically every time.** Consistency is the point: the value of the
@@ -55,7 +55,7 @@ Deterministic, so the same folder always resolves to the same slug:
55
55
  repo root:
56
56
 
57
57
  ```ini
58
- AGENTDOX_URL=http://localhost:3003
58
+ AGENTDOX_URL=http://localhost:8790/agentdox
59
59
  AGENTDOX_SCOPE=<slug>
60
60
  AGENTDOX_TOKEN=<the global PAT>
61
61
  ```
@@ -104,6 +104,16 @@ determine the scope, ask — do not guess, and do not fall back to a default.
104
104
 
105
105
  ## 1. Pick your transport — MCP or REST
106
106
 
107
+ > **Where the store lives (since 2026-09-10).** The store is the team edition's embedded agentdox,
108
+ > reached through the team: REST at `http://localhost:8790/agentdox/...` and MCP at
109
+ > `http://localhost:8790/mcp`, with a **team member key** (`amrt_…`) as `AGENTDOX_TOKEN`. The team maps
110
+ > the key to the member's grants (write on every project of their groups, read on the group's
111
+ > context), so a 401 means the key and a 403 means the project belongs to another group — not a PAT.
112
+ > agentdox's own `/auth` routes are not reachable through the team; projects are created on the
113
+ > team's Groups page or appear on a workspace's first turn. The old dev container on :3003 is retired.
114
+ > A member key updates entries (`PATCH /memory/:id`) but cannot delete them (agentdox wants admin on the
115
+ > scope for that): to retire a fact, lower its importance or leave it to the team's retention pass.
116
+
107
117
  Both hit the same live store with the same RBAC. **Check which you have, then use it:**
108
118
 
109
119
  - **MCP tools present** — use them. Claude Code and Cursor mount them as `memory_add`,
@@ -129,7 +139,7 @@ inline JSON and avoids quoting pain in `curl`:
129
139
  ```ts
130
140
  const tok = /AGENTDOX_TOKEN=(.+)/.exec(await Bun.file(".env.agentdox").text())?.[1]?.trim() ?? "";
131
141
  const H = { Authorization: `Bearer ${tok}`, "content-type": "application/json" };
132
- await fetch("http://localhost:3003/memory", { method: "POST", headers: H,
142
+ await fetch("http://localhost:8790/agentdox/memory", { method: "POST", headers: H,
133
143
  body: JSON.stringify({ content: "…", category: "<scope>", importance: 0.9 }) });
134
144
  ```
135
145
 
package/.mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "agentdox": {
4
4
  "type": "http",
5
- "url": "http://localhost:3003/mcp",
5
+ "url": "http://localhost:8790/mcp",
6
6
  "headers": {
7
7
  "Authorization": "Bearer ${AGENTDOX_TOKEN}"
8
8
  }
@@ -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.16.0",
10
+ "version": "0.18.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.16.0",
17
+ "version": "0.18.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/CLAUDE.md CHANGED
@@ -24,7 +24,7 @@ tips). Read it before using agentdox; do not improvise from this summary.
24
24
  | --- | --- |
25
25
  | Token, URL, scope | `.env.agentdox` in this repo root (gitignored — never commit) |
26
26
  | What `.mcp.json` reads | the `AGENTDOX_TOKEN` **environment variable** (Windows *User* scope; shells opened before it was set lack it) |
27
- | Server | `http://localhost:3003` — Docker container `agentdox-server`; endpoints in `E:/projects/agentdox/packages/server/src/index.ts` |
27
+ | Server | `http://localhost:8790/agentdox` — the team edition's embedded agentdox (the `agentdox-server` container is retired); endpoints in `E:/projects/agentdox/packages/server/src/index.ts` |
28
28
  | Admin token (re-mint) | `E:/projects/agentdox/deploy/.env` |
29
29
 
30
30
  Two rules that cause silent mistakes: **memory calls take `category`, everything else takes
@@ -46,7 +46,7 @@ behind them are recorded there as they are made.
46
46
  Turning the bridge on for the router itself (distinct from the MCP wiring above):
47
47
 
48
48
  ```bash
49
- export AGENTDOX_URL=http://localhost:3003
49
+ export AGENTDOX_URL=http://localhost:8790/agentdox
50
50
  export AGENTDOX_TOKEN=<the global PAT, same value .mcp.json uses>
51
51
  export AGENTDOX_SCOPE=omp-router
52
52
  ```
package/README.md CHANGED
@@ -716,6 +716,16 @@ escalation signal, error. Three views aggregate it, all from the same
716
716
  bill, escalation signal and verdicts (`?session=` narrows to one omp session, as `/router
717
717
  why` does). These are what a front door such as the team edition reads instead of the
718
718
  ledger file.
719
+ - `GET /v1/router/catalog[?policy=<X-Omp-Policy JSON>]` — the model catalog as data: every
720
+ model the router knows, sorted by slug, with provider (`openrouter`, `ollama`, a named
721
+ upstream's id), vendor, context, capabilities, prices in USD per million tokens and quality
722
+ scores; a model whose upstream cannot serve right now (no key, disabled, in cooldown) is
723
+ listed too. With `policy` every model also carries `admitted` and, when out, `reason`
724
+ (`not in the allow list`, `denied by <glob>`, `pinned to <slug>`, the router's own filters,
725
+ or the upstream's state), judged by the same matcher a turn uses, so the view never drifts
726
+ from what a turn gets; tiers are per turn and do not take part. A malformed policy is a
727
+ 400; before the first fetch it answers `{ "fetchedAtMs": 0, "models": [] }` rather than
728
+ waiting. A team front door reads it for its model governance views.
719
729
  - `GET /v1/router/report?days=7&harness=<id>` for dashboards (`harness` may be
720
730
  a comma-separated set of ids, for a group).
721
731
  - `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
@@ -1287,7 +1297,8 @@ request allow list replaces the configured one, a deny list adds to it.
1287
1297
  widen it. `pin` forces one model the way `/router pin` does, unless a session
1288
1298
  override already pinned one. Every field is optional; a malformed header is
1289
1299
  ignored rather than failing the turn. The decision trail records what the
1290
- policy changed (`policy: …`).
1300
+ policy changed (`policy: …`), and `GET /v1/router/catalog?policy=…` shows what a
1301
+ policy admits, model by model, without routing a turn.
1291
1302
 
1292
1303
  ## Using a remote router
1293
1304
 
@@ -1343,6 +1354,9 @@ the embed extension sets it from the workspace as it loads. If the variable is u
1343
1354
  sends the name itself, which the router does not accept as a scope (a scope is a
1344
1355
  lowercase slug) and falls back to its default. `--scope <slug>` pins one project for the
1345
1356
  whole machine instead; a refresh keeps a pin, and `connect --scope ""` removes it.
1357
+ `X-Agentdox-Origin` rides the same way under `AUTO_MODEL_ROUTER_ORIGIN`, set from the
1358
+ workspace's git remote; a pinned scope does not pin it, because the repository a turn
1359
+ came from is still the workspace's. See [the origin fingerprint](#the-origin-fingerprint).
1346
1360
 
1347
1361
  ### One file to install
1348
1362
 
@@ -1541,6 +1555,23 @@ a team posts exactly what it always did, so its block is byte-identical; an olde
1541
1555
  agentdox ignores the keys it does not know. `context.layers: false` is the kill
1542
1556
  switch — the headers are still parsed but nothing new goes to agentdox.
1543
1557
 
1558
+ ### The origin fingerprint
1559
+
1560
+ The scope is the folder's name, and folder names collide: two unrelated repositories
1561
+ checked out as `api` would share one project, and one repository cloned into `~/work/api`
1562
+ and `~/src/backend-api` would split into two. So the embed extension also sends
1563
+ `X-Agentdox-Origin`: the workspace's git remote `origin`, normalised to `<host>/<path>` —
1564
+ lowercase, no scheme, credentials, port, `.git` or trailing slash — so
1565
+ `https://github.com/DrewAppling/omp-router.git` and `git@github.com:drewappling/omp-router.git`
1566
+ are both `github.com/drewappling/omp-router`. It is read from `.git/config` (or through a
1567
+ worktree's `.git` file) with the filesystem alone, never by running git, and a workspace
1568
+ outside a repository or with a local-path remote sends none. Like the scope it reaches the
1569
+ main model through an environment variable, `AUTO_MODEL_ROUTER_ORIGIN`, that the managed
1570
+ `models.yml` entry names and the extension sets. A team edition uses it to find the
1571
+ project a workspace belongs to whatever the folder is called; a router on its own has no
1572
+ project registry and ignores it — the header is parsed and validated (a raw URL or the
1573
+ variable's name is dropped) and nothing else changes.
1574
+
1544
1575
  ### It does not cost you a cache miss per turn
1545
1576
 
1546
1577
  The context block sits at the front of the prompt, so re-fetching it every turn
@@ -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-scope` header parsing |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -46,7 +46,7 @@ export function createCompositeCatalog(
46
46
  ollama: OllamaCatalogSource,
47
47
  availability: OllamaAvailability,
48
48
  bias: CompositeBias = { costBias: 1, biasUntilUsage: 1, usage: NO_USAGE },
49
- ): CatalogSource & { ollamaModels(): CatalogModel[]; ollamaBias(): number } {
49
+ ): CatalogSource & { ollamaModels(): CatalogModel[]; ollamaBias(): number; peekAll(): CatalogSnapshot | null } {
50
50
  let lastBase: CatalogSnapshot | null = null;
51
51
  let lastOllama: readonly CatalogModel[] = [];
52
52
  let lastAvailable = true;
@@ -118,6 +118,17 @@ export function createCompositeCatalog(
118
118
  return combine(base, ollama.peek());
119
119
  },
120
120
  find,
121
+ /**
122
+ * Every model the catalog knows, whether or not its upstream can take a
123
+ * turn now: OpenRouter's without a key, Ollama's in cooldown, a named
124
+ * upstream's in cooldown. `peek()` is what routes; this is what a front
125
+ * door lists so it can say why a model is out. No network.
126
+ */
127
+ peekAll(): CatalogSnapshot | null {
128
+ const base = openrouter.peek();
129
+ if (base === null) return null;
130
+ return { ...base, models: [...base.models, ...ollama.peek(), ...(bias.named?.models(base.models) ?? NO_NAMED)] };
131
+ },
121
132
  lastShrink(): CatalogShrink | null {
122
133
  return openrouter.lastShrink?.() ?? null;
123
134
  },
@@ -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
  }
@@ -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(
@@ -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/lib.ts CHANGED
@@ -28,4 +28,5 @@ export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./
28
28
  export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
29
29
  export { parseSkillsBundle, type SkillsBundle } from "./cli/skills.ts";
30
30
  export type { RequestPolicy } from "./wire/types.ts";
31
+ export type { CatalogView, CatalogViewModel } from "./server/catalog-view.ts";
31
32
  export type { Ledger, LedgerEntry } from "./cost/types.ts";
@@ -48,12 +48,37 @@ export interface BuildCandidatesArgs {
48
48
  excludeSlugs?: readonly string[];
49
49
  }
50
50
 
51
- /** Tiny glob: `*` matches any run of characters; everything else is literal. */
52
- function globToRe(glob: string): RegExp {
51
+ /**
52
+ * Tiny glob: `*` matches any run of characters; everything else is literal.
53
+ * The one matcher behind `filters.allow`/`filters.deny` and a request
54
+ * policy's lists; the catalog view (`GET /v1/router/catalog`) judges with it
55
+ * too, so what a front door shows never drifts from what a turn gets.
56
+ */
57
+ export function globToRe(glob: string): RegExp {
53
58
  const escaped = glob.replace(/[.*+?^${}()|[\]\\]/g, (ch) => (ch === "*" ? ".*" : `\\${ch}`));
54
59
  return new RegExp(`^${escaped}$`);
55
60
  }
56
61
 
62
+ /**
63
+ * The denials that precede any user configuration, as the reason text, or
64
+ * null when the model may be a candidate. These slugs can never serve an
65
+ * interactive turn:
66
+ * - "~vendor/model-latest": floating aliases whose identity changes
67
+ * underneath us, poisoning ledger trust statistics.
68
+ * - ":batch": asynchronous batch endpoints, unusable for streaming.
69
+ * - "stealth/": cloaked models with no stable identity.
70
+ * - "openrouter/": their meta-routers do our job at unknown cost.
71
+ * - a negative price: OpenRouter's unknown/dynamic sentinel (-1), never a discount.
72
+ */
73
+ export function builtInDenial(model: CatalogModel): string | null {
74
+ const slug = model.slug;
75
+ if (slug.startsWith("~") || slug.endsWith(":batch") || slug.startsWith("stealth/") || model.author === "openrouter") {
76
+ return "built-in deny: floating alias, batch endpoint, stealth, or meta-router";
77
+ }
78
+ if (model.price.prompt < 0 || model.price.completion < 0) return "dynamic pricing sentinel";
79
+ return null;
80
+ }
81
+
57
82
  /**
58
83
  * Quality fallback chain: score the preferred axis first, then the general
59
84
  * intelligence index (the most widely published), then the remaining axis.
@@ -182,20 +207,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
182
207
  continue;
183
208
  }
184
209
 
185
- // Hard-coded denials, before any user configuration. These slugs can
186
- // never serve an interactive turn:
187
- // - "~vendor/model-latest": floating aliases whose identity changes
188
- // underneath us, poisoning ledger trust statistics.
189
- // - ":batch": asynchronous batch endpoints, unusable for streaming.
190
- // - "stealth/": cloaked models with no stable identity.
191
- // - "openrouter/": their meta-routers do our job at unknown cost.
192
- if (slug.startsWith("~") || slug.endsWith(":batch") || slug.startsWith("stealth/") || model.author === "openrouter") {
193
- rejected.push({ slug, reason: "denylisted", detail: "built-in deny: floating alias, batch endpoint, stealth, or meta-router" });
194
- continue;
195
- }
196
- // A negative price is OpenRouter's unknown/dynamic sentinel (-1), never a discount.
197
- if (model.price.prompt < 0 || model.price.completion < 0) {
198
- rejected.push({ slug, reason: "denylisted", detail: "dynamic pricing sentinel" });
210
+ // Hard-coded denials, before any user configuration.
211
+ const builtIn = builtInDenial(model);
212
+ if (builtIn !== null) {
213
+ rejected.push({ slug, reason: "denylisted", detail: builtIn });
199
214
  continue;
200
215
  }
201
216
  if (allowRes.length > 0 && !allowRes.some((re) => re.test(slug))) {
@@ -52,6 +52,7 @@ function requestOf(req: AdviseRequest): NormRequest {
52
52
  agentdoxScope: "",
53
53
  agentdoxGroup: "",
54
54
  agentdoxPersonal: "",
55
+ agentdoxOrigin: "",
55
56
  isSubagent: false,
56
57
  requestedModel: "auto",
57
58
  messages: [