auto-model-router 0.6.3 → 0.7.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.
@@ -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.6.3",
10
+ "version": "0.7.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.6.3",
17
+ "version": "0.7.0",
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.6.3",
3
+ "version": "0.7.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",
@@ -83,7 +83,8 @@ export function createCompositeCatalog(
83
83
  return {
84
84
  async get(): Promise<CatalogSnapshot> {
85
85
  const base = await openrouter.get();
86
- const models = await ollama.get(base.models);
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
- const root = ollamaApiRoot(cfg.baseUrl);
222
- const direct = isOllamaDotCom(cfg.baseUrl);
223
- const headers: Record<string, string> = {};
224
- if (cfg.apiKey !== "") headers.authorization = `Bearer ${cfg.apiKey}`;
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 }),
@@ -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
+ }
@@ -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
- const before = JSON.stringify((live as unknown as Record<string, unknown>)[key]);
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
 
@@ -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
- export function createBridgeFromConfig(cfg: RouterConfig, db: Database): ContextBridge {
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/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";
@@ -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 !== null) log.warn("no OpenRouter key: routing over Ollama Cloud models only (OpenRouter's catalog is read for metadata, never served)");
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 !== null) {
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 === null || meter === null ? null : { plan: meter.plan ?? null, usedUsd: meter.usedUsd, creditsUsd: meter.creditsUsd, runwayDays: runway?.days ?? null };
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"] : []), ...(ollama !== null && ollama.available() ? ["ollama"] : [])],
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 === null
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);
@@ -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, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
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
- /** Non-null when Ollama Cloud is enabled; carries the circuit breaker. */
25
- ollama: OllamaClient | null;
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
  };
@@ -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
- if (opts.apiKey === "" || opts.pollMs <= 0) return NO_USAGE;
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;
@@ -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
- const baseUrl = o.baseUrl.replace(/\/+$/, "");
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
  }
@@ -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.ollama.apiKey = "pinned-key";
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
- expect(live.ollama.apiKey).toBe("pinned-key");
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);
@@ -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: 50, timeoutMs: 1000, log, fetchImpl })).toBe(NO_USAGE);
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
+ });