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.
- package/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/config/hot-reload.ts +163 -0
- package/src/config/load.ts +1 -1
- package/src/server/http.ts +24 -1
- package/test/hot-reload.test.ts +131 -0
- package/test/tier-plan.test.ts +6 -2
|
@@ -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.
|
|
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.
|
|
17
|
+
"version": "0.2.21",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
|
@@ -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
|
+
|
package/src/config/load.ts
CHANGED
|
@@ -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
|
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/tier-plan.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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> {
|