auto-model-router 0.2.16 → 0.2.20
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/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 +9 -2
- 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/http-resilience.test.ts +58 -0
- package/test/tier-plan.test.ts +91 -0
- package/test/turn.test.ts +1 -1
- package/test/wire-sink.test.ts +31 -0
- package/tools/replay.ts +82 -29
package/src/context/bridge.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface BridgeOptions {
|
|
|
38
38
|
memoryLimit: number;
|
|
39
39
|
docsLimit: number;
|
|
40
40
|
sessionLimit: number;
|
|
41
|
+
/** Character budget for the project brief rendered first in the block; 0 omits it. */
|
|
42
|
+
briefChars: number;
|
|
41
43
|
/** Record settled turns back into agentdox sessions. */
|
|
42
44
|
recordTurns: boolean;
|
|
43
45
|
/** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
|
|
@@ -81,7 +83,7 @@ function appendFragment(prior: string, next: string): string {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
84
|
-
const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, recordTurns, maxQueue } = opts;
|
|
86
|
+
const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, briefChars, recordTurns, maxQueue } = opts;
|
|
85
87
|
|
|
86
88
|
// Serialized write-back queue. Session appends for one conversation must
|
|
87
89
|
// stay ordered, and agentdox is a local service — one worker is plenty.
|
|
@@ -119,7 +121,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
119
121
|
return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
|
|
120
122
|
}
|
|
121
123
|
|
|
122
|
-
const raw = await client.assemble(input.scope, input.query, { memoryLimit, docsLimit, sessionLimit });
|
|
124
|
+
const raw = await client.assemble(input.scope, input.query, { memoryLimit, docsLimit, sessionLimit, briefChars });
|
|
123
125
|
if (raw === null) {
|
|
124
126
|
// agentdox unreachable or empty. Keep serving the pinned block if we
|
|
125
127
|
// have one: stale shared context beats none, and re-using it also
|
package/src/context/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Context
|
|
|
30
30
|
memoryLimit: c.memoryLimit,
|
|
31
31
|
docsLimit: c.docsLimit,
|
|
32
32
|
sessionLimit: c.sessionLimit,
|
|
33
|
+
briefChars: c.briefChars,
|
|
33
34
|
recordTurns: c.recordTurns,
|
|
34
35
|
maxQueue: c.maxQueue,
|
|
35
36
|
});
|
package/src/cost/types.ts
CHANGED
|
@@ -124,7 +124,12 @@ export interface LedgerEntry {
|
|
|
124
124
|
/** Time to first content token, ms. */
|
|
125
125
|
ttftMs: number | null;
|
|
126
126
|
finishReason: string | null;
|
|
127
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Attempt superseded by a retry or escalation. NOT a cost figure: by design
|
|
129
|
+
* these rows never carry reported_usd, so "wasted spend" sums to $0.00.
|
|
130
|
+
* The meaningful waste measure is retry spend — rows with attempt > 0 that
|
|
131
|
+
* DID bill. Kept for compatibility; do not read it as money.
|
|
132
|
+
*/
|
|
128
133
|
wasted: boolean;
|
|
129
134
|
upstreamGenerationId: string | null;
|
|
130
135
|
error: string | null;
|
package/src/router/candidates.ts
CHANGED
|
@@ -134,6 +134,8 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
134
134
|
|
|
135
135
|
const candidates: Candidate[] = [];
|
|
136
136
|
const rejected: Rejection[] = [];
|
|
137
|
+
// Carries the trust/latency-adjusted cost into the second scoring pass.
|
|
138
|
+
const effectiveUsdBySlug = new Map<string, number>();
|
|
137
139
|
|
|
138
140
|
for (const model of snapshot.models) {
|
|
139
141
|
const slug = model.slug;
|
|
@@ -231,6 +233,20 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
231
233
|
continue;
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
// Every candidate is priced COLD, deliberately, and this has been measured
|
|
237
|
+
// rather than assumed. Two reasons:
|
|
238
|
+
// 1. `coldUsd` feeds the budget guard in select.ts, and a budget must
|
|
239
|
+
// survive a cache miss.
|
|
240
|
+
// 2. Discounting the warm slug here only ever LOWERS its effective cost,
|
|
241
|
+
// so it can only make the warm model win more often — and the warm
|
|
242
|
+
// model is already either the cheapest candidate or kept by the
|
|
243
|
+
// dedicated stay-vs-switch comparison in select.ts step 4, which does
|
|
244
|
+
// price staying at `cacheRead` against switching at cold+`cacheWrite`.
|
|
245
|
+
// So the ranking change has no headroom to alter an outcome.
|
|
246
|
+
// Verified with tools/replay.ts: scoring the warm candidate at hit rates
|
|
247
|
+
// 0.5 / 0.8 / 0.95 changed 0 of 897 decisions, and 0 of 702 on the subset
|
|
248
|
+
// whose conversations ran the expensive model. Cache economics belong in
|
|
249
|
+
// the switch decision, not in candidate scoring — do not "fix" this.
|
|
234
250
|
const fc = forecast(model, {
|
|
235
251
|
promptTokens: features.promptTokens,
|
|
236
252
|
completionTokens: expectedCompletionTokens,
|
|
@@ -249,7 +265,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
249
265
|
: null;
|
|
250
266
|
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
|
|
251
267
|
const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5)) * latencyMult;
|
|
252
|
-
|
|
268
|
+
// Score is assigned in a SECOND PASS below: both qualityNormalization and
|
|
269
|
+
// capabilityFloorUsd are properties of the candidate SET, not of one
|
|
270
|
+
// model, so no per-model value can be computed here. Placeholder only.
|
|
271
|
+
const score = 0;
|
|
253
272
|
|
|
254
273
|
const reasons: string[] = [
|
|
255
274
|
quality === null
|
|
@@ -267,6 +286,30 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
267
286
|
}
|
|
268
287
|
if (pinned) reasons.push("pinned into tier");
|
|
269
288
|
candidates.push({ model, forecast: fc, qualityScore, trustScore, score, reasons });
|
|
289
|
+
effectiveUsdBySlug.set(slug, effectiveUsd);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Second pass: both new tier modes need the whole set.
|
|
293
|
+
// - qualityNormalization rescales quality to the set's own [worst, best]
|
|
294
|
+
// range, so the exponent operates on a full 0-1 spread instead of the
|
|
295
|
+
// raw index's compressed 69-78 band.
|
|
296
|
+
// - capabilityFloorUsd ignores the ratio entirely and takes the highest
|
|
297
|
+
// quality candidate affordable within the cap.
|
|
298
|
+
const qualities = candidates.map((c) => c.qualityScore);
|
|
299
|
+
const qMin = qualities.length > 0 ? Math.min(...qualities) : 0;
|
|
300
|
+
const qMax = qualities.length > 0 ? Math.max(...qualities) : 0;
|
|
301
|
+
const qSpread = qMax - qMin;
|
|
302
|
+
const normalize = tierCfg.qualityNormalization === true && qSpread > 0;
|
|
303
|
+
for (const c of candidates) {
|
|
304
|
+
const effectiveUsd = effectiveUsdBySlug.get(c.model.slug) ?? c.forecast.expectedUsd;
|
|
305
|
+
// Normalised quality is unitless in [0,1]: the set's cheapest-quality
|
|
306
|
+
// model scores 0, its best scores 1. A single-model set has no spread,
|
|
307
|
+
// so it keeps the raw path (guarded by qSpread > 0).
|
|
308
|
+
const q = normalize ? (c.qualityScore - qMin) / qSpread : c.qualityScore / 100;
|
|
309
|
+
c.score = Math.pow(q, tierCfg.qualityExponent) / Math.max(effectiveUsd, 1e-9);
|
|
310
|
+
if (normalize) {
|
|
311
|
+
c.reasons.push(`quality normalised ${q.toFixed(3)} within set [${qMin}, ${qMax}]`);
|
|
312
|
+
}
|
|
270
313
|
}
|
|
271
314
|
|
|
272
315
|
candidates.sort((a, b) => {
|
|
@@ -285,5 +328,28 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
285
328
|
if (b.model.slug === warmSlug) return 1;
|
|
286
329
|
return a.model.slug < b.model.slug ? -1 : 1;
|
|
287
330
|
});
|
|
331
|
+
|
|
332
|
+
// Capability-floor mode: the top tier's job is "best model the work needs",
|
|
333
|
+
// which quality-per-dollar cannot express — a bargain model always wins the
|
|
334
|
+
// ratio however weak it is. Promote the highest-quality candidate whose
|
|
335
|
+
// forecast turn cost fits the cap to the front. Strictly an upgrade: when
|
|
336
|
+
// nothing is affordable, or the ranked winner is already the best quality,
|
|
337
|
+
// the order is untouched.
|
|
338
|
+
const floorUsd = tierCfg.capabilityFloorUsd;
|
|
339
|
+
if (floorUsd !== undefined && candidates.length > 1) {
|
|
340
|
+
let best: Candidate | undefined;
|
|
341
|
+
for (const c of candidates) {
|
|
342
|
+
if (c.forecast.coldUsd > floorUsd) continue;
|
|
343
|
+
if (best === undefined || c.qualityScore > best.qualityScore) best = c;
|
|
344
|
+
}
|
|
345
|
+
if (best !== undefined && best !== candidates[0]) {
|
|
346
|
+
const idx = candidates.indexOf(best);
|
|
347
|
+
candidates.splice(idx, 1);
|
|
348
|
+
candidates.unshift(best);
|
|
349
|
+
best.reasons.push(
|
|
350
|
+
`capability floor: highest quality ${best.qualityScore} within $${floorUsd}/turn (cold $${best.forecast.coldUsd.toFixed(4)})`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
288
354
|
return { candidates, rejected };
|
|
289
355
|
}
|
package/src/server/http.ts
CHANGED
|
@@ -167,7 +167,7 @@ function isLoopbackHostHeader(hostHeader: string | null): boolean {
|
|
|
167
167
|
export function startServer(cfg: RouterConfig): StartedServer {
|
|
168
168
|
const log = createLogger(cfg.logLevel);
|
|
169
169
|
|
|
170
|
-
mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
170
|
+
if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
171
171
|
const db = openDb(cfg.ledger.path);
|
|
172
172
|
const ledger = createLedger(db, cfg);
|
|
173
173
|
const upstream = createOpenRouterClient(cfg);
|
|
@@ -258,7 +258,14 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
258
258
|
runTurn(normReq, sink, turnDeps, req.signal)
|
|
259
259
|
.catch((err: unknown) => {
|
|
260
260
|
log.error("turn failed", { error: err instanceof Error ? err.message : String(err) });
|
|
261
|
-
|
|
261
|
+
// sink.error() is SYNCHRONOUS: it runs before Promise.resolve wraps
|
|
262
|
+
// anything, so a throw out of it escapes this .catch() handler and
|
|
263
|
+
// becomes an unhandled rejection that kills the process. Wrap it.
|
|
264
|
+
try {
|
|
265
|
+
Promise.resolve(sink.error(toWireError(err))).catch(() => {});
|
|
266
|
+
} catch {
|
|
267
|
+
// Stream already gone; the response cannot carry the error.
|
|
268
|
+
}
|
|
262
269
|
})
|
|
263
270
|
.finally(() => {
|
|
264
271
|
// Release the concurrency slot when the turn settles, not when the
|
package/src/wire/openai/sink.ts
CHANGED
|
@@ -26,12 +26,25 @@ export function createStreamingSink(virtualModel: string): { sink: ResponseSink;
|
|
|
26
26
|
},
|
|
27
27
|
});
|
|
28
28
|
const send = (bytes: Uint8Array): void => {
|
|
29
|
-
if (
|
|
29
|
+
if (closed) return;
|
|
30
|
+
try {
|
|
31
|
+
controller?.enqueue(bytes);
|
|
32
|
+
} catch {
|
|
33
|
+
// The runtime can close the controller under us — a client cancelling
|
|
34
|
+
// the stream (reader.cancel()) is not exceptional and nothing above us
|
|
35
|
+
// observes it. Mark closed and drop the write; measured to throw
|
|
36
|
+
// ERR_INVALID_STATE synchronously on Bun 1.x otherwise.
|
|
37
|
+
closed = true;
|
|
38
|
+
}
|
|
30
39
|
};
|
|
31
40
|
const close = (): void => {
|
|
32
41
|
if (!closed) {
|
|
33
42
|
closed = true;
|
|
34
|
-
|
|
43
|
+
try {
|
|
44
|
+
controller?.close();
|
|
45
|
+
} catch {
|
|
46
|
+
// Already closed by the runtime; nothing left to do.
|
|
47
|
+
}
|
|
35
48
|
}
|
|
36
49
|
};
|
|
37
50
|
|
|
@@ -55,6 +55,7 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
|
|
|
55
55
|
memoryLimit: 8,
|
|
56
56
|
docsLimit: 2,
|
|
57
57
|
sessionLimit: 6,
|
|
58
|
+
briefChars: 0,
|
|
58
59
|
recordTurns: true,
|
|
59
60
|
maxQueue: 64,
|
|
60
61
|
...over,
|
|
@@ -102,12 +103,12 @@ describe("context bridge refresh policy", () => {
|
|
|
102
103
|
// especially: docs are WHOLE documents and were left unbounded, and a single
|
|
103
104
|
// ashlands note-doc measured 41,921 chars — over the whole cap by itself.
|
|
104
105
|
// The REST endpoint also ignores snake_case limit keys, which silently reads
|
|
105
|
-
// as unbounded, so pin that all
|
|
106
|
+
// as unbounded, so pin that all four limits actually reach the client.
|
|
106
107
|
const client = mkClient();
|
|
107
|
-
const { bridge, db } = mkBridge(client, { memoryLimit: 5, docsLimit: 1, sessionLimit: 2 });
|
|
108
|
+
const { bridge, db } = mkBridge(client, { memoryLimit: 5, docsLimit: 1, sessionLimit: 2, briefChars: 9000 });
|
|
108
109
|
try {
|
|
109
110
|
await bridge.resolve(input());
|
|
110
|
-
expect(client.lastLimits).toEqual({ memoryLimit: 5, docsLimit: 1, sessionLimit: 2 });
|
|
111
|
+
expect(client.lastLimits).toEqual({ memoryLimit: 5, docsLimit: 1, sessionLimit: 2, briefChars: 9000 });
|
|
111
112
|
} finally {
|
|
112
113
|
db.close();
|
|
113
114
|
}
|
|
@@ -244,6 +245,7 @@ describe("context bridge refresh policy", () => {
|
|
|
244
245
|
memoryLimit: 8,
|
|
245
246
|
docsLimit: 2,
|
|
246
247
|
sessionLimit: 6,
|
|
248
|
+
briefChars: 0,
|
|
247
249
|
recordTurns: true,
|
|
248
250
|
maxQueue: 64,
|
|
249
251
|
};
|
package/test/failover.test.ts
CHANGED
|
@@ -69,7 +69,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
69
69
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
70
70
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
71
71
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
72
|
-
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 },
|
|
72
|
+
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 },
|
|
73
73
|
compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
|
|
74
74
|
budget: { onExceeded: "downgrade" },
|
|
75
75
|
profiles: [],
|
|
@@ -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
|
@@ -354,3 +354,94 @@ describe("adaptive price ceilings", () => {
|
|
|
354
354
|
expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
|
|
355
355
|
});
|
|
356
356
|
});
|
|
357
|
+
|
|
358
|
+
describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
|
|
359
|
+
const req = parseChatRequest(
|
|
360
|
+
{
|
|
361
|
+
model: "auto",
|
|
362
|
+
tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }],
|
|
363
|
+
messages: [{ role: "user", content: "implement nested transaction savepoints" }],
|
|
364
|
+
},
|
|
365
|
+
new Headers(),
|
|
366
|
+
);
|
|
367
|
+
const features = extractFeatures(req, 100);
|
|
368
|
+
|
|
369
|
+
// The real catalog's shape: quality in a narrow band, price spanning ~100x.
|
|
370
|
+
// `hard` has a quality floor of 72, so cheap/1 is deliberately below it —
|
|
371
|
+
// it must be rejected, and mid/2 is the cheapest ELIGIBLE model, the one
|
|
372
|
+
// raw quality-per-dollar ranking picks at any sane exponent.
|
|
373
|
+
const spread = snapshot(
|
|
374
|
+
models([
|
|
375
|
+
["cheap/1", 70, 0.05],
|
|
376
|
+
["mid/2", 74, 1.0],
|
|
377
|
+
["good/3", 76, 3.0],
|
|
378
|
+
["best/4", 78, 5.0],
|
|
379
|
+
]),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const run = (tierOverride: Partial<(typeof BASE)["tiers"]["hard"]>) =>
|
|
383
|
+
buildCandidates({
|
|
384
|
+
req,
|
|
385
|
+
features,
|
|
386
|
+
tier: "hard",
|
|
387
|
+
task: "coding",
|
|
388
|
+
snapshot: spread,
|
|
389
|
+
ledger: null,
|
|
390
|
+
cfg: { ...BASE, tiers: { ...BASE.tiers, hard: { ...BASE.tiers.hard, ...tierOverride } } },
|
|
391
|
+
expectedCompletionTokens: 512,
|
|
392
|
+
warmSlug: null,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("raw scoring at the shipped exponent picks the cheapest ELIGIBLE model", () => {
|
|
396
|
+
const { candidates, rejected } = run({ qualityExponent: 3 });
|
|
397
|
+
expect(candidates[0]?.model.slug).toBe("mid/2");
|
|
398
|
+
// cheap/1 is under the hard floor of 72 and never competes.
|
|
399
|
+
expect(rejected.some((r) => r.slug === "cheap/1" && r.reason === "below_quality_floor")).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("normalization lets a single-digit exponent buy the best model, which raw cannot", () => {
|
|
403
|
+
// Raw at the same exponent still cannot reach it: that is the defect.
|
|
404
|
+
expect(run({ qualityExponent: 12 }).candidates[0]?.model.slug).toBe("mid/2");
|
|
405
|
+
// Normalised, the same 12 selects the top-quality model.
|
|
406
|
+
const normalised = run({ qualityExponent: 12, qualityNormalization: true });
|
|
407
|
+
expect(normalised.candidates[0]?.model.slug).toBe("best/4");
|
|
408
|
+
expect(normalised.candidates[0]?.reasons.some((r) => r.includes("quality normalised"))).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("normalization is monotone in the exponent: higher never picks a weaker model", () => {
|
|
412
|
+
let lastQuality = 0;
|
|
413
|
+
for (const qualityExponent of [1, 4, 8, 12, 20]) {
|
|
414
|
+
const top = run({ qualityExponent, qualityNormalization: true }).candidates[0];
|
|
415
|
+
expect(top).toBeDefined();
|
|
416
|
+
expect(top?.qualityScore ?? 0).toBeGreaterThanOrEqual(lastQuality);
|
|
417
|
+
lastQuality = top?.qualityScore ?? 0;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("capability floor takes the best model inside the cap, ignoring the ratio", () => {
|
|
422
|
+
// mid/2 costs ~$0.0016 and good/3 ~$0.0049, so this cap admits both but
|
|
423
|
+
// excludes best/4 (~$0.0082). The ranked winner is mid/2 (cheapest).
|
|
424
|
+
const cap = 0.005;
|
|
425
|
+
const capped = run({ capabilityFloorUsd: cap });
|
|
426
|
+
const top = capped.candidates[0];
|
|
427
|
+
expect(top).toBeDefined();
|
|
428
|
+
expect(top?.forecast.coldUsd ?? 1).toBeLessThanOrEqual(cap);
|
|
429
|
+
// It must be the highest-quality affordable one, not the cheapest: good/3.
|
|
430
|
+
expect(top?.model.slug).toBe("good/3");
|
|
431
|
+
expect(top?.reasons.some((r) => r.includes("capability floor"))).toBe(true);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("capability floor is strictly an upgrade: an unaffordable cap changes nothing", () => {
|
|
435
|
+
const base = run({}).candidates.map((c) => c.model.slug);
|
|
436
|
+
// A cap below every candidate's cost promotes nobody.
|
|
437
|
+
const tiny = run({ capabilityFloorUsd: 1e-9 }).candidates.map((c) => c.model.slug);
|
|
438
|
+
expect(tiny).toEqual(base);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("both modes stay inert by default, so shipped behaviour is unchanged", () => {
|
|
442
|
+
const shipped = run({});
|
|
443
|
+
expect(shipped.candidates[0]?.model.slug).toBe("mid/2");
|
|
444
|
+
expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("normalised")))).toBe(true);
|
|
445
|
+
expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("capability floor")))).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
});
|
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);
|