auto-model-router 0.2.16 → 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/README.md +12 -6
- package/docs/routing-benchmark-findings.md +346 -0
- package/omp-extension/embed-logic.ts +21 -0
- package/omp-extension/router-embed.ts +32 -5
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +39 -0
- package/src/config/defaults.ts +6 -0
- package/src/config/hot-reload.ts +163 -0
- package/src/config/load.ts +1 -1
- package/src/config/schema.ts +3 -0
- package/src/config/types.ts +36 -0
- package/src/context/agentdox.ts +6 -1
- package/src/context/bridge.ts +4 -2
- package/src/context/index.ts +1 -0
- package/src/cost/types.ts +6 -1
- package/src/router/candidates.ts +67 -1
- package/src/server/http.ts +33 -3
- package/src/wire/openai/sink.ts +15 -2
- package/test/context-bridge.test.ts +5 -3
- package/test/failover.test.ts +1 -1
- package/test/hot-reload.test.ts +131 -0
- package/test/http-resilience.test.ts +58 -0
- package/test/tier-plan.test.ts +97 -2
- package/test/turn.test.ts +1 -1
- package/test/wire-sink.test.ts +31 -0
- package/tools/replay.ts +82 -29
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
3
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
4
|
+
import { startServer, type StartedServer } from "../src/server/http.ts";
|
|
5
|
+
|
|
6
|
+
describe("HTTP server resilience against dead streams", () => {
|
|
7
|
+
let handle: StartedServer;
|
|
8
|
+
let baseUrl: string;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
const cfg: RouterConfig = {
|
|
12
|
+
...DEFAULT_CONFIG,
|
|
13
|
+
server: { host: "127.0.0.1", port: 0 },
|
|
14
|
+
ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
|
|
15
|
+
context: { ...DEFAULT_CONFIG.context, enabled: false },
|
|
16
|
+
logLevel: "silent",
|
|
17
|
+
};
|
|
18
|
+
handle = startServer(cfg);
|
|
19
|
+
baseUrl = `http://127.0.0.1:${handle.server.port}`;
|
|
20
|
+
});
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
await handle.stop();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("server answers /v1/models cleanly initially", async () => {
|
|
26
|
+
const res = await fetch(`${baseUrl}/v1/models`);
|
|
27
|
+
expect(res.status).toBe(200);
|
|
28
|
+
const json = (await res.json()) as { data: unknown[] };
|
|
29
|
+
expect(Array.isArray(json.data)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("cancelling a streaming /v1/chat/completions client does not crash the server", async () => {
|
|
33
|
+
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
model: "auto",
|
|
38
|
+
stream: true,
|
|
39
|
+
messages: [{ role: "user", content: "hello" }],
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Immediately cancel the reader mid-stream (simulates client disconnect)
|
|
44
|
+
const reader = res.body?.getReader();
|
|
45
|
+
expect(reader).toBeDefined();
|
|
46
|
+
await reader?.cancel("client abruptly dropped");
|
|
47
|
+
|
|
48
|
+
// Allow microtasks and I/O ticks to settle without real wall-clock delays
|
|
49
|
+
await new Promise<void>((resolve) => {
|
|
50
|
+
setImmediate(() => resolve());
|
|
51
|
+
});
|
|
52
|
+
// and answers subsequent requests cleanly.
|
|
53
|
+
const modelsRes = await fetch(`${baseUrl}/v1/models`);
|
|
54
|
+
expect(modelsRes.status).toBe(200);
|
|
55
|
+
const json = (await modelsRes.json()) as { data: unknown[] };
|
|
56
|
+
expect(Array.isArray(json.data)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
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> {
|
|
@@ -354,3 +358,94 @@ describe("adaptive price ceilings", () => {
|
|
|
354
358
|
expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
|
|
355
359
|
});
|
|
356
360
|
});
|
|
361
|
+
|
|
362
|
+
describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
|
|
363
|
+
const req = parseChatRequest(
|
|
364
|
+
{
|
|
365
|
+
model: "auto",
|
|
366
|
+
tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }],
|
|
367
|
+
messages: [{ role: "user", content: "implement nested transaction savepoints" }],
|
|
368
|
+
},
|
|
369
|
+
new Headers(),
|
|
370
|
+
);
|
|
371
|
+
const features = extractFeatures(req, 100);
|
|
372
|
+
|
|
373
|
+
// The real catalog's shape: quality in a narrow band, price spanning ~100x.
|
|
374
|
+
// `hard` has a quality floor of 72, so cheap/1 is deliberately below it —
|
|
375
|
+
// it must be rejected, and mid/2 is the cheapest ELIGIBLE model, the one
|
|
376
|
+
// raw quality-per-dollar ranking picks at any sane exponent.
|
|
377
|
+
const spread = snapshot(
|
|
378
|
+
models([
|
|
379
|
+
["cheap/1", 70, 0.05],
|
|
380
|
+
["mid/2", 74, 1.0],
|
|
381
|
+
["good/3", 76, 3.0],
|
|
382
|
+
["best/4", 78, 5.0],
|
|
383
|
+
]),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const run = (tierOverride: Partial<(typeof BASE)["tiers"]["hard"]>) =>
|
|
387
|
+
buildCandidates({
|
|
388
|
+
req,
|
|
389
|
+
features,
|
|
390
|
+
tier: "hard",
|
|
391
|
+
task: "coding",
|
|
392
|
+
snapshot: spread,
|
|
393
|
+
ledger: null,
|
|
394
|
+
cfg: { ...BASE, tiers: { ...BASE.tiers, hard: { ...BASE.tiers.hard, ...tierOverride } } },
|
|
395
|
+
expectedCompletionTokens: 512,
|
|
396
|
+
warmSlug: null,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("raw scoring at the shipped exponent picks the cheapest ELIGIBLE model", () => {
|
|
400
|
+
const { candidates, rejected } = run({ qualityExponent: 3 });
|
|
401
|
+
expect(candidates[0]?.model.slug).toBe("mid/2");
|
|
402
|
+
// cheap/1 is under the hard floor of 72 and never competes.
|
|
403
|
+
expect(rejected.some((r) => r.slug === "cheap/1" && r.reason === "below_quality_floor")).toBe(true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("normalization lets a single-digit exponent buy the best model, which raw cannot", () => {
|
|
407
|
+
// Raw at the same exponent still cannot reach it: that is the defect.
|
|
408
|
+
expect(run({ qualityExponent: 12 }).candidates[0]?.model.slug).toBe("mid/2");
|
|
409
|
+
// Normalised, the same 12 selects the top-quality model.
|
|
410
|
+
const normalised = run({ qualityExponent: 12, qualityNormalization: true });
|
|
411
|
+
expect(normalised.candidates[0]?.model.slug).toBe("best/4");
|
|
412
|
+
expect(normalised.candidates[0]?.reasons.some((r) => r.includes("quality normalised"))).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("normalization is monotone in the exponent: higher never picks a weaker model", () => {
|
|
416
|
+
let lastQuality = 0;
|
|
417
|
+
for (const qualityExponent of [1, 4, 8, 12, 20]) {
|
|
418
|
+
const top = run({ qualityExponent, qualityNormalization: true }).candidates[0];
|
|
419
|
+
expect(top).toBeDefined();
|
|
420
|
+
expect(top?.qualityScore ?? 0).toBeGreaterThanOrEqual(lastQuality);
|
|
421
|
+
lastQuality = top?.qualityScore ?? 0;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("capability floor takes the best model inside the cap, ignoring the ratio", () => {
|
|
426
|
+
// mid/2 costs ~$0.0016 and good/3 ~$0.0049, so this cap admits both but
|
|
427
|
+
// excludes best/4 (~$0.0082). The ranked winner is mid/2 (cheapest).
|
|
428
|
+
const cap = 0.005;
|
|
429
|
+
const capped = run({ capabilityFloorUsd: cap });
|
|
430
|
+
const top = capped.candidates[0];
|
|
431
|
+
expect(top).toBeDefined();
|
|
432
|
+
expect(top?.forecast.coldUsd ?? 1).toBeLessThanOrEqual(cap);
|
|
433
|
+
// It must be the highest-quality affordable one, not the cheapest: good/3.
|
|
434
|
+
expect(top?.model.slug).toBe("good/3");
|
|
435
|
+
expect(top?.reasons.some((r) => r.includes("capability floor"))).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("capability floor is strictly an upgrade: an unaffordable cap changes nothing", () => {
|
|
439
|
+
const base = run({}).candidates.map((c) => c.model.slug);
|
|
440
|
+
// A cap below every candidate's cost promotes nobody.
|
|
441
|
+
const tiny = run({ capabilityFloorUsd: 1e-9 }).candidates.map((c) => c.model.slug);
|
|
442
|
+
expect(tiny).toEqual(base);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("both modes stay inert by default, so shipped behaviour is unchanged", () => {
|
|
446
|
+
const shipped = run({});
|
|
447
|
+
expect(shipped.candidates[0]?.model.slug).toBe("mid/2");
|
|
448
|
+
expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("normalised")))).toBe(true);
|
|
449
|
+
expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("capability floor")))).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
});
|
package/test/turn.test.ts
CHANGED
|
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
70
70
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
71
71
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
72
72
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
73
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
|
|
73
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
|
|
74
74
|
compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
|
|
75
75
|
budget: { onExceeded: "downgrade" },
|
|
76
76
|
profiles: [],
|
package/test/wire-sink.test.ts
CHANGED
|
@@ -70,6 +70,37 @@ describe("createStreamingSink", () => {
|
|
|
70
70
|
{ error: { message: "boom", type: "server_error", code: "upstream_error" } },
|
|
71
71
|
]);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
test("chunk, error, and finish survive a client-cancelled body without throwing", async () => {
|
|
75
|
+
const { sink, response } = createStreamingSink("auto");
|
|
76
|
+
const reader = response.body?.getReader();
|
|
77
|
+
expect(reader).toBeDefined();
|
|
78
|
+
// Client disconnects mid-stream: reader cancels, controller closes.
|
|
79
|
+
await reader?.cancel("client closed connection");
|
|
80
|
+
|
|
81
|
+
// None of these may throw ERR_INVALID_STATE:
|
|
82
|
+
expect(() => {
|
|
83
|
+
sink.chunk(
|
|
84
|
+
chunk({
|
|
85
|
+
id: "gen-cancelled",
|
|
86
|
+
model: "openai/gpt-5.5",
|
|
87
|
+
choices: [{ index: 0, delta: { content: "trailing" }, finish_reason: null }],
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}).not.toThrow();
|
|
91
|
+
|
|
92
|
+
expect(() => {
|
|
93
|
+
sink.finish(SUMMARY);
|
|
94
|
+
}).not.toThrow();
|
|
95
|
+
|
|
96
|
+
// Repeated calls (e.g. error after chunk on dead stream) must also stay safe:
|
|
97
|
+
const { sink: sink2, response: response2 } = createStreamingSink("auto");
|
|
98
|
+
const reader2 = response2.body?.getReader();
|
|
99
|
+
await reader2?.cancel();
|
|
100
|
+
expect(() => {
|
|
101
|
+
sink2.error({ status: 500, code: "server_error", message: "late failure" });
|
|
102
|
+
}).not.toThrow();
|
|
103
|
+
});
|
|
73
104
|
});
|
|
74
105
|
|
|
75
106
|
describe("createBufferedSink", () => {
|
package/tools/replay.ts
CHANGED
|
@@ -26,20 +26,29 @@
|
|
|
26
26
|
* - `explorationDraw` keys on `conversationKey:turn`, both recorded, so
|
|
27
27
|
* exploration reproduces deterministically and cancels out in a diff.
|
|
28
28
|
*
|
|
29
|
+
* Conversation state is reconstructed from the PRECEDING recorded dispatch in
|
|
30
|
+
* the same conversation — prior slug, prior tier, cache warmth, cumulative
|
|
31
|
+
* spend — rather than simulated, so cache-warmth behaviour is exercised. Rows
|
|
32
|
+
* are replayed chronologically for that reason.
|
|
33
|
+
*
|
|
29
34
|
* WHAT IT DOES NOT MODEL — read this before trusting a conclusion
|
|
30
35
|
* - `messages` are not recorded, so compaction cannot be re-planned. Replay
|
|
31
36
|
* forces `compaction.enabled=false` and feeds the POST-compaction prompt
|
|
32
37
|
* size (`usage.promptTokens`), i.e. the prompt selection actually saw.
|
|
33
|
-
* -
|
|
34
|
-
*
|
|
35
|
-
* no accumulated spend. Hysteresis, cache-warmth tie-breaks and the
|
|
36
|
-
* per-conversation budget guard are therefore NOT exercised.
|
|
38
|
+
* - `stickyUntilTurn` was never persisted per turn, so the hysteresis hold
|
|
39
|
+
* window is absent. This is the main residual gap.
|
|
37
40
|
* - `requestedReasoning` is the one `Features` field the ledger omits; it
|
|
38
41
|
* replays as undefined.
|
|
42
|
+
* - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
|
|
43
|
+
* be A/B'd via `--set` — only `RouterConfig` paths can.
|
|
39
44
|
*
|
|
40
|
-
* Because of those gaps
|
|
41
|
-
*
|
|
42
|
-
*
|
|
45
|
+
* Because of those gaps the report leads with a FIDELITY figure. Read it with
|
|
46
|
+
* care: it conflates replay error with genuine code change, since replay always
|
|
47
|
+
* runs CURRENT code against rows served by whatever code was live then. Measured
|
|
48
|
+
* on rows served by matching code it is 90% model / 77% tier; across older
|
|
49
|
+
* history it drops to ~55%, and that drop is the shipped classifier changes
|
|
50
|
+
* showing up, not the tool being wrong. Isolate a population with `--where` when
|
|
51
|
+
* measuring one change.
|
|
43
52
|
*/
|
|
44
53
|
|
|
45
54
|
import { Database } from "bun:sqlite";
|
|
@@ -106,7 +115,11 @@ function withOverrides(cfg: RouterConfig, sets: readonly string[]): RouterConfig
|
|
|
106
115
|
node = child as Record<string, unknown>;
|
|
107
116
|
}
|
|
108
117
|
const leaf = path[path.length - 1];
|
|
109
|
-
if (leaf === undefined
|
|
118
|
+
if (leaf === undefined) throw new Error(`--set expects a key, got: ${entry}`);
|
|
119
|
+
// An absent leaf is legitimate and required: optional config fields are
|
|
120
|
+
// simply missing until set (exactOptionalPropertyTypes), and introducing
|
|
121
|
+
// one is exactly what a variant does. A wrong PARENT path still throws,
|
|
122
|
+
// in the walk above, which is what catches typos.
|
|
110
123
|
node[leaf] = value;
|
|
111
124
|
}
|
|
112
125
|
return next;
|
|
@@ -124,6 +137,7 @@ interface Row {
|
|
|
124
137
|
usage: string;
|
|
125
138
|
reported_usd: number | null;
|
|
126
139
|
predicted_usd: number;
|
|
140
|
+
created_at_ms: number;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
/** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
|
|
@@ -164,26 +178,48 @@ function requestOf(row: Row, f: Features): NormRequest {
|
|
|
164
178
|
};
|
|
165
179
|
}
|
|
166
180
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Conversation state reconstructed from the PRECEDING recorded dispatch in the
|
|
183
|
+
* same conversation, not simulated.
|
|
184
|
+
*
|
|
185
|
+
* A neutral state cannot validate anything that depends on cache warmth — every
|
|
186
|
+
* candidate looks cold, so a warm-cache change shows zero effect. But the
|
|
187
|
+
* ledger does carry what the previous dispatch actually did, so warmth is
|
|
188
|
+
* recoverable: `cacheWarmSlug` is the slug it served, `lastPromptTokens` its
|
|
189
|
+
* prompt size. Deriving state from the RECORDED outcome rather than the
|
|
190
|
+
* replayed one also stops replay error compounding down a conversation.
|
|
191
|
+
*
|
|
192
|
+
* Still not modelled: `stickyUntilTurn`, which was never persisted per turn, so
|
|
193
|
+
* the hysteresis hold window remains absent.
|
|
194
|
+
*/
|
|
195
|
+
function stateOf(row: Row, prior: PriorTurn | undefined): ConversationState {
|
|
169
196
|
return {
|
|
170
197
|
key: row.conversation_key,
|
|
171
198
|
sessionId: `omp-${row.conversation_key}`,
|
|
172
199
|
turn: row.turn,
|
|
173
|
-
currentSlug: null,
|
|
174
|
-
currentTier: null,
|
|
200
|
+
currentSlug: prior?.slug ?? null,
|
|
201
|
+
currentTier: (prior?.tier as Tier | undefined) ?? null,
|
|
175
202
|
stickyUntilTurn: 0,
|
|
176
203
|
escalations: 0,
|
|
177
|
-
spentUsd: 0,
|
|
178
|
-
lastPromptTokens: 0,
|
|
179
|
-
cacheWarmSlug: null,
|
|
180
|
-
cacheWarmAtMs: 0,
|
|
204
|
+
spentUsd: prior?.spentUsd ?? 0,
|
|
205
|
+
lastPromptTokens: prior?.promptTokens ?? 0,
|
|
206
|
+
cacheWarmSlug: prior?.cachedTokens !== undefined && prior.cachedTokens > 0 ? prior.slug : null,
|
|
207
|
+
cacheWarmAtMs: prior?.atMs ?? 0,
|
|
181
208
|
contextVersion: null,
|
|
182
209
|
contextFetchedAtMs: 0,
|
|
183
|
-
updatedAtMs: 0,
|
|
210
|
+
updatedAtMs: prior?.atMs ?? 0,
|
|
184
211
|
};
|
|
185
212
|
}
|
|
186
213
|
|
|
214
|
+
interface PriorTurn {
|
|
215
|
+
slug: string | null;
|
|
216
|
+
tier: string;
|
|
217
|
+
promptTokens: number;
|
|
218
|
+
cachedTokens: number;
|
|
219
|
+
spentUsd: number;
|
|
220
|
+
atMs: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
187
223
|
/**
|
|
188
224
|
* Re-prices a decision against the tokens the turn ACTUALLY used, via the real
|
|
189
225
|
* `computeCost` so price tiers, the cache split and reasoning/request fees are
|
|
@@ -224,14 +260,18 @@ const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
|
|
|
224
260
|
const ledger = createLedger(db, cfgA);
|
|
225
261
|
|
|
226
262
|
const predicate = args.where === "" ? "" : ` AND (${args.where})`;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
263
|
+
// Newest-first to honour --limit, then flipped to chronological so each row can
|
|
264
|
+
// see the dispatch that preceded it in its conversation.
|
|
265
|
+
const rows = (
|
|
266
|
+
db
|
|
267
|
+
.query(
|
|
268
|
+
`SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd, created_at_ms
|
|
269
|
+
FROM ledger
|
|
270
|
+
WHERE features IS NOT NULL AND wasted = 0${predicate}
|
|
271
|
+
ORDER BY created_at_ms DESC LIMIT ?`,
|
|
272
|
+
)
|
|
273
|
+
.all(args.limit) as Row[]
|
|
274
|
+
).reverse();
|
|
235
275
|
|
|
236
276
|
if (rows.length === 0) {
|
|
237
277
|
console.error("no rows matched; widen --where or --limit");
|
|
@@ -253,7 +293,7 @@ interface Outcome {
|
|
|
253
293
|
usd: number;
|
|
254
294
|
}
|
|
255
295
|
|
|
256
|
-
function run(cfg: RouterConfig, row: Row, usage: UsageCounts): Outcome {
|
|
296
|
+
function run(cfg: RouterConfig, row: Row, usage: UsageCounts, prior: PriorTurn | undefined): Outcome {
|
|
257
297
|
const f = featuresOf(row, usage.promptTokens);
|
|
258
298
|
const req = requestOf(row, f);
|
|
259
299
|
const decision: Decision = select({
|
|
@@ -261,7 +301,7 @@ function run(cfg: RouterConfig, row: Row, usage: UsageCounts): Outcome {
|
|
|
261
301
|
features: f,
|
|
262
302
|
classification: scoreHeuristic(f, cfg),
|
|
263
303
|
profile: profileOf(cfg, row.requested_model),
|
|
264
|
-
state: stateOf(row),
|
|
304
|
+
state: stateOf(row, prior),
|
|
265
305
|
snapshot,
|
|
266
306
|
ledger,
|
|
267
307
|
cfg,
|
|
@@ -285,11 +325,24 @@ let comparable = 0;
|
|
|
285
325
|
const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
|
|
286
326
|
const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
|
|
287
327
|
|
|
328
|
+
// Carries the RECORDED outcome of each conversation's previous dispatch forward,
|
|
329
|
+
// so cache warmth and the prior slug are real rather than assumed absent.
|
|
330
|
+
const priorByConv = new Map<string, PriorTurn>();
|
|
331
|
+
|
|
288
332
|
for (const row of rows) {
|
|
289
333
|
const u = JSON.parse(row.usage) as UsageCounts;
|
|
290
334
|
if (!(u.promptTokens > 0)) continue;
|
|
291
|
-
const
|
|
292
|
-
const
|
|
335
|
+
const prior = priorByConv.get(row.conversation_key);
|
|
336
|
+
const a = run(cfgA, row, u, prior);
|
|
337
|
+
const b = run(cfgB, row, u, prior);
|
|
338
|
+
priorByConv.set(row.conversation_key, {
|
|
339
|
+
slug: row.served_slug,
|
|
340
|
+
tier: row.tier,
|
|
341
|
+
promptTokens: u.promptTokens,
|
|
342
|
+
cachedTokens: u.cachedTokens,
|
|
343
|
+
spentUsd: (prior?.spentUsd ?? 0) + (row.reported_usd ?? row.predicted_usd),
|
|
344
|
+
atMs: row.created_at_ms,
|
|
345
|
+
});
|
|
293
346
|
bump(tallyA, a.slug);
|
|
294
347
|
bump(tallyB, b.slug);
|
|
295
348
|
bump(tierA, a.tier);
|