auto-model-router 0.6.3 → 0.7.1
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 +41 -1
- package/package.json +1 -1
- package/src/catalog/composite.ts +3 -2
- package/src/catalog/ollama-catalog.ts +9 -8
- package/src/cli/connect.ts +115 -3
- package/src/config/apply.ts +66 -0
- package/src/config/hot-reload.ts +20 -19
- package/src/context/index.ts +43 -2
- package/src/index.ts +1 -1
- package/src/lib.ts +2 -1
- package/src/server/http.ts +83 -6
- package/src/server/providers.ts +13 -7
- package/src/server/turn.ts +13 -5
- package/src/upstream/ollama-usage.ts +7 -4
- package/src/upstream/ollama.ts +6 -4
- package/test/hot-reload.test.ts +4 -3
- package/test/ollama.test.ts +17 -3
- package/test/reconfigure.test.ts +122 -0
- package/test/remote.test.ts +45 -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.7.1",
|
|
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.7.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1162,6 +1162,35 @@ router handles for you: no `models[]` fallback cascade, no `tool_choice`,
|
|
|
1162
1162
|
`reasoning_effort` instead of the `reasoning` object, and no `cache_control`
|
|
1163
1163
|
markers (they are stripped before dispatch).
|
|
1164
1164
|
|
|
1165
|
+
## Changing the config while it runs
|
|
1166
|
+
|
|
1167
|
+
Ranking knobs have always hot-reloaded: every consumer reads the shared config
|
|
1168
|
+
object at call time, so editing `config.yml` changes the next turn. Since
|
|
1169
|
+
v0.7.0 that covers the settings that used to be captured at construction — the
|
|
1170
|
+
**OpenRouter key**, the whole **`ollama` block** (including turning it on or
|
|
1171
|
+
off), and the **agentdox bridge**. The clients read them per call, the catalogs
|
|
1172
|
+
re-fetch in the background, and the bridge is re-pointed in place. Only the
|
|
1173
|
+
bound socket (`server.*`) and the ledger file (`ledger.path`) still need a
|
|
1174
|
+
restart, because the process is built around them.
|
|
1175
|
+
|
|
1176
|
+
An embedder gets the same thing as a call. `startServer` returns
|
|
1177
|
+
`reconfigure(patch)`:
|
|
1178
|
+
|
|
1179
|
+
```ts
|
|
1180
|
+
const router = startServer(cfg);
|
|
1181
|
+
const { changed, rejected, catalogRefreshing } = await router.reconfigure({
|
|
1182
|
+
openrouter: { apiKey: "sk-or-…" },
|
|
1183
|
+
ollama: { enabled: true, apiKey: "…", baseUrl: "https://ollama.com/v1" },
|
|
1184
|
+
context: { enabled: true, baseUrl: "http://agentdox:3003", token: "…" },
|
|
1185
|
+
});
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
`changed` lists the dotted paths that actually moved, `rejected` the ones that
|
|
1189
|
+
need a restart, and `catalogRefreshing` says whether an upstream change started
|
|
1190
|
+
a catalog re-fetch. No socket closes and no turn in flight is cut: the config
|
|
1191
|
+
object keeps its identity and only its leaves are written, which is what lets a
|
|
1192
|
+
client that bound `cfg.ollama` at construction see the new key.
|
|
1193
|
+
|
|
1165
1194
|
## Harness-side model switch (experimental)
|
|
1166
1195
|
|
|
1167
1196
|
Most engineers reach Claude through a subscription, not an API key, and a
|
|
@@ -1267,10 +1296,21 @@ adds the Codex provider and the Aider settings, and prints (or with `--profile`
|
|
|
1267
1296
|
the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
|
|
1268
1297
|
shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
|
|
1269
1298
|
|
|
1299
|
+
`connect` also writes omp's `models.yml` (a managed block, other providers untouched, the
|
|
1300
|
+
previous file backed up). That entry is what makes `auto-model-router/auto` resolvable at
|
|
1301
|
+
**startup**: omp builds the main model's handle before extensions load, so without it only
|
|
1302
|
+
the late-resolved roles (`smol`, `tiny`) reach the router and the main turns fall back to
|
|
1303
|
+
whatever else is authenticated. A local router deliberately gets no such entry — its port
|
|
1304
|
+
is ephemeral, so a persisted one names a dead socket next launch — but a remote's URL and
|
|
1305
|
+
key are stable. **The file then holds the member key: treat it as a secret.**
|
|
1306
|
+
|
|
1270
1307
|
In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
|
|
1271
1308
|
remote router serves every repo on the machine with that repo's shared context. The remote
|
|
1272
1309
|
decides what to do with it: a team edition that pins a scope on the member's group
|
|
1273
|
-
overrides it, and one that pins none follows the workspace.
|
|
1310
|
+
overrides it, and one that pins none follows the workspace. That header rides on the roles
|
|
1311
|
+
the extensions register; the **main** model's handle comes from `models.yml`, which is
|
|
1312
|
+
machine-wide, so it carries a scope only if you pass `--scope <slug>` to `connect` — right
|
|
1313
|
+
for a single-project machine, wrong for one with several repos.
|
|
1274
1314
|
|
|
1275
1315
|
## Multiple coding harnesses, one router
|
|
1276
1316
|
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -83,7 +83,8 @@ export function createCompositeCatalog(
|
|
|
83
83
|
return {
|
|
84
84
|
async get(): Promise<CatalogSnapshot> {
|
|
85
85
|
const base = await openrouter.get();
|
|
86
|
-
|
|
86
|
+
// Nothing to fetch while Ollama cannot serve (off, or its breaker open).
|
|
87
|
+
const models = availability.available() ? await ollama.get(base.models) : ollama.peek();
|
|
87
88
|
// Refreshes on its own poll interval; a cached reading returns at once.
|
|
88
89
|
await bias.usage.get();
|
|
89
90
|
return combine(base, models);
|
|
@@ -91,7 +92,7 @@ export function createCompositeCatalog(
|
|
|
91
92
|
async refresh(): Promise<CatalogSnapshot> {
|
|
92
93
|
const base = await openrouter.refresh();
|
|
93
94
|
ollama.invalidate();
|
|
94
|
-
const models = await ollama.get(base.models);
|
|
95
|
+
const models = availability.available() ? await ollama.get(base.models) : ollama.peek();
|
|
95
96
|
await bias.usage.get();
|
|
96
97
|
return combine(base, models);
|
|
97
98
|
},
|
|
@@ -218,10 +218,11 @@ export function loadOllamaCatalogCache(db: Database): { models: CatalogModel[];
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: FetchLike = fetch, db?: Database): OllamaCatalogSource {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
221
|
+
// `cfg` is the live config block: resolve per call so a changed base URL or
|
|
222
|
+
// key applies without a restart.
|
|
223
|
+
const root = (): string => ollamaApiRoot(cfg.baseUrl);
|
|
224
|
+
const direct = (): boolean => isOllamaDotCom(cfg.baseUrl);
|
|
225
|
+
const headers = (): Record<string, string> => (cfg.apiKey === "" ? {} : { authorization: `Bearer ${cfg.apiKey}` });
|
|
225
226
|
// Hydrate from disk so a restart peeks a real set before the first listing;
|
|
226
227
|
// listedAtMs stays 0 so the first get() still refreshes.
|
|
227
228
|
let models: CatalogModel[] = db === undefined ? [] : loadOllamaCatalogCache(db).models;
|
|
@@ -237,23 +238,23 @@ export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: F
|
|
|
237
238
|
const shown = new Map<string, { contextLength: number | null; capabilities: string[] }>();
|
|
238
239
|
|
|
239
240
|
async function list(): Promise<OllamaListing[]> {
|
|
240
|
-
const res = await fetchImpl(`${root}/api/tags`, { headers, signal: AbortSignal.timeout(cfg.timeoutMs) });
|
|
241
|
+
const res = await fetchImpl(`${root()}/api/tags`, { headers: headers(), signal: AbortSignal.timeout(cfg.timeoutMs) });
|
|
241
242
|
if (!res.ok) throw new Error(`ollama /api/tags HTTP ${res.status}`);
|
|
242
243
|
const json = asRec(await res.json());
|
|
243
244
|
const raw = json !== null && Array.isArray(json.models) ? json.models : [];
|
|
244
245
|
const out: OllamaListing[] = [];
|
|
245
246
|
for (const r of raw) {
|
|
246
|
-
const l = parseOllamaListing(r, direct ? "ollama.com" : "daemon");
|
|
247
|
+
const l = parseOllamaListing(r, direct() ? "ollama.com" : "daemon");
|
|
247
248
|
if (l !== null) out.push(l);
|
|
248
249
|
}
|
|
249
250
|
// ollama.com's listing has no context/capabilities; ask per model, once.
|
|
250
|
-
if (direct) {
|
|
251
|
+
if (direct()) {
|
|
251
252
|
for (const l of out) {
|
|
252
253
|
if (l.contextLength !== null && l.capabilities.length > 0) continue;
|
|
253
254
|
let s = shown.get(l.id);
|
|
254
255
|
if (s === undefined) {
|
|
255
256
|
try {
|
|
256
|
-
const r = await fetchImpl(`${root}/api/show`, {
|
|
257
|
+
const r = await fetchImpl(`${root()}/api/show`, {
|
|
257
258
|
method: "POST",
|
|
258
259
|
headers: { ...headers, "content-type": "application/json" },
|
|
259
260
|
body: JSON.stringify({ model: l.id }),
|
package/src/cli/connect.ts
CHANGED
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* `<router home>/remote.json` (the omp extensions then run in remote mode and
|
|
5
5
|
* never bind a local router), and configures every harness it finds:
|
|
6
6
|
*
|
|
7
|
-
* omp the four extensions are added to ~/.omp/agent/config.yml
|
|
7
|
+
* omp the four extensions are added to ~/.omp/agent/config.yml, and
|
|
8
|
+
* the remote is written into ~/.omp/agent/models.yml so
|
|
9
|
+
* `auto-model-router/auto` resolves at STARTUP — omp builds the
|
|
10
|
+
* main model's handle before extensions load, so without that
|
|
11
|
+
* entry only the late-resolved roles (smol, tiny) reach the
|
|
12
|
+
* router and the main turns fall back to another provider
|
|
8
13
|
* Hermes the provider plugin and the native plugin are copied into
|
|
9
14
|
* $HERMES_HOME/plugins and .env points them at the remote
|
|
10
15
|
* Codex ~/.codex/config.toml gains the auto-model-router provider
|
|
@@ -37,6 +42,10 @@ export interface ConnectOptions {
|
|
|
37
42
|
home: string;
|
|
38
43
|
/** Where this package lives (the extensions are referenced from here). */
|
|
39
44
|
packageDir: string;
|
|
45
|
+
/** Cost figures omp shows for the remote's virtual models, USD per million tokens. */
|
|
46
|
+
blend?: { inputPerMtok: number; outputPerMtok: number };
|
|
47
|
+
/** Adds `X-Agentdox-Scope` to omp's models.yml entry. Machine-wide: only for a single-project machine. */
|
|
48
|
+
agentdoxScope?: string;
|
|
40
49
|
platform: string;
|
|
41
50
|
pathHas: (bin: string) => boolean;
|
|
42
51
|
}
|
|
@@ -103,6 +112,90 @@ http_headers = { "X-Omp-Harness" = "codex" }
|
|
|
103
112
|
`;
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
/** The models a remote router advertises, and what omp should believe they cost. */
|
|
116
|
+
const REMOTE_MODEL_ROWS: readonly { id: string; name: string }[] = [
|
|
117
|
+
{ id: "auto", name: "Auto (auto-model-router)" },
|
|
118
|
+
{ id: "auto-cheap", name: "Auto Cheap (auto-model-router)" },
|
|
119
|
+
{ id: "auto-max", name: "Auto Max (auto-model-router)" },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const MODELS_YML_BEGIN = " # BEGIN auto-model-router (remote)";
|
|
123
|
+
const MODELS_YML_END = " # END auto-model-router (remote)";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* omp's `models.yml` entry for a remote router.
|
|
127
|
+
*
|
|
128
|
+
* A LOCAL router deliberately never writes this file: its port is ephemeral, so
|
|
129
|
+
* a persisted entry names a dead socket on the next launch. A remote router has
|
|
130
|
+
* neither problem — the URL and the key are stable — and the entry is what makes
|
|
131
|
+
* omp's main model resolvable at startup, before extensions load.
|
|
132
|
+
*
|
|
133
|
+
* `scope` adds `X-Agentdox-Scope` to every request through this provider. It is
|
|
134
|
+
* off by default on purpose: the file is machine-wide, so a scope here would
|
|
135
|
+
* label turns from every workspace with one project. The extensions still send
|
|
136
|
+
* the workspace's own scope on the roles that resolve after they load.
|
|
137
|
+
*/
|
|
138
|
+
export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string {
|
|
139
|
+
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
140
|
+
const cost = {
|
|
141
|
+
input: round(blend.inputPerMtok),
|
|
142
|
+
output: round(blend.outputPerMtok),
|
|
143
|
+
cacheRead: round(blend.inputPerMtok * 0.1),
|
|
144
|
+
cacheWrite: round(blend.inputPerMtok * 1.25),
|
|
145
|
+
};
|
|
146
|
+
const lines = [
|
|
147
|
+
MODELS_YML_BEGIN,
|
|
148
|
+
" # Managed by `auto-model-router connect`. Remove this block to stop routing omp through the remote.",
|
|
149
|
+
" auto-model-router:",
|
|
150
|
+
` baseUrl: ${url.replace(/\/+$/, "")}/v1`,
|
|
151
|
+
" api: openai-completions",
|
|
152
|
+
` apiKey: ${key}`,
|
|
153
|
+
];
|
|
154
|
+
if (scope !== "") lines.push(" headers:", ` X-Agentdox-Scope: ${scope}`);
|
|
155
|
+
lines.push(" models:");
|
|
156
|
+
for (const m of REMOTE_MODEL_ROWS) {
|
|
157
|
+
lines.push(
|
|
158
|
+
` - id: ${m.id}`,
|
|
159
|
+
` name: ${m.name}`,
|
|
160
|
+
" contextWindow: 200000",
|
|
161
|
+
" maxTokens: 32000",
|
|
162
|
+
" input: [text, image]",
|
|
163
|
+
` cost: { input: ${cost.input}, output: ${cost.output}, cacheRead: ${cost.cacheRead}, cacheWrite: ${cost.cacheWrite} }`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
lines.push(MODELS_YML_END);
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Merges the remote block into an existing `models.yml`, replacing a previous
|
|
172
|
+
* one and leaving every other provider alone. Returns the new file text.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeModelsYml(before: string, blockText: string): string {
|
|
175
|
+
const eol = before.includes("\r\n") ? "\r\n" : "\n";
|
|
176
|
+
const body = before.replace(/^\uFEFF/, "");
|
|
177
|
+
const block = blockText.split("\n").join(eol);
|
|
178
|
+
const begin = body.indexOf(MODELS_YML_BEGIN);
|
|
179
|
+
if (begin >= 0) {
|
|
180
|
+
const endIdx = body.indexOf(MODELS_YML_END, begin);
|
|
181
|
+
const end = endIdx < 0 ? body.length : endIdx + MODELS_YML_END.length;
|
|
182
|
+
return `${body.slice(0, begin)}${block}${body.slice(end)}`;
|
|
183
|
+
}
|
|
184
|
+
// A provider entry for the same id from an earlier local install would shadow
|
|
185
|
+
// ours; the caller reports it rather than editing a block it does not own.
|
|
186
|
+
if (body.trim() === "") return `providers:${eol}${block}${eol}`;
|
|
187
|
+
if (/^providers:\s*$/m.test(body)) {
|
|
188
|
+
return body.replace(/^providers:\s*$/m, (m) => `${m}${eol}${block}`);
|
|
189
|
+
}
|
|
190
|
+
return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** True when the file already defines our provider outside a block we manage. */
|
|
194
|
+
export function hasForeignRouterProvider(text: string): boolean {
|
|
195
|
+
if (text.includes(MODELS_YML_BEGIN)) return false;
|
|
196
|
+
return /^\s{2,}auto-model-router:\s*$/m.test(text);
|
|
197
|
+
}
|
|
198
|
+
|
|
106
199
|
export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
107
200
|
const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [] };
|
|
108
201
|
const write = (path: string, content: string): void => {
|
|
@@ -124,7 +217,22 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
124
217
|
const before = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
125
218
|
const after = addExtensions(before, ext);
|
|
126
219
|
if (after !== before) write(cfgPath, after);
|
|
127
|
-
|
|
220
|
+
// models.yml: what makes the MAIN model resolvable, since omp builds that
|
|
221
|
+
// handle at startup, before the extensions register anything.
|
|
222
|
+
const modelsPath = join(agentDir, "models.yml");
|
|
223
|
+
const modelsBefore = existsSync(modelsPath) ? readFileSync(modelsPath, "utf8") : "";
|
|
224
|
+
if (hasForeignRouterProvider(modelsBefore)) {
|
|
225
|
+
report.notes.push(`${modelsPath} already defines an auto-model-router provider by hand; left alone — remove it to let connect manage the remote entry`);
|
|
226
|
+
report.configured.push(`omp (${cfgPath}; extensions only)`);
|
|
227
|
+
} else {
|
|
228
|
+
const modelsAfter = mergeModelsYml(modelsBefore, renderRemoteModelsYml(o.url, o.key, o.blend ?? { inputPerMtok: 1.1, outputPerMtok: 4.4 }, o.agentdoxScope ?? ""));
|
|
229
|
+
if (modelsAfter !== modelsBefore) {
|
|
230
|
+
// Never overwrite another provider's work without a way back.
|
|
231
|
+
if (modelsBefore !== "" && !o.dryRun) writeFileSync(`${modelsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, modelsBefore, "utf8");
|
|
232
|
+
write(modelsPath, modelsAfter);
|
|
233
|
+
}
|
|
234
|
+
report.configured.push(`omp (${cfgPath} + ${modelsPath}; auto-model-router/auto is ready to pick)`);
|
|
235
|
+
}
|
|
128
236
|
} else report.skipped.push("omp (no ~/.omp/agent)");
|
|
129
237
|
|
|
130
238
|
// 3. Hermes
|
|
@@ -183,6 +291,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
183
291
|
report.notes.push(`environment appended to ${rc}; open a new shell or source it`);
|
|
184
292
|
}
|
|
185
293
|
} else report.notes.push("add the environment lines to your shell profile, or re-run with --profile");
|
|
294
|
+
report.notes.push("omp's models.yml now carries the member key; treat that file as a secret");
|
|
186
295
|
return report;
|
|
187
296
|
}
|
|
188
297
|
|
|
@@ -205,7 +314,10 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
205
314
|
}
|
|
206
315
|
// HOME wins when set (Git Bash, WSL, CI) so a caller can redirect every write; the OS profile otherwise.
|
|
207
316
|
const home = process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir();
|
|
208
|
-
|
|
317
|
+
// A single-project machine can label every request; a machine with several
|
|
318
|
+
// repos should leave it off and let the extensions send the workspace's own.
|
|
319
|
+
const agentdoxScope = flagString(args, "scope") ?? "";
|
|
320
|
+
const report = connectRemote({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home, packageDir, platform: process.platform, pathHas, agentdoxScope });
|
|
209
321
|
console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
|
|
210
322
|
for (const c of report.configured) console.log(` configured ${c}`);
|
|
211
323
|
for (const s of report.skipped) console.log(` skipped ${s}`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applying config changes to a RUNNING router, in place.
|
|
3
|
+
*
|
|
4
|
+
* The rule that makes live reconfiguration possible: every block object keeps
|
|
5
|
+
* its identity. Consumers hold references into the config — the Ollama client
|
|
6
|
+
* binds `cfg.ollama`, the OpenRouter client reads `cfg.openrouter.apiKey` per
|
|
7
|
+
* request — so a change must be written INTO those objects, never by replacing
|
|
8
|
+
* them. Assigning `cfg.ollama = next` would leave every holder on the old
|
|
9
|
+
* object, which is exactly why those blocks used to be restart-only.
|
|
10
|
+
*
|
|
11
|
+
* Arrays are replaced wholesale: they are read through their parent
|
|
12
|
+
* (`cfg.filters.allow`) rather than captured, and element-wise patching would
|
|
13
|
+
* make a shorter list impossible to express.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { RouterConfig } from "./types.ts";
|
|
17
|
+
import type { DeepPartial } from "./load.ts";
|
|
18
|
+
|
|
19
|
+
type Rec = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
const isPlainObject = (v: unknown): v is Rec => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Writes `source` into `target`, keeping every existing object's identity, and
|
|
25
|
+
* returns the dotted paths whose value actually changed. Keys absent from
|
|
26
|
+
* `source` are left alone, so this works for both a full config (a file reload)
|
|
27
|
+
* and a patch (one dashboard setting).
|
|
28
|
+
*/
|
|
29
|
+
export function assignInPlace(target: Rec, source: Rec, prefix = "", opts: { prune?: boolean } = {}): string[] {
|
|
30
|
+
const changed: string[] = [];
|
|
31
|
+
// A whole-config apply (a file reload) must mirror a restart: a knob deleted
|
|
32
|
+
// from the file goes away rather than lingering at its last live value. A
|
|
33
|
+
// patch (one setting from a dashboard) touches only what it names.
|
|
34
|
+
if (opts.prune === true) {
|
|
35
|
+
for (const key of Object.keys(target)) {
|
|
36
|
+
if (key in source) continue;
|
|
37
|
+
delete target[key];
|
|
38
|
+
changed.push(prefix === "" ? key : `${prefix}.${key}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const key of Object.keys(source)) {
|
|
42
|
+
const path = prefix === "" ? key : `${prefix}.${key}`;
|
|
43
|
+
const next = source[key];
|
|
44
|
+
const current = target[key];
|
|
45
|
+
if (isPlainObject(next) && isPlainObject(current)) {
|
|
46
|
+
changed.push(...assignInPlace(current, next, path, opts));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (JSON.stringify(current) === JSON.stringify(next)) continue;
|
|
50
|
+
// A fresh object (or array) is cloned in, so the caller's patch cannot
|
|
51
|
+
// alias live config and mutate it from outside later.
|
|
52
|
+
target[key] = isPlainObject(next) || Array.isArray(next) ? structuredClone(next) : next;
|
|
53
|
+
changed.push(path);
|
|
54
|
+
}
|
|
55
|
+
return changed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `assignInPlace` over a typed config. Returns the dotted paths that changed. */
|
|
59
|
+
export function applyConfigPatch(live: RouterConfig, patch: DeepPartial<RouterConfig>): string[] {
|
|
60
|
+
return assignInPlace(live as unknown as Rec, patch as Rec);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when any changed path falls inside `block` (`"ollama"` matches `ollama.apiKey`). */
|
|
64
|
+
export function touched(changed: readonly string[], ...blocks: readonly string[]): boolean {
|
|
65
|
+
return changed.some((c) => blocks.some((b) => c === b || c.startsWith(`${b}.`)));
|
|
66
|
+
}
|
package/src/config/hot-reload.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { existsSync, readFileSync, watch, type FSWatcher } from "node:fs";
|
|
30
30
|
import { parse as parseYaml } from "yaml";
|
|
31
31
|
import { configInputSchema } from "./schema.ts";
|
|
32
|
+
import { assignInPlace } from "./apply.ts";
|
|
32
33
|
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
33
34
|
import { deepMerge, resolveTilde } from "./load.ts";
|
|
34
35
|
import type { RouterConfig } from "./types.ts";
|
|
@@ -97,26 +98,21 @@ export interface WatchConfigOptions {
|
|
|
97
98
|
* is read at call time and hot-reloads. A bare block name pins the whole
|
|
98
99
|
* block; `block.key` pins one key and lets its siblings through.
|
|
99
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* Config a RUNNING router cannot change, because the process is built around it:
|
|
103
|
+
* the bound socket and the ledger file it opened.
|
|
104
|
+
*
|
|
105
|
+
* Everything else now applies live. The upstream keys, the Ollama block and the
|
|
106
|
+
* agentdox bridge used to be pinned here because they were captured by
|
|
107
|
+
* construction; they are read (or re-pointed) at call time instead, and the
|
|
108
|
+
* server re-fetches the catalogs and rebuilds the bridge when they change.
|
|
109
|
+
*/
|
|
100
110
|
export const PINNED_CONFIG_PATHS: readonly string[] = [
|
|
101
111
|
"server.host",
|
|
102
112
|
"server.port",
|
|
103
113
|
"server.apiKey",
|
|
104
114
|
"server.harnessId",
|
|
105
115
|
"server.maxConcurrentTurns",
|
|
106
|
-
"openrouter",
|
|
107
|
-
"ollama.enabled",
|
|
108
|
-
"ollama.baseUrl",
|
|
109
|
-
"ollama.apiKey",
|
|
110
|
-
"ollama.timeoutMs",
|
|
111
|
-
"ollama.catalogTtlMs",
|
|
112
|
-
"ollama.includeLocal",
|
|
113
|
-
"ollama.prices",
|
|
114
|
-
"ollama.twins",
|
|
115
|
-
"ollama.usagePollMs",
|
|
116
|
-
"ollama.quotaCooldownMs",
|
|
117
|
-
"ollama.rateLimitCooldownMs",
|
|
118
|
-
"ollama.planCreditsUsd",
|
|
119
|
-
"context",
|
|
120
116
|
"ledger.path",
|
|
121
117
|
];
|
|
122
118
|
|
|
@@ -125,6 +121,11 @@ export const PINNED_CONFIG_PATHS: readonly string[] = [
|
|
|
125
121
|
* entries are re-copied from `pinned` after every reload so file edits to
|
|
126
122
|
* construction-captured settings cannot silently diverge: a top-level name
|
|
127
123
|
* pins the whole block, `block.key` pins one key of it.
|
|
124
|
+
*
|
|
125
|
+
* "In place" is load-bearing, not an optimisation: consumers hold references
|
|
126
|
+
* INTO the config (the Ollama client binds `cfg.ollama`), so blocks keep their
|
|
127
|
+
* identity and only leaves are written. Replacing a block would leave every
|
|
128
|
+
* holder on the old object — which is what made those blocks restart-only.
|
|
128
129
|
*/
|
|
129
130
|
export function watchConfig(
|
|
130
131
|
path: string,
|
|
@@ -159,8 +160,8 @@ export function watchConfig(
|
|
|
159
160
|
const block = f.slice(0, dot);
|
|
160
161
|
frozenKeys.set(block, [...(frozenKeys.get(block) ?? []), f.slice(dot + 1)]);
|
|
161
162
|
}
|
|
162
|
-
const changed: string[] = [];
|
|
163
163
|
const next = result.cfg as unknown as Record<string, unknown>;
|
|
164
|
+
const staged: Record<string, unknown> = {};
|
|
164
165
|
const pinnedRec = pinned as unknown as Record<string, unknown>;
|
|
165
166
|
for (const key of Object.keys(next)) {
|
|
166
167
|
// Frozen blocks belong to construction: keep the pinned values. A
|
|
@@ -176,11 +177,11 @@ export function watchConfig(
|
|
|
176
177
|
}
|
|
177
178
|
value = merged;
|
|
178
179
|
}
|
|
179
|
-
|
|
180
|
-
const after = JSON.stringify(value);
|
|
181
|
-
if (before !== after) changed.push(key);
|
|
182
|
-
(live as unknown as Record<string, unknown>)[key] = value;
|
|
180
|
+
staged[key] = value;
|
|
183
181
|
}
|
|
182
|
+
// One in-place pass over the whole config: block identity survives, and a
|
|
183
|
+
// knob deleted from the file reverts, exactly as a restart would leave it.
|
|
184
|
+
const changed = assignInPlace(live as unknown as Record<string, unknown>, staged, "", { prune: true });
|
|
184
185
|
if (changed.length > 0) opts.onReload?.({ changed });
|
|
185
186
|
};
|
|
186
187
|
|
package/src/context/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bridge factory. Returns the inert bridge unless agentdox is fully
|
|
3
|
-
* configured, so every call site can stay unconditional
|
|
3
|
+
* configured, so every call site can stay unconditional — and stays
|
|
4
|
+
* reconfigurable, so those settings can change while the router runs.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { Database } from "bun:sqlite";
|
|
@@ -17,7 +18,47 @@ export { createContextBridge, createDisabledBridge } from "./bridge.ts";
|
|
|
17
18
|
export { createContextStore } from "./store.ts";
|
|
18
19
|
export { createAgentDoxClient } from "./agentdox.ts";
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/** A bridge that can be pointed at different agentdox settings while the router runs. */
|
|
22
|
+
export interface ReloadableContextBridge extends ContextBridge {
|
|
23
|
+
/**
|
|
24
|
+
* Applies the config's current `context` block: the URL, token, limits or the
|
|
25
|
+
* enabled flag may all have changed. Queued write-backs are drained first, so
|
|
26
|
+
* nothing recorded against the old settings is lost.
|
|
27
|
+
*/
|
|
28
|
+
reconfigure(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bridge the server runs with. It follows `cfg.context` for the life of the
|
|
33
|
+
* process: `reconfigure()` rebuilds the inner bridge from whatever the config
|
|
34
|
+
* now says, including off→on and on→off, so agentdox settings never need a
|
|
35
|
+
* restart. The block store is the database, not the bridge, so a rebuild keeps
|
|
36
|
+
* every pinned block and session binding.
|
|
37
|
+
*/
|
|
38
|
+
export function createBridgeFromConfig(cfg: RouterConfig, db: Database): ReloadableContextBridge {
|
|
39
|
+
let inner = buildBridge(cfg, db);
|
|
40
|
+
return {
|
|
41
|
+
get enabled() {
|
|
42
|
+
return inner.enabled;
|
|
43
|
+
},
|
|
44
|
+
resolve: (input) => inner.resolve(input),
|
|
45
|
+
recordTurn: (rec) => {
|
|
46
|
+
inner.recordTurn(rec);
|
|
47
|
+
},
|
|
48
|
+
flush: () => inner.flush(),
|
|
49
|
+
pruneBlocks: (maxAgeMs) => inner.pruneBlocks(maxAgeMs),
|
|
50
|
+
close: () => {
|
|
51
|
+
inner.close();
|
|
52
|
+
},
|
|
53
|
+
async reconfigure() {
|
|
54
|
+
await inner.flush();
|
|
55
|
+
inner.close();
|
|
56
|
+
inner = buildBridge(cfg, db);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildBridge(cfg: RouterConfig, db: Database): ContextBridge {
|
|
21
62
|
const c = cfg.context;
|
|
22
63
|
if (!c.enabled || c.baseUrl === "" || c.token === "") return createDisabledBridge();
|
|
23
64
|
const log = createLogger(cfg.logLevel);
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ Usage: auto-model-router <command> [options]
|
|
|
26
26
|
stats Show routed spend, per-model share, and escalation rates
|
|
27
27
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
28
28
|
export One row per day, harness and model as CSV (--json for rows)
|
|
29
|
-
connect Point this machine at a remote router (--url, --key; --profile persists the environment)
|
|
29
|
+
connect Point this machine at a remote router (--url, --key; --scope labels a single-project machine; --profile persists the environment)
|
|
30
30
|
models Show what each complexity tier would consider, and why
|
|
31
31
|
explain Route a saved request without dispatching it, and explain the decision
|
|
32
32
|
config Interactive wizard over the router's own config.yml
|
package/src/lib.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* await router.stop();
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
export { startServer, type StartedServer } from "./server/http.ts";
|
|
15
|
+
export { startServer, type ReconfigureResult, type StartedServer } from "./server/http.ts";
|
|
16
16
|
export { loadConfig, apiKeySource } from "./config/load.ts";
|
|
17
17
|
export { DEFAULT_CONFIG } from "./config/defaults.ts";
|
|
18
18
|
export type { RouterConfig } from "./config/types.ts";
|
|
19
|
+
export type { DeepPartial } from "./config/load.ts";
|
|
19
20
|
export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
|
|
20
21
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
|
21
22
|
export { openDb } from "./util/sqlite.ts";
|
package/src/server/http.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { UpstreamError } from "../upstream/types.ts";
|
|
|
20
20
|
import { apiKeySource, ollamaKeySource } from "../config/load.ts";
|
|
21
21
|
import { ollamaMeter } from "../upstream/ollama-usage.ts";
|
|
22
22
|
import { routerConfigPath } from "../cli/config-cmd.ts";
|
|
23
|
+
import { applyConfigPatch, touched } from "../config/apply.ts";
|
|
24
|
+
import type { DeepPartial } from "../config/load.ts";
|
|
23
25
|
import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
|
|
24
26
|
import type { RouterConfig } from "../config/types.ts";
|
|
25
27
|
import { createLogger } from "../util/log.ts";
|
|
@@ -35,9 +37,33 @@ import { runTurn } from "./turn.ts";
|
|
|
35
37
|
export interface StartedServer {
|
|
36
38
|
// No websocket upgrade path, so the Server payload type is `undefined`.
|
|
37
39
|
server: Server<undefined>;
|
|
40
|
+
/**
|
|
41
|
+
* Applies a config change to the RUNNING router and reports the dotted paths
|
|
42
|
+
* that changed. Everything a turn reads through the config (tiers, filters,
|
|
43
|
+
* budgets, provider keys) takes effect on the next turn; the pieces built
|
|
44
|
+
* from config — the agentdox bridge, the catalogs — are re-pointed here. No
|
|
45
|
+
* socket closes and no turn in flight is cut.
|
|
46
|
+
*
|
|
47
|
+
* `server.*` and `ledger.path` are the exceptions: the listener and the
|
|
48
|
+
* database file are the process. Changing those still means a restart, and
|
|
49
|
+
* they are rejected rather than half-applied.
|
|
50
|
+
*/
|
|
51
|
+
reconfigure(patch: DeepPartial<RouterConfig>): Promise<ReconfigureResult>;
|
|
38
52
|
stop(): Promise<void>;
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
export interface ReconfigureResult {
|
|
56
|
+
/** Dotted config paths whose value changed. */
|
|
57
|
+
changed: string[];
|
|
58
|
+
/** Paths that were refused because they belong to construction (`server.*`, `ledger.path`). */
|
|
59
|
+
rejected: string[];
|
|
60
|
+
/** True when an upstream changed and a catalog re-fetch was started in the background. */
|
|
61
|
+
catalogRefreshing: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Config a running router cannot change: the bound socket and the ledger file. */
|
|
65
|
+
const RESTART_ONLY_PATHS: readonly string[] = ["server", "ledger.path"];
|
|
66
|
+
|
|
41
67
|
export interface ModelSpendRow {
|
|
42
68
|
slug: string;
|
|
43
69
|
requests: number;
|
|
@@ -200,7 +226,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
200
226
|
if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
201
227
|
const db = openDb(cfg.ledger.path);
|
|
202
228
|
const ledger = createLedger(db, cfg);
|
|
203
|
-
const { upstream, catalog, ollama, ollamaUsage, ollamaCostScale } = createProviders(cfg, db, log);
|
|
229
|
+
const { upstream, catalog, ollama, ollamaServing, ollamaUsage, ollamaCostScale } = createProviders(cfg, db, log);
|
|
204
230
|
const conversations = createConversationStore(db);
|
|
205
231
|
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
206
232
|
const context = createBridgeFromConfig(cfg, db);
|
|
@@ -225,6 +251,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
225
251
|
{
|
|
226
252
|
onReload: ({ changed }) => {
|
|
227
253
|
log.info("config reloaded", { changed: changed.join(", ") });
|
|
254
|
+
// The file is a config change like any other: same live application.
|
|
255
|
+
void applyLive(changed).catch((err: unknown) => log.warn("applying the reloaded config failed", { error: err instanceof Error ? err.message : String(err) }));
|
|
228
256
|
},
|
|
229
257
|
onError: (message) => {
|
|
230
258
|
log.warn("config reload rejected; keeping the running config", { error: message });
|
|
@@ -241,10 +269,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
241
269
|
}
|
|
242
270
|
|
|
243
271
|
if (cfg.openrouter.apiKey === "") {
|
|
244
|
-
if (ollama
|
|
272
|
+
if (cfg.ollama.enabled) log.warn("no OpenRouter key: routing over Ollama Cloud models only (OpenRouter's catalog is read for metadata, never served)");
|
|
245
273
|
else log.warn("OPENROUTER_API_KEY is not set and Ollama is off; /v1/chat/completions will fail at dispatch time");
|
|
246
274
|
}
|
|
247
|
-
if (ollama
|
|
275
|
+
if (cfg.ollama.enabled) {
|
|
248
276
|
log.info("ollama cloud upstream enabled", {
|
|
249
277
|
baseUrl: cfg.ollama.baseUrl,
|
|
250
278
|
// Provenance only; never the key itself.
|
|
@@ -509,7 +537,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
509
537
|
const meter = ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd);
|
|
510
538
|
const runway = ollamaRunway(meter, ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1);
|
|
511
539
|
const ollamaSummary: SummaryOllama | null =
|
|
512
|
-
ollama
|
|
540
|
+
!cfg.ollama.enabled || meter === null ? null : { plan: meter.plan ?? null, usedUsd: meter.usedUsd, creditsUsd: meter.creditsUsd, runwayDays: runway?.days ?? null };
|
|
513
541
|
const summary = buildDailySummary(db, {
|
|
514
542
|
harnessId,
|
|
515
543
|
baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)),
|
|
@@ -611,7 +639,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
611
639
|
apiKeyConfigured: cfg.openrouter.apiKey !== "",
|
|
612
640
|
// Which upstreams turns can actually be served from: OpenRouter needs
|
|
613
641
|
// its key; Ollama needs to be on and out of cooldown.
|
|
614
|
-
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(
|
|
642
|
+
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(ollamaServing() ? ["ollama"] : [])],
|
|
615
643
|
// Provenance only; never the key itself.
|
|
616
644
|
apiKeySource: apiKeySource(cfg).source,
|
|
617
645
|
// Provenance only; never the agentdox token itself.
|
|
@@ -621,7 +649,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
621
649
|
// Never the key. `available` is the circuit breaker: false while a
|
|
622
650
|
// 402/429 cooldown routes every turn around Ollama.
|
|
623
651
|
ollama:
|
|
624
|
-
ollama
|
|
652
|
+
!cfg.ollama.enabled
|
|
625
653
|
? null
|
|
626
654
|
: {
|
|
627
655
|
baseUrl: cfg.ollama.baseUrl,
|
|
@@ -665,8 +693,57 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
665
693
|
},
|
|
666
694
|
});
|
|
667
695
|
|
|
696
|
+
/**
|
|
697
|
+
* The one place that turns a config change into a live router. The watcher
|
|
698
|
+
* (file edits) and `reconfigure` (an embedder, e.g. the team edition) both
|
|
699
|
+
* come through here, so the two can never drift apart.
|
|
700
|
+
*/
|
|
701
|
+
async function applyLive(changed: readonly string[]): Promise<boolean> {
|
|
702
|
+
if (changed.length === 0) return false;
|
|
703
|
+
if (touched(changed, "context")) {
|
|
704
|
+
await context.reconfigure();
|
|
705
|
+
log.info("agentdox bridge reconfigured", {
|
|
706
|
+
enabled: context.enabled,
|
|
707
|
+
url: cfg.context.baseUrl === "" ? "(none)" : cfg.context.baseUrl,
|
|
708
|
+
defaultScope: cfg.context.defaultScope === "" ? "(per-request header only)" : cfg.context.defaultScope,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
if (!touched(changed, "openrouter", "ollama")) return false;
|
|
712
|
+
// A key change makes the catalog key-scoped (or not), and enabling Ollama adds
|
|
713
|
+
// its models: the snapshot is rebuilt before the next turn ranks. Started, not
|
|
714
|
+
// awaited — the caller is a settings save, not a network client, and the
|
|
715
|
+
// previous snapshot serves turns until the new one lands.
|
|
716
|
+
void catalog
|
|
717
|
+
.refresh()
|
|
718
|
+
.then((snap) => log.info("catalog refreshed after an upstream change", { models: snap.models.length, ollama: cfg.ollama.enabled }))
|
|
719
|
+
.catch((err: unknown) => log.warn("catalog refresh after an upstream change failed; the previous snapshot stands", { error: err instanceof Error ? err.message : String(err) }));
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
|
|
668
723
|
return {
|
|
669
724
|
server,
|
|
725
|
+
async reconfigure(patch) {
|
|
726
|
+
const rejected: string[] = [];
|
|
727
|
+
const rec = patch as Record<string, unknown>;
|
|
728
|
+
for (const block of RESTART_ONLY_PATHS) {
|
|
729
|
+
const [head, key] = block.split(".") as [string, string | undefined];
|
|
730
|
+
const value = rec[head];
|
|
731
|
+
if (value === undefined) continue;
|
|
732
|
+
if (key === undefined) rejected.push(head);
|
|
733
|
+
else if ((value as Record<string, unknown>)[key] !== undefined) rejected.push(block);
|
|
734
|
+
}
|
|
735
|
+
const safe = structuredClone(rec);
|
|
736
|
+
for (const block of rejected) {
|
|
737
|
+
const [head, key] = block.split(".") as [string, string | undefined];
|
|
738
|
+
if (key === undefined) delete safe[head];
|
|
739
|
+
else delete (safe[head] as Record<string, unknown>)[key];
|
|
740
|
+
}
|
|
741
|
+
const changed = applyConfigPatch(cfg, safe as DeepPartial<RouterConfig>);
|
|
742
|
+
const catalogRefreshing = await applyLive(changed);
|
|
743
|
+
if (changed.length > 0) log.info("config reconfigured", { changed: changed.join(", ") });
|
|
744
|
+
if (rejected.length > 0) log.warn("config change needs a restart; not applied", { paths: rejected.join(", ") });
|
|
745
|
+
return { changed, rejected, catalogRefreshing };
|
|
746
|
+
},
|
|
670
747
|
stop: async () => {
|
|
671
748
|
configWatcher.close();
|
|
672
749
|
clearInterval(pruneTimer);
|
package/src/server/providers.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { RouterConfig } from "../config/types.ts";
|
|
|
13
13
|
import { createMultiUpstream } from "../upstream/multi.ts";
|
|
14
14
|
import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
15
15
|
import { createLedger } from "../cost/ledger.ts";
|
|
16
|
-
import { createOllamaUsageSource,
|
|
16
|
+
import { createOllamaUsageSource, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
17
17
|
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
18
18
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
19
19
|
import { createLogger, type Logger } from "../util/log.ts";
|
|
@@ -21,8 +21,10 @@ import { createLogger, type Logger } from "../util/log.ts";
|
|
|
21
21
|
export interface Providers {
|
|
22
22
|
upstream: UpstreamClient;
|
|
23
23
|
catalog: CatalogSource & { ollamaModels?(): unknown[]; ollamaBias?(): number };
|
|
24
|
-
/**
|
|
25
|
-
ollama: OllamaClient
|
|
24
|
+
/** Always present: it carries the circuit breaker. Whether it SERVES follows `cfg.ollama.enabled`. */
|
|
25
|
+
ollama: OllamaClient;
|
|
26
|
+
/** True while Ollama Cloud is enabled and out of cooldown, read live. */
|
|
27
|
+
ollamaServing(): boolean;
|
|
26
28
|
/** Plan usage reader; inert without a key. */
|
|
27
29
|
ollamaUsage: OllamaUsageSource;
|
|
28
30
|
/** Multiplier that brings the ledger's Ollama estimate in line with the plan meter; 1 until calibrated. */
|
|
@@ -32,15 +34,18 @@ export interface Providers {
|
|
|
32
34
|
export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
|
|
33
35
|
const openrouter = createOpenRouterClient(cfg);
|
|
34
36
|
const openrouterCatalog = createCatalog(cfg, openrouter, db);
|
|
35
|
-
if (!cfg.ollama.enabled) return { upstream: openrouter, catalog: openrouterCatalog, ollama: null, ollamaUsage: NO_USAGE, ollamaCostScale: () => 1 };
|
|
36
37
|
// Ollama Cloud is a second upstream ranked in the same catalog: `ollama/…`
|
|
37
|
-
// slugs dispatch to it, everything else to OpenRouter.
|
|
38
|
+
// slugs dispatch to it, everything else to OpenRouter. It is always built, and
|
|
39
|
+
// `ollama.enabled` decides per call whether it serves — so turning it on or off
|
|
40
|
+
// in a running router is a config change, not a restart. Nothing is fetched
|
|
41
|
+
// from it while it is off.
|
|
38
42
|
const ollama = createOllamaClient(cfg);
|
|
43
|
+
const ollamaServing = (): boolean => cfg.ollama.enabled && ollama.available();
|
|
39
44
|
// Plan usage lives on ollama.com whichever base URL dispatches; it needs the
|
|
40
45
|
// key, so the daemon path without `/login ollama-cloud` keeps a static bias.
|
|
41
46
|
const ledgerForCalibration = createLedger(db, cfg);
|
|
42
47
|
const ollamaUsage = createOllamaUsageSource({
|
|
43
|
-
apiKey: cfg.ollama.apiKey,
|
|
48
|
+
apiKey: () => cfg.ollama.apiKey,
|
|
44
49
|
pollMs: cfg.ollama.usagePollMs,
|
|
45
50
|
timeoutMs: Math.min(cfg.ollama.timeoutMs, 15_000),
|
|
46
51
|
log,
|
|
@@ -50,7 +55,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
50
55
|
});
|
|
51
56
|
return {
|
|
52
57
|
upstream: createMultiUpstream(openrouter, ollama),
|
|
53
|
-
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log, fetch, db), ollama, {
|
|
58
|
+
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log, fetch, db), { available: ollamaServing, cooldownUntilMs: () => ollama.cooldownUntilMs(), lastTrip: () => ollama.lastTrip() }, {
|
|
54
59
|
costBias: cfg.ollama.costBias,
|
|
55
60
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
56
61
|
usage: ollamaUsage,
|
|
@@ -58,6 +63,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
58
63
|
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
59
64
|
}),
|
|
60
65
|
ollama,
|
|
66
|
+
ollamaServing,
|
|
61
67
|
ollamaUsage,
|
|
62
68
|
ollamaCostScale: () => ollamaUsage.calibration()?.factor ?? 1,
|
|
63
69
|
};
|
package/src/server/turn.ts
CHANGED
|
@@ -93,13 +93,21 @@ function lastUserText(req: NormRequest): string {
|
|
|
93
93
|
return "";
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Session title: the conversation's opening ask, truncated.
|
|
98
|
+
*
|
|
99
|
+
* Read through `userContent`, the same filter the recorded user message uses:
|
|
100
|
+
* a harness wraps the first message in reminders (omp opens with a
|
|
101
|
+
* `<system-reminder>` naming the date and cwd), and titling a session with that
|
|
102
|
+
* wrapper both reads as noise in agentdox and is re-injected into later context
|
|
103
|
+
* assembly, which lists session titles.
|
|
104
|
+
*/
|
|
97
105
|
function sessionTitle(req: NormRequest): string {
|
|
98
106
|
for (const m of req.messages) {
|
|
99
|
-
if (m.role
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
107
|
+
if (m.role !== "user") continue;
|
|
108
|
+
const t = userContent(m.text);
|
|
109
|
+
if (t === "") continue;
|
|
110
|
+
return t.length > 80 ? `${t.slice(0, 79)}…` : t;
|
|
103
111
|
}
|
|
104
112
|
return `omp ${req.conversationKey.slice(0, 8)}`;
|
|
105
113
|
}
|
|
@@ -166,9 +166,11 @@ export interface CalibrationDeps {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
export function createOllamaUsageSource(
|
|
169
|
-
opts: { apiKey: string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string; calibration?: CalibrationDeps },
|
|
169
|
+
opts: { apiKey: () => string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string; calibration?: CalibrationDeps },
|
|
170
170
|
): OllamaUsageSource {
|
|
171
|
-
|
|
171
|
+
// Only the poll interval is structural. A key that is empty now may be set from
|
|
172
|
+
// the dashboard later, so the reader stays live and simply idles until it is.
|
|
173
|
+
if (opts.pollMs <= 0) return NO_USAGE;
|
|
172
174
|
const cal = opts.calibration;
|
|
173
175
|
const insertSample = cal === undefined ? null : cal.db.query("INSERT OR REPLACE INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, ?, ?)");
|
|
174
176
|
const readSamples = cal === undefined ? null : cal.db.query("SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= ? ORDER BY at_ms ASC");
|
|
@@ -199,7 +201,7 @@ export function createOllamaUsageSource(
|
|
|
199
201
|
try {
|
|
200
202
|
const res = await fetchImpl(`${root}/api/me`, {
|
|
201
203
|
method: "POST",
|
|
202
|
-
headers: { authorization: `Bearer ${opts.apiKey}` },
|
|
204
|
+
headers: { authorization: `Bearer ${opts.apiKey()}` },
|
|
203
205
|
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
204
206
|
});
|
|
205
207
|
if (res.ok) {
|
|
@@ -222,7 +224,7 @@ export function createOllamaUsageSource(
|
|
|
222
224
|
await refreshPlan();
|
|
223
225
|
try {
|
|
224
226
|
const res = await fetchImpl(`${root}/api/usage`, {
|
|
225
|
-
headers: { authorization: `Bearer ${opts.apiKey}` },
|
|
227
|
+
headers: { authorization: `Bearer ${opts.apiKey()}` },
|
|
226
228
|
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
227
229
|
});
|
|
228
230
|
if (res.ok) {
|
|
@@ -253,6 +255,7 @@ export function createOllamaUsageSource(
|
|
|
253
255
|
|
|
254
256
|
return {
|
|
255
257
|
async get() {
|
|
258
|
+
if (opts.apiKey() === "") return null;
|
|
256
259
|
if (Date.now() - checkedAtMs < opts.pollMs) return current;
|
|
257
260
|
inflight ??= refresh().finally(() => {
|
|
258
261
|
inflight = null;
|
package/src/upstream/ollama.ts
CHANGED
|
@@ -129,7 +129,9 @@ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
|
|
|
129
129
|
|
|
130
130
|
export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fetch): OllamaClient {
|
|
131
131
|
const o = cfg.ollama;
|
|
132
|
-
|
|
132
|
+
// Read per call, not captured: `o` is the live config block, so a base URL
|
|
133
|
+
// changed while the router runs takes effect on the next dispatch.
|
|
134
|
+
const baseUrl = (): string => o.baseUrl.replace(/\/+$/, "");
|
|
133
135
|
const log = createLogger(cfg.logLevel);
|
|
134
136
|
let cooldownUntil = 0;
|
|
135
137
|
let lastTrip: { kind: UpstreamErrorKind; atMs: number; message: string } | null = null;
|
|
@@ -189,7 +191,7 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
|
|
|
189
191
|
const body = toOllamaBody({ ...opts.body, stream: true });
|
|
190
192
|
let res: Response;
|
|
191
193
|
try {
|
|
192
|
-
res = await fetchImpl(`${baseUrl}/chat/completions`, {
|
|
194
|
+
res = await fetchImpl(`${baseUrl()}/chat/completions`, {
|
|
193
195
|
method: "POST",
|
|
194
196
|
headers: headers(),
|
|
195
197
|
body: JSON.stringify(body),
|
|
@@ -237,7 +239,7 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
|
|
|
237
239
|
async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
|
|
238
240
|
let res: Response;
|
|
239
241
|
try {
|
|
240
|
-
res = await fetchImpl(`${baseUrl}/chat/completions`, {
|
|
242
|
+
res = await fetchImpl(`${baseUrl()}/chat/completions`, {
|
|
241
243
|
method: "POST",
|
|
242
244
|
headers: headers(),
|
|
243
245
|
body: JSON.stringify(toOllamaBody({ ...body, stream: false })),
|
|
@@ -258,7 +260,7 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
|
|
|
258
260
|
async fetchModels(signal?: AbortSignal): Promise<unknown[]> {
|
|
259
261
|
let res: Response;
|
|
260
262
|
try {
|
|
261
|
-
res = await fetchImpl(`${baseUrl}/models`, { headers: headers(), signal: composeSignal(signal) });
|
|
263
|
+
res = await fetchImpl(`${baseUrl()}/models`, { headers: headers(), signal: composeSignal(signal) });
|
|
262
264
|
} catch (err) {
|
|
263
265
|
throw transportError(err);
|
|
264
266
|
}
|
package/test/hot-reload.test.ts
CHANGED
|
@@ -87,7 +87,7 @@ describe("watchConfig", () => {
|
|
|
87
87
|
writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: 0.35 } } }));
|
|
88
88
|
await settle();
|
|
89
89
|
expect(live.tiers.hard.capabilityFloorUsd).toBe(0.35);
|
|
90
|
-
expect(reloads.flat()).toContain("tiers");
|
|
90
|
+
expect(reloads.flat()).toContain("tiers.hard.capabilityFloorUsd");
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
test("a second edit replaces the value and reverting restores the default", async () => {
|
|
@@ -138,7 +138,7 @@ describe("watchConfig pins by path", () => {
|
|
|
138
138
|
beforeAll(() => {
|
|
139
139
|
writeFileSync(CFG2, "");
|
|
140
140
|
const pinned = structuredClone(DEFAULT_CONFIG);
|
|
141
|
-
pinned.
|
|
141
|
+
pinned.server.port = 8788;
|
|
142
142
|
watcher = watchConfig(CFG2, live, pinned, PINNED_CONFIG_PATHS);
|
|
143
143
|
});
|
|
144
144
|
afterAll(() => watcher?.close());
|
|
@@ -148,7 +148,8 @@ describe("watchConfig pins by path", () => {
|
|
|
148
148
|
await settle();
|
|
149
149
|
expect(live.ollama.costBias).toBe(0.25);
|
|
150
150
|
expect(live.ollama.biasUntilUsage).toBe(0.5);
|
|
151
|
-
|
|
151
|
+
// The upstream key is no longer pinned: the router re-points its clients instead.
|
|
152
|
+
expect(live.ollama.apiKey).toBe("from-file");
|
|
152
153
|
expect(live.server.port).toBe(DEFAULT_CONFIG.server.port);
|
|
153
154
|
expect(live.server.subagentProfile).toBe("auto");
|
|
154
155
|
expect(live.ledger.retentionDays).toBe(30);
|
package/test/ollama.test.ts
CHANGED
|
@@ -521,7 +521,7 @@ describe("ollama plan usage (credit-aware bias)", () => {
|
|
|
521
521
|
if (fail) return new Response("down", { status: 503 });
|
|
522
522
|
return Response.json({ ...PAYLOAD, limits: { monthly: { usage: 42, models: [] } } });
|
|
523
523
|
};
|
|
524
|
-
const src = createOllamaUsageSource({ apiKey: "k", pollMs: 20, timeoutMs: 1000, log, fetchImpl });
|
|
524
|
+
const src = createOllamaUsageSource({ apiKey: () => "k", pollMs: 20, timeoutMs: 1000, log, fetchImpl });
|
|
525
525
|
expect(src.peek()).toBeNull();
|
|
526
526
|
expect((await src.get())?.monthlyUsedFraction).toBeCloseTo(0.42, 6);
|
|
527
527
|
expect(src.peek()?.plan).toBe("pro");
|
|
@@ -531,7 +531,21 @@ describe("ollama plan usage (credit-aware bias)", () => {
|
|
|
531
531
|
await new Promise((r) => setTimeout(r, 120)); // well past the 20ms poll interval
|
|
532
532
|
expect((await src.get())?.monthlyUsedFraction).toBeCloseTo(0.42, 6); // last good reading survives a 503
|
|
533
533
|
expect(calls).toBe(2);
|
|
534
|
-
expect(createOllamaUsageSource({ apiKey: "", pollMs:
|
|
534
|
+
expect(createOllamaUsageSource({ apiKey: () => "k", pollMs: 0, timeoutMs: 1000, log, fetchImpl })).toBe(NO_USAGE);
|
|
535
|
+
// A key that is empty NOW is not structural: the reader idles and starts polling once one is set,
|
|
536
|
+
// which is what lets a key added from a dashboard work without restarting the router.
|
|
537
|
+
let liveKey = "";
|
|
538
|
+
let polls = 0;
|
|
539
|
+
const laterFetch = async (url: string): Promise<Response> => {
|
|
540
|
+
polls++;
|
|
541
|
+
if (url === "https://ollama.com/api/me") return Response.json({ ID: "x", Email: "e", Plan: "Pro" });
|
|
542
|
+
return Response.json({ ...PAYLOAD, limits: { monthly: { usage: 42, models: [] } } });
|
|
543
|
+
};
|
|
544
|
+
const later = createOllamaUsageSource({ apiKey: () => liveKey, pollMs: 1, timeoutMs: 1000, log, fetchImpl: laterFetch });
|
|
545
|
+
expect(await later.get()).toBeNull();
|
|
546
|
+
expect(polls).toBe(0); // no key, no network
|
|
547
|
+
liveKey = "k";
|
|
548
|
+
expect((await later.get())?.monthlyUsedFraction).toBeCloseTo(0.42, 5);
|
|
535
549
|
});
|
|
536
550
|
|
|
537
551
|
test("the composite snapshot carries the live bias and re-merges when it flips", async () => {
|
|
@@ -623,7 +637,7 @@ describe("ollama calibration", () => {
|
|
|
623
637
|
if (url.endsWith("/api/me")) return Response.json({ Plan: "pro" });
|
|
624
638
|
return Response.json({ limits: { monthly: { usage: frac, models: [] } } });
|
|
625
639
|
};
|
|
626
|
-
const src = createOllamaUsageSource({ apiKey: "k", pollMs: 5, timeoutMs: 1000, log, fetchImpl, calibration: { db, ledgerUsd: () => ledgerUsd, planCreditsOverrideUsd: 0 } });
|
|
640
|
+
const src = createOllamaUsageSource({ apiKey: () => "k", pollMs: 5, timeoutMs: 1000, log, fetchImpl, calibration: { db, ledgerUsd: () => ledgerUsd, planCreditsOverrideUsd: 0 } });
|
|
627
641
|
await src.get(); // meter $6 (10% of $60), ledger $1
|
|
628
642
|
expect(src.calibration()).toBeNull(); // one sample
|
|
629
643
|
await new Promise((r) => setTimeout(r, 20));
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyConfigPatch, assignInPlace, touched } from "../src/config/apply.ts";
|
|
3
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
4
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
5
|
+
import { startServer } from "../src/server/http.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Live reconfiguration: a running router follows a config change without a
|
|
9
|
+
* restart. The socket stays bound, turns in flight are untouched, and the
|
|
10
|
+
* pieces that used to be captured at construction (upstream clients, the
|
|
11
|
+
* catalogs, the agentdox bridge) are re-pointed instead.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe("applying config in place", () => {
|
|
15
|
+
test("blocks keep their identity, only leaves change, and the changed paths come back dotted", () => {
|
|
16
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
17
|
+
const ollamaRef = cfg.ollama; // what a client binds at construction
|
|
18
|
+
const changed = applyConfigPatch(cfg, { ollama: { enabled: true, apiKey: "k" } });
|
|
19
|
+
expect(changed.sort()).toEqual(["ollama.apiKey", "ollama.enabled"]);
|
|
20
|
+
expect(cfg.ollama).toBe(ollamaRef); // the holder sees the new values
|
|
21
|
+
expect(ollamaRef.apiKey).toBe("k");
|
|
22
|
+
// An unchanged value is not reported, so nothing rebuilds for nothing.
|
|
23
|
+
expect(applyConfigPatch(cfg, { ollama: { apiKey: "k" } })).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a patch touches only what it names; a full apply prunes what the source dropped", () => {
|
|
27
|
+
const target: Record<string, unknown> = { a: { x: 1, y: 2 }, b: 3 };
|
|
28
|
+
expect(assignInPlace(target, { a: { x: 9 } })).toEqual(["a.x"]);
|
|
29
|
+
expect(target).toEqual({ a: { x: 9, y: 2 }, b: 3 });
|
|
30
|
+
expect(assignInPlace(target, { a: { x: 9 } }, "", { prune: true }).sort()).toEqual(["a.y", "b"]);
|
|
31
|
+
expect(target).toEqual({ a: { x: 9 } });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a patch cannot alias live config", () => {
|
|
35
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
36
|
+
const patch = { filters: { allow: ["a/b"] } };
|
|
37
|
+
applyConfigPatch(cfg, patch);
|
|
38
|
+
patch.filters.allow.push("c/d");
|
|
39
|
+
expect(cfg.filters.allow).toEqual(["a/b"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("touched matches a block and its keys", () => {
|
|
43
|
+
expect(touched(["ollama.apiKey"], "ollama")).toBe(true);
|
|
44
|
+
expect(touched(["context"], "context")).toBe(true);
|
|
45
|
+
expect(touched(["filters.allow"], "openrouter", "ollama")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("a running server reconfigures", () => {
|
|
50
|
+
const base = (): RouterConfig => ({
|
|
51
|
+
...structuredClone(DEFAULT_CONFIG),
|
|
52
|
+
server: { ...DEFAULT_CONFIG.server, host: "127.0.0.1", port: 0, apiKey: "rk" },
|
|
53
|
+
ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
|
|
54
|
+
// No network at boot: an empty key keeps the catalog fetch keyless and cheap,
|
|
55
|
+
// and this test never dispatches a turn.
|
|
56
|
+
openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "" },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("provider keys, Ollama and the agentdox bridge all change while the socket stays bound", async () => {
|
|
60
|
+
const cfg = base();
|
|
61
|
+
const started = startServer(cfg);
|
|
62
|
+
const port = started.server.port;
|
|
63
|
+
const H = { authorization: "Bearer rk" };
|
|
64
|
+
try {
|
|
65
|
+
const health1 = (await (await fetch(`http://127.0.0.1:${port}/health`, { headers: H })).json()) as { apiKeyConfigured: boolean; serving: string[]; agentdox: unknown; ollama: unknown };
|
|
66
|
+
expect(health1.apiKeyConfigured).toBe(false);
|
|
67
|
+
expect(health1.serving).toEqual([]);
|
|
68
|
+
expect(health1.agentdox).toBeNull();
|
|
69
|
+
expect(health1.ollama).toBeNull();
|
|
70
|
+
|
|
71
|
+
// One call turns on both upstreams and the bridge.
|
|
72
|
+
const r = await started.reconfigure({
|
|
73
|
+
openrouter: { apiKey: "sk-or-live" },
|
|
74
|
+
ollama: { enabled: true, apiKey: "ol-live", baseUrl: "https://ollama.com/v1" },
|
|
75
|
+
context: { enabled: true, baseUrl: "http://127.0.0.1:1/never", token: "t", defaultScope: "demo" },
|
|
76
|
+
});
|
|
77
|
+
expect(r.rejected).toEqual([]);
|
|
78
|
+
expect(r.catalogRefreshing).toBe(true); // started in the background, not awaited
|
|
79
|
+
expect(r.changed).toContain("openrouter.apiKey");
|
|
80
|
+
expect(r.changed).toContain("ollama.enabled");
|
|
81
|
+
expect(r.changed).toContain("context.enabled");
|
|
82
|
+
expect(cfg.openrouter.apiKey).toBe("sk-or-live");
|
|
83
|
+
|
|
84
|
+
const health2 = (await (await fetch(`http://127.0.0.1:${port}/health`, { headers: H })).json()) as { apiKeyConfigured: boolean; serving: string[]; agentdox: { url: string; defaultScope: string } | null; ollama: { baseUrl: string } | null };
|
|
85
|
+
expect(started.server.port).toBe(port); // same socket, never rebound
|
|
86
|
+
expect(health2.apiKeyConfigured).toBe(true);
|
|
87
|
+
expect(health2.serving).toContain("openrouter");
|
|
88
|
+
expect(health2.serving).toContain("ollama");
|
|
89
|
+
expect(health2.agentdox).toMatchObject({ url: "http://127.0.0.1:1/never", defaultScope: "demo" });
|
|
90
|
+
expect(health2.ollama).toMatchObject({ baseUrl: "https://ollama.com/v1" });
|
|
91
|
+
|
|
92
|
+
// And back off again: the bridge goes inert and Ollama stops serving.
|
|
93
|
+
const off = await started.reconfigure({ ollama: { enabled: false }, context: { enabled: false } });
|
|
94
|
+
expect(off.changed.sort()).toEqual(["context.enabled", "ollama.enabled"]);
|
|
95
|
+
const health3 = (await (await fetch(`http://127.0.0.1:${port}/health`, { headers: H })).json()) as { serving: string[]; agentdox: unknown; ollama: unknown };
|
|
96
|
+
expect(health3.serving).toEqual(["openrouter"]);
|
|
97
|
+
expect(health3.agentdox).toBeNull();
|
|
98
|
+
expect(health3.ollama).toBeNull();
|
|
99
|
+
} finally {
|
|
100
|
+
await started.stop();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the socket and the ledger file are refused rather than half-applied", async () => {
|
|
105
|
+
const cfg = base();
|
|
106
|
+
const started = startServer(cfg);
|
|
107
|
+
const port = started.server.port;
|
|
108
|
+
try {
|
|
109
|
+
const r = await started.reconfigure({ server: { port: 1 }, ledger: { path: "/tmp/other.db", retentionDays: 9 }, filters: { latencyWeight: 0.42 } });
|
|
110
|
+
expect(r.rejected.sort()).toEqual(["ledger.path", "server"]);
|
|
111
|
+
expect(cfg.server.port).toBe(0);
|
|
112
|
+
expect(cfg.ledger.path).toBe(":memory:");
|
|
113
|
+
expect(started.server.port).toBe(port);
|
|
114
|
+
// Everything else in the same call still applied.
|
|
115
|
+
expect(cfg.filters.latencyWeight).toBe(0.42);
|
|
116
|
+
expect(cfg.ledger.retentionDays).toBe(9);
|
|
117
|
+
expect(r.changed).toContain("filters.latencyWeight");
|
|
118
|
+
} finally {
|
|
119
|
+
await started.stop();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
package/test/remote.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
|
|
7
7
|
import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
|
|
8
|
+
import { hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Remote mode: remote.json puts the omp extensions on a router elsewhere,
|
|
@@ -102,3 +103,47 @@ describe("connect", () => {
|
|
|
102
103
|
rmSync(h2, { recursive: true, force: true });
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
|
|
107
|
+
describe("omp models.yml for a remote router", () => {
|
|
108
|
+
const BLEND = { inputPerMtok: 1.1, outputPerMtok: 4.4 };
|
|
109
|
+
const NL = String.fromCharCode(10);
|
|
110
|
+
const yaml = (...lines: string[]): string => lines.join(NL) + NL;
|
|
111
|
+
|
|
112
|
+
test("the block names the remote, the key and the three virtual models; a scope is opt-in", () => {
|
|
113
|
+
const block = renderRemoteModelsYml("https://team.example/", "amrt_k", BLEND);
|
|
114
|
+
expect(block).toContain("baseUrl: https://team.example/v1");
|
|
115
|
+
expect(block).toContain("apiKey: amrt_k");
|
|
116
|
+
expect(block).toContain("- id: auto");
|
|
117
|
+
expect(block).toContain("- id: auto-cheap");
|
|
118
|
+
expect(block).toContain("- id: auto-max");
|
|
119
|
+
expect(block).toContain("cost: { input: 1.1, output: 4.4, cacheRead: 0.11, cacheWrite: 1.375 }");
|
|
120
|
+
// Machine-wide file: no scope unless the caller asks for one.
|
|
121
|
+
expect(block).not.toContain("X-Agentdox-Scope");
|
|
122
|
+
expect(renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router")).toContain("X-Agentdox-Scope: omp-router");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("merging keeps other providers, replaces our own block, and is idempotent", () => {
|
|
126
|
+
const block = renderRemoteModelsYml("https://team.example", "k1", BLEND);
|
|
127
|
+
const empty = mergeModelsYml("", block);
|
|
128
|
+
expect(empty.startsWith("providers:")).toBe(true);
|
|
129
|
+
expect(mergeModelsYml(empty, block)).toBe(empty);
|
|
130
|
+
|
|
131
|
+
const existing = yaml("providers:", " openai:", " apiKey: sk-x");
|
|
132
|
+
const merged = mergeModelsYml(existing, block);
|
|
133
|
+
expect(merged).toContain("openai:");
|
|
134
|
+
expect(merged).toContain("baseUrl: https://team.example/v1");
|
|
135
|
+
|
|
136
|
+
// A later connect with a new key replaces the block in place, not a second copy.
|
|
137
|
+
const rekeyed = mergeModelsYml(merged, renderRemoteModelsYml("https://team.example", "k2", BLEND));
|
|
138
|
+
expect(rekeyed).toContain("apiKey: k2");
|
|
139
|
+
expect(rekeyed).not.toContain("apiKey: k1");
|
|
140
|
+
expect(rekeyed.match(/auto-model-router:/g)).toHaveLength(1);
|
|
141
|
+
expect(rekeyed).toContain("openai:");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a hand-written provider of the same name is left alone", () => {
|
|
145
|
+
expect(hasForeignRouterProvider(yaml("providers:", " auto-model-router:", " baseUrl: http://127.0.0.1:1/v1"))).toBe(true);
|
|
146
|
+
expect(hasForeignRouterProvider(mergeModelsYml("", renderRemoteModelsYml("https://t", "k", BLEND)))).toBe(false);
|
|
147
|
+
expect(hasForeignRouterProvider(yaml("providers:", " openai: {}"))).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|