auto-model-router 0.2.20 → 0.2.21

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.2.20",
10
+ "version": "0.2.21",
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.2.20",
17
+ "version": "0.2.21",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Hot reload for `config.yml`.
3
+ *
4
+ * The router is long-lived (an omp session embeds it), and the ranking knobs —
5
+ * tiers, filters, escalation, budgets, hysteresis — are exactly what a tuning
6
+ * session wants to change without restarting the harness. This module watches
7
+ * the config file, re-validates it through the same schema `loadConfig` uses,
8
+ * and mutates the SHARED config object in place.
9
+ *
10
+ * In-place mutation is the design: every consumer reads `cfg.tiers`,
11
+ * `cfg.filters`, `cfg.escalation` … at call time through the same object
12
+ * reference, so field assignment makes every per-turn read live with zero
13
+ * call-site changes. What is deliberately NOT reloaded is anything captured at
14
+ * construction — the listening socket (server.*), the OpenRouter client
15
+ * (openrouter.*), and the agentdox bridge (context.*). Those still require a
16
+ * restart; the watcher re-pinns them from the live object and reports skips.
17
+ *
18
+ * Safety properties:
19
+ * - Schema validation BEFORE any mutation; an invalid file leaves the running
20
+ * config untouched and logs the zod issues, exactly like loadConfig.
21
+ * - fs.watch fires several times per save; a trailing debounce collapses them.
22
+ * - A half-written or invalid file never throws into the watcher: the reload
23
+ * is skipped and the previous config keeps serving.
24
+ * - A deleted or emptied knob reverts to its DEFAULT, mirroring loadConfig's
25
+ * merge order (defaults <- file): the file is the source of truth, so
26
+ * disabling a feature by deleting its key works.
27
+ */
28
+
29
+ import { existsSync, readFileSync, watch, type FSWatcher } from "node:fs";
30
+ import { parse as parseYaml } from "yaml";
31
+ import { configInputSchema } from "./schema.ts";
32
+ import { DEFAULT_CONFIG } from "./defaults.ts";
33
+ import { deepMerge, resolveTilde } from "./load.ts";
34
+ import type { RouterConfig } from "./types.ts";
35
+
36
+ /** Milliseconds of quiet after the last fs event before a reload actually runs. */
37
+ const DEBOUNCE_MS = 250;
38
+
39
+ /** The validated file input, or why it could not be used. */
40
+ export type ConfigRead =
41
+ | { ok: true; cfg: RouterConfig }
42
+ | { ok: false; error: string };
43
+
44
+ /**
45
+ * Re-reads and schema-validates the config file. Exported for tests: this is
46
+ * the exact gate a file must pass before it may touch the running config.
47
+ *
48
+ * The result is merged over DEFAULT_CONFIG — the file is the full source of
49
+ * truth, so a knob REMOVED from the file reverts to its default, matching what
50
+ * a restart would do. `server`/`openrouter`/`context` come back too, but the
51
+ * applier re-pinns those blocks from the live object, since they were captured
52
+ * by construction.
53
+ */
54
+ export function readValidatedConfig(path: string): ConfigRead {
55
+ try {
56
+ if (!existsSync(path)) return { ok: false, error: "file missing" };
57
+ const raw: unknown = parseYaml(readFileSync(path, "utf8"));
58
+ if (raw === null || raw === undefined) return { ok: false, error: "file empty" };
59
+ const parsed = configInputSchema.safeParse(raw);
60
+ if (!parsed.success) {
61
+ const lines = parsed.error.issues
62
+ .slice(0, 5)
63
+ .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`)
64
+ .join("\n");
65
+ return { ok: false, error: `schema validation failed:\n${lines}` };
66
+ }
67
+ return { ok: true, cfg: deepMerge(DEFAULT_CONFIG, parsed.data) };
68
+ } catch (err) {
69
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
70
+ }
71
+ }
72
+
73
+ /** What changed in a reload, for logging. */
74
+ export interface ReloadSummary {
75
+ changed: string[];
76
+ }
77
+
78
+ /** A live config file watcher. */
79
+ export interface ConfigWatcher {
80
+ /** Stops watching. Idempotent. */
81
+ close(): void;
82
+ }
83
+
84
+ /** Options for watchConfig. */
85
+ export interface WatchConfigOptions {
86
+ /** Called after a successful in-place reload that changed something. */
87
+ onReload?: (summary: ReloadSummary) => void;
88
+ /** Called when a file could not be applied (invalid, unreadable). */
89
+ onError?: (message: string) => void;
90
+ }
91
+
92
+ /**
93
+ * Watches `path` and applies valid changes to `live` in place. `frozen` blocks
94
+ * (top-level names) are re-copied from `pinned` after every reload so file
95
+ * edits to construction-captured blocks cannot silently diverge.
96
+ */
97
+ export function watchConfig(
98
+ path: string,
99
+ live: RouterConfig,
100
+ pinned: RouterConfig,
101
+ frozen: readonly (keyof RouterConfig)[],
102
+ opts: WatchConfigOptions = {},
103
+ ): ConfigWatcher {
104
+ let closed = false;
105
+ let timer: ReturnType<typeof setTimeout> | undefined;
106
+ let lastError = "";
107
+
108
+ const apply = (): void => {
109
+ if (closed) return;
110
+ const result = readValidatedConfig(path);
111
+ if (!result.ok) {
112
+ // A half-written file is normal (editors truncate-then-write): stay on
113
+ // the current config. Report each distinct error once.
114
+ if (result.error !== lastError) {
115
+ lastError = result.error;
116
+ opts.onError?.(result.error);
117
+ }
118
+ return;
119
+ }
120
+ lastError = "";
121
+
122
+ const frozenSet = new Set(frozen);
123
+ const changed: string[] = [];
124
+ const next = result.cfg as unknown as Record<string, unknown>;
125
+ for (const key of Object.keys(next)) {
126
+ // Frozen blocks belong to construction: keep the pinned values.
127
+ const value = frozenSet.has(key as keyof RouterConfig)
128
+ ? (pinned as unknown as Record<string, unknown>)[key]
129
+ : next[key];
130
+ const before = JSON.stringify((live as unknown as Record<string, unknown>)[key]);
131
+ const after = JSON.stringify(value);
132
+ if (before !== after) changed.push(key);
133
+ (live as unknown as Record<string, unknown>)[key] = value;
134
+ }
135
+ if (changed.length > 0) opts.onReload?.({ changed });
136
+ };
137
+
138
+ const schedule = (): void => {
139
+ clearTimeout(timer);
140
+ timer = setTimeout(() => {
141
+ timer = undefined;
142
+ apply();
143
+ }, DEBOUNCE_MS);
144
+ };
145
+
146
+ let watcher: FSWatcher | null = null;
147
+ try {
148
+ watcher = watch(resolveTilde(path), { persistent: false }, schedule);
149
+ } catch {
150
+ // Unwatchable file is not fatal: the router keeps its boot config.
151
+ }
152
+
153
+ return {
154
+ close() {
155
+ closed = true;
156
+ clearTimeout(timer);
157
+ timer = undefined;
158
+ watcher?.close();
159
+ watcher = null;
160
+ },
161
+ };
162
+ }
163
+
@@ -36,7 +36,7 @@ function mergeValue(base: unknown, override: unknown): unknown {
36
36
  return override;
37
37
  }
38
38
 
39
- function deepMerge(base: RouterConfig, override: unknown): RouterConfig {
39
+ export function deepMerge(base: RouterConfig, override: unknown): RouterConfig {
40
40
  return mergeValue(base, override) as RouterConfig;
41
41
  }
42
42
 
@@ -10,6 +10,8 @@ import { createConversationStore } from "../router/state.ts";
10
10
  import { createOpenRouterClient } from "../upstream/openrouter.ts";
11
11
  import { UpstreamError } from "../upstream/types.ts";
12
12
  import { apiKeySource } from "../config/load.ts";
13
+ import { routerConfigPath } from "../cli/config-cmd.ts";
14
+ import { watchConfig } from "../config/hot-reload.ts";
13
15
  import type { RouterConfig } from "../config/types.ts";
14
16
  import { createLogger } from "../util/log.ts";
15
17
  import { openDb } from "../util/sqlite.ts";
@@ -177,6 +179,27 @@ export function startServer(cfg: RouterConfig): StartedServer {
177
179
  const context = createBridgeFromConfig(cfg, db);
178
180
  const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context };
179
181
 
182
+ // Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
183
+ // effect on the next turn without a restart, because every consumer reads
184
+ // the shared config object at call time. Construction-captured blocks
185
+ // (server socket, OpenRouter client, agentdox bridge) are pinned — editing
186
+ // those still requires a restart, and the watcher says so explicitly.
187
+ const pinned = { ...cfg };
188
+ const configWatcher = watchConfig(
189
+ routerConfigPath(),
190
+ cfg,
191
+ pinned,
192
+ ["server", "openrouter", "context", "ledger"],
193
+ {
194
+ onReload: ({ changed }) => {
195
+ log.info("config reloaded", { changed: changed.join(", ") });
196
+ },
197
+ onError: (message) => {
198
+ log.warn("config reload rejected; keeping the running config", { error: message });
199
+ },
200
+ },
201
+ );
202
+
180
203
  if (context.enabled) {
181
204
  log.info("agentdox context bridge enabled", {
182
205
  url: cfg.context.baseUrl,
@@ -355,8 +378,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
355
378
  return {
356
379
  server,
357
380
  stop: async () => {
381
+ configWatcher.close();
358
382
  clearInterval(pruneTimer);
359
- clearInterval(catalogRefreshTimer);
360
383
  await server.stop(true);
361
384
  // Drain queued agentdox write-backs before the DB closes under them.
362
385
  context.close();
@@ -0,0 +1,131 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
5
+ import type { RouterConfig } from "../src/config/types.ts";
6
+ import { readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
7
+
8
+ const DIR = join(import.meta.dir, ".tmp-hot-reload");
9
+ const CFG = join(DIR, "config.yml");
10
+
11
+ beforeAll(() => {
12
+ rmSync(DIR, { recursive: true, force: true });
13
+ mkdirSync(DIR, { recursive: true });
14
+ });
15
+ afterAll(() => {
16
+ rmSync(DIR, { recursive: true, force: true });
17
+ });
18
+
19
+ /** A clone of the shipped defaults serialized as YAML via JSON (the schema accepts JSON). */
20
+ function yamlOf(partial: Record<string, unknown>): string {
21
+ const lines: string[] = [];
22
+ for (const [k, v] of Object.entries(partial)) {
23
+ if (typeof v === "object" && v !== null) {
24
+ lines.push(`${k}:`);
25
+ for (const [k2, v2] of Object.entries(v)) {
26
+ lines.push(` ${k2}: ${JSON.stringify(v2).replaceAll('"', v2 === true || v2 === false || typeof v2 === "number" ? "" : '"')}`);
27
+ }
28
+ } else {
29
+ lines.push(`${k}: ${JSON.stringify(v)}`);
30
+ }
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+
35
+ /** Waits out the watcher's debounce. */
36
+ const settle = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 400));
37
+
38
+ describe("readValidatedConfig", () => {
39
+ test("accepts a valid partial and merges over defaults (removed knobs revert)", () => {
40
+ writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 0.5 } }));
41
+ const result = readValidatedConfig(CFG);
42
+ expect(result.ok).toBe(true);
43
+ if (!result.ok) return;
44
+ expect(result.cfg.filters.latencyWeight).toBe(0.5);
45
+ // Untouched knobs carry the shipped default, not garbage.
46
+ expect(result.cfg.filters.contextHeadroom).toBe(DEFAULT_CONFIG.filters.contextHeadroom);
47
+ // A tier not mentioned in the file keeps its default shape.
48
+ expect(result.cfg.tiers.simple).toEqual(DEFAULT_CONFIG.tiers.simple);
49
+ });
50
+
51
+ test("rejects a schema violation and names the path", () => {
52
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: -5 } } }));
53
+ const result = readValidatedConfig(CFG);
54
+ expect(result.ok).toBe(false);
55
+ if (result.ok) return;
56
+ expect(result.error).toContain("capabilityFloorUsd");
57
+ });
58
+
59
+ test("rejects malformed YAML", () => {
60
+ writeFileSync(CFG, "filters: [unclosed");
61
+ const result = readValidatedConfig(CFG);
62
+ expect(result.ok).toBe(false);
63
+ });
64
+
65
+ test("reports a missing file", () => {
66
+ const result = readValidatedConfig(join(DIR, "nope.yml"));
67
+ expect(result.ok).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe("watchConfig", () => {
72
+ const live: RouterConfig = structuredClone(DEFAULT_CONFIG);
73
+ let watcher: ConfigWatcher | null = null;
74
+ const reloads: string[][] = [];
75
+ const errors: string[] = [];
76
+
77
+ beforeAll(() => {
78
+ writeFileSync(CFG, "");
79
+ watcher = watchConfig(CFG, live, structuredClone(DEFAULT_CONFIG), ["server", "openrouter", "context", "ledger"], {
80
+ onReload: ({ changed }) => reloads.push(changed),
81
+ onError: (message) => errors.push(message),
82
+ });
83
+ });
84
+ afterAll(() => watcher?.close());
85
+
86
+ test("a valid edit mutates the live object in place, no restart", async () => {
87
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: 0.35 } } }));
88
+ await settle();
89
+ expect(live.tiers.hard.capabilityFloorUsd).toBe(0.35);
90
+ expect(reloads.flat()).toContain("tiers");
91
+ });
92
+
93
+ test("a second edit replaces the value and reverting restores the default", async () => {
94
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: 0.65 } } }));
95
+ await settle();
96
+ expect(live.tiers.hard.capabilityFloorUsd).toBe(0.65);
97
+ // Deleting the knob reverts to the shipped default, mirroring a restart.
98
+ writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 0.4 } }));
99
+ await settle();
100
+ expect(live.tiers.hard.capabilityFloorUsd).toBeUndefined();
101
+ expect(live.filters.latencyWeight).toBe(0.4);
102
+ });
103
+
104
+ test("frozen blocks are pinned: file edits to them cannot reach the live object", async () => {
105
+ writeFileSync(CFG, yamlOf({ server: { port: 1, host: "10.9.9.9" }, filters: { latencyWeight: 0.3 } }));
106
+ await settle();
107
+ expect(live.server.port).toBe(DEFAULT_CONFIG.server.port);
108
+ expect(live.server.host).toBe(DEFAULT_CONFIG.server.host);
109
+ // The non-frozen sibling still applied.
110
+ expect(live.filters.latencyWeight).toBe(0.3);
111
+ });
112
+
113
+ test("an invalid file is rejected and the running config keeps serving", async () => {
114
+ const before = structuredClone(live.filters);
115
+ const tierBefore = structuredClone(live.tiers.hard);
116
+ // capabilityFloorUsd must be strictly positive: -1 is a schema violation.
117
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: -1 } } }));
118
+ await settle();
119
+ expect(errors.length).toBeGreaterThan(0);
120
+ expect(errors.at(-1)).toContain("capabilityFloorUsd");
121
+ // The live object keeps the last-good values.
122
+ expect(live.tiers.hard.capabilityFloorUsd).toBe(tierBefore.capabilityFloorUsd);
123
+ expect(live.filters.latencyWeight).toBe(before.latencyWeight);
124
+ });
125
+ test("close() stops watching: later edits are ignored", async () => {
126
+ watcher?.close();
127
+ writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 9.9 } }));
128
+ await settle();
129
+ expect(live.filters.latencyWeight).not.toBe(9.9);
130
+ });
131
+ });
@@ -2,14 +2,18 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import { joinBenchmarks, normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
4
  import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
5
- import { loadConfig } from "../src/config/load.ts";
5
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
6
6
  import { buildCandidates } from "../src/router/candidates.ts";
7
7
  import { extractFeatures } from "../src/router/features.ts";
8
8
  import { computeTierPlan, effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
9
9
  import { TIER_ORDER } from "../src/router/types.ts";
10
10
  import { parseChatRequest } from "../src/wire/openai/request.ts";
11
11
 
12
- const BASE = loadConfig({});
12
+ // SHIPPED defaults, deliberately NOT loadConfig({}): that reads the developer's
13
+ // live ~/.auto-model-router/config.yml, so an enabled machine-wide knob (e.g.
14
+ // tiers.hard.capabilityFloorUsd during the 0.2.20 rollout) silently changed
15
+ // these expectations and made the suite machine-dependent.
16
+ const BASE = DEFAULT_CONFIG;
13
17
 
14
18
  /** Raw `/models`-shaped record with a controllable coding score and price. */
15
19
  function raw(id: string, coding: number | null, inPerMtok: number): Record<string, unknown> {