auto-model-router 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +24 -2
- package/omp-extension/report-logic.ts +93 -0
- package/omp-extension/router-configure.ts +128 -2
- package/package.json +1 -1
- package/src/catalog/ollama-catalog.ts +30 -2
- package/src/cli/config-wizard.ts +8 -0
- package/src/cli/report.ts +7 -2
- package/src/config/defaults.ts +9 -0
- package/src/config/schema.ts +5 -0
- package/src/config/types.ts +41 -0
- package/src/cost/feedback.ts +81 -0
- package/src/cost/ledger.ts +73 -0
- package/src/cost/report.ts +106 -2
- package/src/cost/types.ts +28 -0
- package/src/router/candidates.ts +13 -4
- package/src/router/classify.ts +10 -0
- package/src/router/features.ts +20 -1
- package/src/router/index.ts +12 -1
- package/src/router/learned.ts +202 -0
- package/src/router/select.ts +64 -8
- package/src/router/types.ts +31 -1
- package/src/server/http.ts +82 -5
- package/src/server/overrides.ts +83 -0
- package/src/server/providers.ts +10 -2
- package/src/server/turn.ts +16 -1
- package/src/upstream/ollama-usage.ts +79 -2
- package/src/util/sqlite.ts +30 -0
- package/test/config-wizard.test.ts +2 -1
- package/test/controls.test.ts +223 -0
- package/test/failover.test.ts +3 -2
- package/test/features.test.ts +31 -0
- package/test/learned.test.ts +61 -0
- package/test/ollama.test.ts +74 -2
- package/test/report-hub.test.ts +4 -2
- package/test/report-logic.test.ts +3 -0
- package/test/report.test.ts +43 -0
- package/test/select.test.ts +126 -1
- package/test/trust-attribution.test.ts +42 -0
- package/test/turn.test.ts +33 -2
- package/tools/replay.ts +266 -156
- package/tools/train-classifier.ts +111 -0
package/tools/replay.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* bun tools/replay.ts --limit 500
|
|
10
10
|
* bun tools/replay.ts --set tiers.hard.minQuality=70
|
|
11
|
-
* bun tools/replay.ts --set
|
|
11
|
+
* bun tools/replay.ts --set hysteresis.switchHorizonTurns=8 --verbose
|
|
12
12
|
* bun tools/replay.ts --where "task='coding'" --set classifier.ambiguityThreshold=0
|
|
13
|
+
* bun tools/replay.ts --warmth recorded # the pre-2026-09-07 warmth model
|
|
13
14
|
*
|
|
14
15
|
* `--set` overrides variant B; `--a` overrides the baseline too (default:
|
|
15
16
|
* config as it currently stands on disk). Read-only: opens the ledger DB
|
|
@@ -18,47 +19,52 @@
|
|
|
18
19
|
* WHAT IT MODELS FAITHFULLY
|
|
19
20
|
* - The recorded `features` blob is the exact classifier input from that turn,
|
|
20
21
|
* so no re-tokenization or re-derivation is involved.
|
|
21
|
-
* - The real catalog
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* - The real catalog is hydrated from `catalog_cache` (OpenRouter) and
|
|
23
|
+
* `ollama_catalog_cache` (Ollama Cloud, when `ollama.enabled`) and merged
|
|
24
|
+
* exactly as `composite.ts` does, with each variant's own `ollama.costBias`
|
|
25
|
+
* stamped on its snapshot. No network.
|
|
24
26
|
* - The real `Ledger` supplies trust and latency, so the trust divisor and the
|
|
25
27
|
* throughput multiplier behave as they do live.
|
|
26
28
|
* - `explorationDraw` keys on `conversationKey:turn`, both recorded, so
|
|
27
29
|
* exploration reproduces deterministically and cancels out in a diff.
|
|
30
|
+
* - Escalations are replayed AS RECORDED: a served attempt > 0 routes with
|
|
31
|
+
* `escalateFrom` set to the tier of the probe-rejected attempt before it
|
|
32
|
+
* and `excludeSlugs` set to the slugs that failed, exactly as `turn.ts`
|
|
33
|
+
* calls `route()`. Hold re-arming uses the escalated hold length. What
|
|
34
|
+
* replay cannot do is decide whether a VARIANT's cheaper pick would have
|
|
35
|
+
* escalated — the probe needs the streamed output — so an escalation that
|
|
36
|
+
* happened stays happened in both variants.
|
|
28
37
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
38
|
+
* CACHE WARMTH (the part that makes switch policy measurable)
|
|
39
|
+
* - `--warmth variant` (default): each variant carries its OWN previous
|
|
40
|
+
* decision. The model it chose last turn is the warm one, and a dispatch is
|
|
41
|
+
* priced warm only when the variant stays on it within `cacheWarmTtlMs`,
|
|
42
|
+
* with the previous prompt as the cached prefix (the same rule
|
|
43
|
+
* `cache-estimate.ts` applies to Ollama). A variant that switches pays the
|
|
44
|
+
* cold read. This is what lets `switchMargin`, `switchHorizonTurns`,
|
|
45
|
+
* `confirmUpgradesBelowConfidence` and the hold lengths be priced.
|
|
46
|
+
* - `--warmth recorded`: warmth comes from the recorded outcome (what the
|
|
47
|
+
* previous dispatch actually served and cached). Keeps replay error from
|
|
48
|
+
* compounding down a conversation, but prices every variant's switch as if
|
|
49
|
+
* the cache followed it, so switch policies show as no-ops.
|
|
33
50
|
*
|
|
34
51
|
* WHAT IT DOES NOT MODEL — read this before trusting a conclusion
|
|
35
52
|
* - `messages` are not recorded, so compaction cannot be re-planned. Replay
|
|
36
53
|
* forces `compaction.enabled=false` and feeds the POST-compaction prompt
|
|
37
54
|
* size (`usage.promptTokens`), i.e. the prompt selection actually saw.
|
|
38
|
-
* -
|
|
39
|
-
*
|
|
40
|
-
* that stops arming an expensive tier also drops the holds that followed it.
|
|
41
|
-
* What remains absent is escalation-lengthened holds, since replay does not
|
|
42
|
-
* retry, and `hold_arm` exploration draws are reproduced from the
|
|
43
|
-
* conversation key rather than read back from the row.
|
|
44
|
-
* - `requestedReasoning` IS recorded and is now used. It was previously forced
|
|
45
|
-
* to undefined here on the belief the ledger omitted it, which under-scored
|
|
46
|
-
* ~42% of dispatches and reproduced 27 hard decisions against 120 served.
|
|
47
|
-
* Treat replay numbers produced before that fix as biased toward cheap tiers.
|
|
55
|
+
* - The credit-aware Ollama bias is replayed at the CONFIGURED `costBias`,
|
|
56
|
+
* not the usage-dependent effective bias that was live at the time.
|
|
48
57
|
* - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
|
|
49
58
|
* be A/B'd via `--set` — only `RouterConfig` paths can.
|
|
50
59
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* runs CURRENT code
|
|
54
|
-
* on rows served by matching code it is 90% model / 77% tier; across older
|
|
55
|
-
* history it drops to ~55%, and that drop is the shipped classifier changes
|
|
56
|
-
* showing up, not the tool being wrong. Isolate a population with `--where` when
|
|
57
|
-
* measuring one change.
|
|
60
|
+
* FIDELITY LINE. "same model N/M" compares variant A against what actually
|
|
61
|
+
* ran. Divergence is expected where code has changed since those rows were
|
|
62
|
+
* served (replay runs CURRENT code); the rest is what replay cannot model.
|
|
58
63
|
*/
|
|
59
64
|
|
|
60
65
|
import { Database } from "bun:sqlite";
|
|
61
66
|
|
|
67
|
+
import { loadOllamaCatalogCache, mergeSnapshots } from "../src/catalog/ollama-catalog.ts";
|
|
62
68
|
import { createCatalog } from "../src/catalog/openrouter-catalog.ts";
|
|
63
69
|
import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
|
|
64
70
|
import { loadConfig } from "../src/config/load.ts";
|
|
@@ -66,10 +72,10 @@ import type { RouterConfig } from "../src/config/types.ts";
|
|
|
66
72
|
import { computeCost } from "../src/cost/forecast.ts";
|
|
67
73
|
import { createLedger } from "../src/cost/ledger.ts";
|
|
68
74
|
import type { UsageCounts } from "../src/cost/types.ts";
|
|
69
|
-
import { scoreHeuristic } from "../src/router/classify.ts";
|
|
75
|
+
import { classifyTask, scoreHeuristic } from "../src/router/classify.ts";
|
|
70
76
|
import { resolveHoldTurns } from "../src/router/explore.ts";
|
|
71
77
|
import { select } from "../src/router/select.ts";
|
|
72
|
-
import type
|
|
78
|
+
import { TIER_ORDER, type Classification, type ConversationState, type Decision, type Features, type Tier } from "../src/router/types.ts";
|
|
73
79
|
import type { UpstreamClient } from "../src/upstream/types.ts";
|
|
74
80
|
import type { NormMessage, NormRequest, NormTool } from "../src/wire/types.ts";
|
|
75
81
|
|
|
@@ -80,10 +86,11 @@ interface Args {
|
|
|
80
86
|
setA: string[];
|
|
81
87
|
verbose: boolean;
|
|
82
88
|
db: string;
|
|
89
|
+
warmth: "variant" | "recorded";
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
function parseArgs(argv: string[]): Args {
|
|
86
|
-
const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "" };
|
|
93
|
+
const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "", warmth: "variant" };
|
|
87
94
|
for (let i = 0; i < argv.length; i++) {
|
|
88
95
|
const k = argv[i];
|
|
89
96
|
const v = argv[i + 1];
|
|
@@ -92,6 +99,7 @@ function parseArgs(argv: string[]): Args {
|
|
|
92
99
|
else if (k === "--set" && v !== undefined) (a.setB.push(v), i++);
|
|
93
100
|
else if (k === "--a" && v !== undefined) (a.setA.push(v), i++);
|
|
94
101
|
else if (k === "--db" && v !== undefined) (a.db = v), i++;
|
|
102
|
+
else if (k === "--warmth" && (v === "variant" || v === "recorded")) (a.warmth = v), i++;
|
|
95
103
|
else if (k === "--verbose") a.verbose = true;
|
|
96
104
|
}
|
|
97
105
|
return a;
|
|
@@ -136,8 +144,12 @@ interface Row {
|
|
|
136
144
|
id: string;
|
|
137
145
|
conversation_key: string;
|
|
138
146
|
turn: number;
|
|
147
|
+
attempt: number;
|
|
148
|
+
wasted: number;
|
|
149
|
+
escalation_signal: string | null;
|
|
139
150
|
requested_model: string;
|
|
140
151
|
harness_id: string;
|
|
152
|
+
slug: string;
|
|
141
153
|
served_slug: string | null;
|
|
142
154
|
tier: string;
|
|
143
155
|
features: string;
|
|
@@ -196,23 +208,34 @@ function requestOf(row: Row, f: Features): NormRequest {
|
|
|
196
208
|
};
|
|
197
209
|
}
|
|
198
210
|
|
|
211
|
+
/** The recorded outcome of a conversation's previous dispatch. */
|
|
212
|
+
interface PriorTurn {
|
|
213
|
+
slug: string | null;
|
|
214
|
+
tier: string;
|
|
215
|
+
promptTokens: number;
|
|
216
|
+
cachedTokens: number;
|
|
217
|
+
spentUsd: number;
|
|
218
|
+
atMs: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
199
221
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* ledger does carry what the previous dispatch actually did, so warmth is
|
|
206
|
-
* recoverable: `cacheWarmSlug` is the slug it served, `lastPromptTokens` its
|
|
207
|
-
* prompt size. Deriving state from the RECORDED outcome rather than the
|
|
208
|
-
* replayed one also stops replay error compounding down a conversation.
|
|
209
|
-
*
|
|
210
|
-
* `stickyUntilTurn` and `currentTier` are the exception: they are SIMULATED per
|
|
211
|
-
* variant, by re-arming the hold exactly as `turn.ts` does after each replayed
|
|
212
|
-
* decision. Without that, replay never held a tier and every hysteresis change
|
|
213
|
-
* priced as zero.
|
|
222
|
+
* A variant's own trail through a conversation: the model it chose last, the
|
|
223
|
+
* prompt it saw, the tier it holds and until when, and the upgrade it deferred.
|
|
224
|
+
* Evolved PER VARIANT, because every one of these follows from the variant's
|
|
225
|
+
* own decisions; reading them from the recorded outcome would charge a variant
|
|
226
|
+
* for holds and caches it never created and hide the switches it made.
|
|
214
227
|
*/
|
|
215
|
-
|
|
228
|
+
interface VariantTrail {
|
|
229
|
+
slug: string | null;
|
|
230
|
+
promptTokens: number;
|
|
231
|
+
atMs: number;
|
|
232
|
+
tier: Tier | null;
|
|
233
|
+
stickyUntilTurn: number;
|
|
234
|
+
upgradeDeferredTier: Tier | null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function stateOf(row: Row, prior: PriorTurn | undefined, trail: VariantTrail | undefined, warmth: Args["warmth"]): ConversationState {
|
|
238
|
+
const warmSlug = warmth === "variant" ? (trail?.slug ?? null) : prior?.cachedTokens !== undefined && prior.cachedTokens > 0 ? prior.slug : null;
|
|
216
239
|
return {
|
|
217
240
|
key: row.conversation_key,
|
|
218
241
|
sessionId: `omp-${row.conversation_key}`,
|
|
@@ -220,64 +243,49 @@ function stateOf(row: Row, prior: PriorTurn | undefined, hold: HoldState | undef
|
|
|
220
243
|
// state `select` sees carries the PREVIOUS turn number. Passing row.turn
|
|
221
244
|
// would expire every hold a turn early.
|
|
222
245
|
turn: row.turn - 1,
|
|
223
|
-
currentSlug: prior?.slug ?? null,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
currentTier: hold?.tier ?? ((prior?.tier as Tier | undefined) ?? null),
|
|
227
|
-
stickyUntilTurn: hold?.stickyUntilTurn ?? 0,
|
|
246
|
+
currentSlug: warmth === "variant" ? (trail?.slug ?? null) : (prior?.slug ?? null),
|
|
247
|
+
currentTier: trail?.tier ?? ((prior?.tier as Tier | undefined) ?? null),
|
|
248
|
+
stickyUntilTurn: trail?.stickyUntilTurn ?? 0,
|
|
228
249
|
escalations: 0,
|
|
229
250
|
spentUsd: prior?.spentUsd ?? 0,
|
|
230
|
-
lastPromptTokens: prior?.promptTokens ?? 0,
|
|
231
|
-
cacheWarmSlug:
|
|
232
|
-
cacheWarmAtMs: prior?.atMs ?? 0,
|
|
251
|
+
lastPromptTokens: warmth === "variant" ? (trail?.promptTokens ?? 0) : (prior?.promptTokens ?? 0),
|
|
252
|
+
cacheWarmSlug: warmSlug,
|
|
253
|
+
cacheWarmAtMs: warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0),
|
|
233
254
|
contextVersion: null,
|
|
234
255
|
contextFetchedAtMs: 0,
|
|
235
256
|
// Compaction cannot be replanned offline (messages are not recorded), so
|
|
236
257
|
// replay carries no plan: forced off in the config it replays under.
|
|
237
258
|
compactionPlan: null,
|
|
238
|
-
|
|
259
|
+
upgradeDeferredTier: trail?.upgradeDeferredTier ?? null,
|
|
260
|
+
updatedAtMs: warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0),
|
|
239
261
|
};
|
|
240
262
|
}
|
|
241
263
|
|
|
242
|
-
interface PriorTurn {
|
|
243
|
-
slug: string | null;
|
|
244
|
-
tier: string;
|
|
245
|
-
promptTokens: number;
|
|
246
|
-
cachedTokens: number;
|
|
247
|
-
spentUsd: number;
|
|
248
|
-
atMs: number;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Hysteresis state, evolved PER VARIANT.
|
|
253
|
-
*
|
|
254
|
-
* A hold is a consequence of the decisions a variant made, so A and B must each
|
|
255
|
-
* carry their own: if both read the recorded holds, a change that stops arming
|
|
256
|
-
* `hard` would still be charged for the holds that followed it in production,
|
|
257
|
-
* and the change would price as smaller than it is.
|
|
258
|
-
*
|
|
259
|
-
* This is the one place replay departs from "inputs come from the recorded
|
|
260
|
-
* outcome". The cost is that hold state compounds a variant's own replay error
|
|
261
|
-
* down a conversation; the benefit is that hold policy becomes measurable at
|
|
262
|
-
* all, which it was not.
|
|
263
|
-
*/
|
|
264
|
-
interface HoldState {
|
|
265
|
-
tier: Tier | null;
|
|
266
|
-
stickyUntilTurn: number;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
264
|
/**
|
|
270
265
|
* Re-prices a decision against the tokens the turn ACTUALLY used, via the real
|
|
271
|
-
* `computeCost
|
|
272
|
-
*
|
|
266
|
+
* `computeCost`, with the cache split decided by the warmth model: under
|
|
267
|
+
* `variant` warmth a dispatch is warm only when the variant stayed on its own
|
|
268
|
+
* previous model within the TTL, and the previous prompt is the cached prefix.
|
|
273
269
|
*
|
|
274
270
|
* Deliberately NOT the router's own forecast: `candidates.ts` hardcodes
|
|
275
271
|
* `cacheHitRate: 0`, so forecasts overstate absolute cost ~2.8x. Pricing both
|
|
276
272
|
* variants off recorded usage keeps the delta apples-to-apples and grounded.
|
|
277
273
|
*/
|
|
278
|
-
function repriceUsd(
|
|
279
|
-
|
|
280
|
-
|
|
274
|
+
function repriceUsd(
|
|
275
|
+
model: CatalogModel | undefined,
|
|
276
|
+
usage: UsageCounts,
|
|
277
|
+
warm: boolean,
|
|
278
|
+
prevPromptTokens: number,
|
|
279
|
+
recordedSlug: string | null,
|
|
280
|
+
): { usd: number; cold: boolean } {
|
|
281
|
+
if (model === undefined) return { usd: 0, cold: false };
|
|
282
|
+
if (!warm) {
|
|
283
|
+
return { usd: computeCost(model, { ...usage, cachedTokens: 0, cacheWriteTokens: 0 }).total, cold: true };
|
|
284
|
+
}
|
|
285
|
+
// Warm: the recorded cache count when the recorded dispatch was this very
|
|
286
|
+
// model (the provider's own figure), else the previous prompt as prefix.
|
|
287
|
+
const cached = recordedSlug === model.slug && usage.cachedTokens > 0 ? usage.cachedTokens : Math.min(prevPromptTokens, usage.promptTokens);
|
|
288
|
+
return { usd: computeCost(model, { ...usage, cachedTokens: cached, cacheWriteTokens: 0 }).total, cold: false };
|
|
281
289
|
}
|
|
282
290
|
|
|
283
291
|
const DEAD_UPSTREAM: UpstreamClient = {
|
|
@@ -297,24 +305,33 @@ const cfgB = withOverrides(baseCfg, [...forced, ...args.setB]);
|
|
|
297
305
|
const dbPath = args.db !== "" ? args.db : baseCfg.ledger.path;
|
|
298
306
|
const db = new Database(dbPath, { readonly: true });
|
|
299
307
|
const catalog = createCatalog(cfgA, DEAD_UPSTREAM, db);
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
308
|
+
const openrouterSnapshot = catalog.peek();
|
|
309
|
+
if (openrouterSnapshot === null) {
|
|
302
310
|
console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
|
|
303
311
|
process.exit(2);
|
|
304
312
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
313
|
+
const ollamaCache = loadOllamaCatalogCache(db);
|
|
314
|
+
|
|
315
|
+
/** The composite snapshot a variant routes over, with its own Ollama bias stamped on. */
|
|
316
|
+
function snapshotFor(cfg: RouterConfig): CatalogSnapshot {
|
|
317
|
+
if (!cfg.ollama.enabled || ollamaCache.models.length === 0) return openrouterSnapshot as CatalogSnapshot;
|
|
318
|
+
return { ...mergeSnapshots(openrouterSnapshot as CatalogSnapshot, ollamaCache.models), providerBias: { ollama: cfg.ollama.costBias } };
|
|
319
|
+
}
|
|
320
|
+
const snapshotA = snapshotFor(cfgA);
|
|
321
|
+
const snapshotB = snapshotFor(cfgB);
|
|
322
|
+
const bySlug = new Map([...snapshotA.models, ...snapshotB.models].map((m) => [m.slug, m]));
|
|
307
323
|
const ledger = createLedger(db, cfgA);
|
|
308
324
|
|
|
309
325
|
const predicate = args.where === "" ? "" : ` AND (${args.where})`;
|
|
310
326
|
// Newest-first to honour --limit, then flipped to chronological so each row can
|
|
311
|
-
// see the dispatch that preceded it in its conversation.
|
|
327
|
+
// see the dispatch that preceded it in its conversation. Wasted attempts ride
|
|
328
|
+
// along so a served attempt > 0 can see what it escalated from.
|
|
312
329
|
const rows = (
|
|
313
330
|
db
|
|
314
331
|
.query(
|
|
315
|
-
`SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd, created_at_ms, error_kind
|
|
332
|
+
`SELECT id, conversation_key, turn, attempt, wasted, escalation_signal, requested_model, harness_id, slug, served_slug, tier, features, usage, reported_usd, predicted_usd, created_at_ms, error_kind
|
|
316
333
|
FROM ledger
|
|
317
|
-
WHERE features IS NOT NULL
|
|
334
|
+
WHERE features IS NOT NULL${predicate}
|
|
318
335
|
ORDER BY created_at_ms DESC LIMIT ?`,
|
|
319
336
|
)
|
|
320
337
|
.all(args.limit) as Row[]
|
|
@@ -334,110 +351,193 @@ function profileOf(cfg: RouterConfig, requested: string) {
|
|
|
334
351
|
return first;
|
|
335
352
|
}
|
|
336
353
|
|
|
354
|
+
/** The escalation context `turn.ts` would have passed to `route()` for a served attempt > 0. */
|
|
355
|
+
interface EscalationContext {
|
|
356
|
+
escalateFrom: Tier | undefined;
|
|
357
|
+
excludeSlugs: string[];
|
|
358
|
+
}
|
|
359
|
+
|
|
337
360
|
interface Outcome {
|
|
338
361
|
tier: Tier;
|
|
339
362
|
slug: string;
|
|
340
363
|
usd: number;
|
|
341
|
-
|
|
364
|
+
cold: boolean;
|
|
365
|
+
switched: boolean;
|
|
342
366
|
held: boolean;
|
|
343
|
-
|
|
344
|
-
hold: HoldState;
|
|
367
|
+
trail: VariantTrail;
|
|
345
368
|
}
|
|
346
369
|
|
|
347
|
-
function run(
|
|
370
|
+
function run(
|
|
371
|
+
cfg: RouterConfig,
|
|
372
|
+
snapshot: CatalogSnapshot,
|
|
373
|
+
row: Row,
|
|
374
|
+
usage: UsageCounts,
|
|
375
|
+
prior: PriorTurn | undefined,
|
|
376
|
+
trail: VariantTrail | undefined,
|
|
377
|
+
esc: EscalationContext,
|
|
378
|
+
): Outcome {
|
|
348
379
|
const f = featuresOf(row, usage.promptTokens);
|
|
349
380
|
const req = requestOf(row, f);
|
|
350
|
-
const state = stateOf(row, prior,
|
|
381
|
+
const state = stateOf(row, prior, trail, args.warmth);
|
|
382
|
+
let classification: Classification;
|
|
383
|
+
if (esc.escalateFrom !== undefined) {
|
|
384
|
+
// Mirrors router/index.ts: an escalation forces strictly upward.
|
|
385
|
+
const nextIdx = Math.min(TIER_ORDER.indexOf(esc.escalateFrom) + 1, TIER_ORDER.length - 1);
|
|
386
|
+
const forcedTier = TIER_ORDER[nextIdx] ?? esc.escalateFrom;
|
|
387
|
+
classification = {
|
|
388
|
+
tier: forcedTier,
|
|
389
|
+
task: classifyTask(f),
|
|
390
|
+
confidence: 1,
|
|
391
|
+
source: "escalation",
|
|
392
|
+
score: 1,
|
|
393
|
+
reasons: [`escalated from ${esc.escalateFrom} after attempt ${row.attempt - 1} was rejected`],
|
|
394
|
+
};
|
|
395
|
+
} else {
|
|
396
|
+
classification = scoreHeuristic(f, cfg);
|
|
397
|
+
}
|
|
351
398
|
const decision: Decision = select({
|
|
352
399
|
req,
|
|
353
400
|
features: f,
|
|
354
|
-
classification
|
|
401
|
+
classification,
|
|
355
402
|
profile: profileOf(cfg, row.requested_model),
|
|
356
403
|
state,
|
|
357
|
-
snapshot
|
|
404
|
+
snapshot,
|
|
358
405
|
ledger,
|
|
359
406
|
cfg,
|
|
360
|
-
|
|
407
|
+
// The row's own clock: cache warmth and hold windows are judged against
|
|
408
|
+
// when the turn happened, not against today. Passing Date.now() here made
|
|
409
|
+
// every replayed cache cold and every switch policy a no-op.
|
|
410
|
+
nowMs: row.created_at_ms,
|
|
411
|
+
...(esc.excludeSlugs.length === 0 ? {} : { excludeSlugs: esc.excludeSlugs }),
|
|
361
412
|
});
|
|
362
413
|
|
|
363
414
|
// Re-arm exactly as turn.ts does: only when the served tier CHANGED, because
|
|
364
415
|
// re-arming every turn extends the window forever and the router then never
|
|
365
|
-
// downgrades.
|
|
366
|
-
// retries, so escalation-lengthened holds are still absent.
|
|
367
|
-
// Only a dispatch that reaches the COMMIT path re-arms, as in turn.ts: an
|
|
416
|
+
// downgrades. Only a dispatch that reaches the COMMIT path re-arms: an
|
|
368
417
|
// aborted one never gets there, and 27% of rows abort (omp closing the
|
|
369
418
|
// stream once it has the tool calls). Re-arming on those inflated the hold
|
|
370
419
|
// count roughly 4x against what production recorded.
|
|
371
420
|
const committed = row.error_kind === null;
|
|
372
|
-
const
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
421
|
+
const escalated = esc.escalateFrom !== undefined;
|
|
422
|
+
const tierChanged = committed && (trail?.tier ?? null) !== decision.tier;
|
|
423
|
+
const nextTier = committed ? decision.tier : (trail?.tier ?? null);
|
|
424
|
+
const stickyUntilTurn = tierChanged ? row.turn + resolveHoldTurns(cfg, row.conversation_key, escalated).turns : (trail?.stickyUntilTurn ?? 0);
|
|
425
|
+
|
|
426
|
+
const prevSlug = args.warmth === "variant" ? (trail?.slug ?? null) : (prior?.slug ?? null);
|
|
427
|
+
const prevPrompt = args.warmth === "variant" ? (trail?.promptTokens ?? 0) : (prior?.promptTokens ?? 0);
|
|
428
|
+
const prevAt = args.warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0);
|
|
429
|
+
const warm = prevSlug === decision.slug && prevPrompt > 0 && row.created_at_ms - prevAt <= cfg.hysteresis.cacheWarmTtlMs;
|
|
430
|
+
const priced = repriceUsd(bySlug.get(decision.slug), usage, warm, prevPrompt, row.served_slug);
|
|
376
431
|
|
|
377
432
|
return {
|
|
378
433
|
tier: decision.tier,
|
|
379
434
|
slug: decision.slug,
|
|
380
|
-
usd:
|
|
435
|
+
usd: priced.usd,
|
|
436
|
+
cold: priced.cold,
|
|
437
|
+
switched: prevSlug !== null && prevSlug !== decision.slug,
|
|
381
438
|
held: decision.classification.source === "sticky",
|
|
382
|
-
|
|
439
|
+
trail: {
|
|
440
|
+
slug: decision.slug,
|
|
441
|
+
promptTokens: usage.promptTokens,
|
|
442
|
+
atMs: row.created_at_ms,
|
|
443
|
+
tier: nextTier,
|
|
444
|
+
stickyUntilTurn,
|
|
445
|
+
upgradeDeferredTier: decision.upgradeDeferred,
|
|
446
|
+
},
|
|
383
447
|
};
|
|
384
448
|
}
|
|
385
449
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
450
|
+
interface Tally {
|
|
451
|
+
slugs: Map<string, number>;
|
|
452
|
+
tiers: Map<string, number>;
|
|
453
|
+
usd: number;
|
|
454
|
+
switches: number;
|
|
455
|
+
switchUsd: number;
|
|
456
|
+
cold: number;
|
|
457
|
+
held: number;
|
|
458
|
+
deferred: number;
|
|
459
|
+
}
|
|
460
|
+
const tally = (): Tally => ({ slugs: new Map(), tiers: new Map(), usd: 0, switches: 0, switchUsd: 0, cold: 0, held: 0, deferred: 0 });
|
|
461
|
+
const A = tally();
|
|
462
|
+
const B = tally();
|
|
463
|
+
const REC = tally();
|
|
395
464
|
let fidelitySlug = 0;
|
|
396
465
|
let fidelityTier = 0;
|
|
397
466
|
let comparable = 0;
|
|
467
|
+
let escalationsReplayed = 0;
|
|
398
468
|
const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
|
|
399
469
|
const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
|
|
400
470
|
|
|
401
|
-
// Carries the RECORDED outcome of each conversation's previous dispatch
|
|
402
|
-
//
|
|
471
|
+
// Carries the RECORDED outcome of each conversation's previous served dispatch
|
|
472
|
+
// forward (spend, tier), plus each variant's own trail.
|
|
403
473
|
const priorByConv = new Map<string, PriorTurn>();
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
let heldA = 0;
|
|
409
|
-
let heldB = 0;
|
|
474
|
+
const trailA = new Map<string, VariantTrail>();
|
|
475
|
+
const trailB = new Map<string, VariantTrail>();
|
|
476
|
+
// Wasted attempts of the turn being replayed, keyed by conversation:turn.
|
|
477
|
+
const wastedByTurn = new Map<string, Row[]>();
|
|
410
478
|
|
|
411
479
|
for (const row of rows) {
|
|
480
|
+
const turnKey = `${row.conversation_key}:${row.turn}`;
|
|
481
|
+
if (row.wasted === 1) {
|
|
482
|
+
const list = wastedByTurn.get(turnKey) ?? [];
|
|
483
|
+
list.push(row);
|
|
484
|
+
wastedByTurn.set(turnKey, list);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
412
487
|
const u = JSON.parse(row.usage) as UsageCounts;
|
|
413
488
|
if (!(u.promptTokens > 0)) continue;
|
|
414
489
|
const prior = priorByConv.get(row.conversation_key);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
490
|
+
// Escalation context as turn.ts would have passed it: the last probe-rejected
|
|
491
|
+
// attempt's tier, and every failed attempt's slug excluded.
|
|
492
|
+
const wasted = (wastedByTurn.get(turnKey) ?? []).filter((w) => w.attempt < row.attempt);
|
|
493
|
+
const rejected = wasted.filter((w) => w.escalation_signal !== null);
|
|
494
|
+
const esc: EscalationContext = {
|
|
495
|
+
escalateFrom: rejected.length > 0 ? (rejected[rejected.length - 1]!.tier as Tier) : undefined,
|
|
496
|
+
excludeSlugs: wasted.map((w) => w.served_slug ?? w.slug),
|
|
497
|
+
};
|
|
498
|
+
if (esc.escalateFrom !== undefined) escalationsReplayed++;
|
|
499
|
+
const a = run(cfgA, snapshotA, row, u, prior, trailA.get(row.conversation_key), esc);
|
|
500
|
+
const b = run(cfgB, snapshotB, row, u, prior, trailB.get(row.conversation_key), esc);
|
|
501
|
+
trailA.set(row.conversation_key, a.trail);
|
|
502
|
+
trailB.set(row.conversation_key, b.trail);
|
|
503
|
+
|
|
504
|
+
const recordedSwitch = prior?.slug !== undefined && prior.slug !== null && row.served_slug !== null && prior.slug !== row.served_slug;
|
|
505
|
+
const recordedUsd = row.reported_usd ?? row.predicted_usd;
|
|
421
506
|
priorByConv.set(row.conversation_key, {
|
|
422
507
|
slug: row.served_slug,
|
|
423
508
|
tier: row.tier,
|
|
424
509
|
promptTokens: u.promptTokens,
|
|
425
510
|
cachedTokens: u.cachedTokens,
|
|
426
|
-
spentUsd: (prior?.spentUsd ?? 0) +
|
|
511
|
+
spentUsd: (prior?.spentUsd ?? 0) + recordedUsd,
|
|
427
512
|
atMs: row.created_at_ms,
|
|
428
513
|
});
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
514
|
+
|
|
515
|
+
for (const [t, o] of [
|
|
516
|
+
[A, a],
|
|
517
|
+
[B, b],
|
|
518
|
+
] as const) {
|
|
519
|
+
bump(t.slugs, o.slug);
|
|
520
|
+
bump(t.tiers, o.tier);
|
|
521
|
+
t.usd += o.usd;
|
|
522
|
+
if (o.switched) {
|
|
523
|
+
t.switches++;
|
|
524
|
+
t.switchUsd += o.usd;
|
|
525
|
+
}
|
|
526
|
+
if (o.cold) t.cold++;
|
|
527
|
+
if (o.held) t.held++;
|
|
528
|
+
if (o.trail.upgradeDeferredTier !== null) t.deferred++;
|
|
529
|
+
}
|
|
433
530
|
// The recorded outcome: what the router ACTUALLY did, under whatever code and
|
|
434
531
|
// config were live then. This is the yardstick for fidelity, and it is also
|
|
435
532
|
// how a shipped classifier change shows up — replay runs current code.
|
|
436
|
-
if (row.served_slug !== null) bump(
|
|
437
|
-
bump(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
533
|
+
if (row.served_slug !== null) bump(REC.slugs, row.served_slug);
|
|
534
|
+
bump(REC.tiers, row.tier);
|
|
535
|
+
REC.usd += recordedUsd;
|
|
536
|
+
if (recordedSwitch) {
|
|
537
|
+
REC.switches++;
|
|
538
|
+
REC.switchUsd += recordedUsd;
|
|
539
|
+
}
|
|
540
|
+
if (u.promptTokens > 20_000 && u.cachedTokens < 0.2 * u.promptTokens) REC.cold++;
|
|
441
541
|
comparable++;
|
|
442
542
|
if (row.served_slug !== null && row.served_slug === a.slug) fidelitySlug++;
|
|
443
543
|
if (row.tier === a.tier) fidelityTier++;
|
|
@@ -447,35 +547,45 @@ for (const row of rows) {
|
|
|
447
547
|
}
|
|
448
548
|
|
|
449
549
|
const pct = (n: number, d: number) => (d === 0 ? "0.0" : ((100 * n) / d).toFixed(1));
|
|
450
|
-
console.log(`\nreplayed ${comparable} dispatches from ${dbPath}`);
|
|
550
|
+
console.log(`\nreplayed ${comparable} dispatches from ${dbPath} (${escalationsReplayed} escalations as recorded; warmth: ${args.warmth})`);
|
|
551
|
+
console.log(`catalog: ${openrouterSnapshot.models.length} OpenRouter models${ollamaCache.models.length > 0 ? ` + ${ollamaCache.models.length} Ollama Cloud models` : ""}${cfgA.ollama.enabled ? "" : " (ollama disabled in config)"}`);
|
|
451
552
|
console.log(`variant A overrides: ${args.setA.length ? args.setA.join(" ") : "(config as-is)"}`);
|
|
452
553
|
console.log(`variant B overrides: ${args.setB.length ? args.setB.join(" ") : "(none — A and B identical)"}`);
|
|
453
554
|
console.log(`\nFIDELITY vs what actually ran:`);
|
|
454
555
|
console.log(` same model ${fidelitySlug}/${comparable} (${pct(fidelitySlug, comparable)}%) same tier ${fidelityTier}/${comparable} (${pct(fidelityTier, comparable)}%)`);
|
|
455
556
|
console.log(" Divergence is expected where code has changed since those rows were served");
|
|
456
557
|
console.log(" (replay runs CURRENT code); the rest is what replay cannot model.");
|
|
457
|
-
console.log(` hysteresis holds bound ${heldA} dispatches in A, ${heldB} in B (simulated per variant).`);
|
|
458
558
|
|
|
459
|
-
function table(label: string, rec: Map<string, number>,
|
|
460
|
-
const keys = [...new Set([...rec.keys(), ...
|
|
559
|
+
function table(label: string, rec: Map<string, number>, a: Map<string, number>, b: Map<string, number>) {
|
|
560
|
+
const keys = [...new Set([...rec.keys(), ...a.keys(), ...b.keys()])].sort((x, y) => (b.get(y) ?? 0) - (b.get(x) ?? 0));
|
|
461
561
|
console.log(`\n${label.padEnd(32)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
|
|
462
562
|
for (const k of keys) {
|
|
463
563
|
const r = rec.get(k) ?? 0;
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
const d =
|
|
467
|
-
console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(
|
|
564
|
+
const av = a.get(k) ?? 0;
|
|
565
|
+
const bv = b.get(k) ?? 0;
|
|
566
|
+
const d = bv - av;
|
|
567
|
+
console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(av).padStart(7)}${String(bv).padStart(7)}${(d > 0 ? `+${d}` : String(d)).padStart(7)}`);
|
|
468
568
|
}
|
|
469
569
|
}
|
|
470
|
-
table("tier",
|
|
471
|
-
table("model",
|
|
570
|
+
table("tier", REC.tiers, A.tiers, B.tiers);
|
|
571
|
+
table("model", REC.slugs, A.slugs, B.slugs);
|
|
572
|
+
|
|
573
|
+
console.log(`\ncache behaviour (per variant; "actual" cold = recorded <20% cached on a >20k prompt):`);
|
|
574
|
+
console.log(` ${"".padEnd(30)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
|
|
575
|
+
const line = (label: string, r: number, av: number, bv: number) =>
|
|
576
|
+
console.log(` ${label.padEnd(30)}${String(r).padStart(8)}${String(av).padStart(7)}${String(bv).padStart(7)}${(bv - av > 0 ? `+${bv - av}` : String(bv - av)).padStart(7)}`);
|
|
577
|
+
line("model switches", REC.switches, A.switches, B.switches);
|
|
578
|
+
line("cold-priced dispatches", REC.cold, A.cold, B.cold);
|
|
579
|
+
line("hysteresis holds", 0, A.held, B.held);
|
|
580
|
+
line("upgrades deferred", 0, A.deferred, B.deferred);
|
|
581
|
+
console.log(` spend on switch turns $${REC.switchUsd.toFixed(2).padStart(7)} $${A.switchUsd.toFixed(2).padStart(6)} $${B.switchUsd.toFixed(2).padStart(6)}`);
|
|
472
582
|
|
|
473
583
|
console.log(`\nspend, re-priced on RECORDED usage via the real computeCost:`);
|
|
474
|
-
console.log(` actual (billed) $${
|
|
475
|
-
console.log(` A $${
|
|
476
|
-
console.log(` B $${
|
|
477
|
-
const delta =
|
|
478
|
-
console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (
|
|
584
|
+
console.log(` actual (billed) $${REC.usd.toFixed(4)} per dispatch $${(REC.usd / comparable).toFixed(5)}`);
|
|
585
|
+
console.log(` A $${A.usd.toFixed(4)} per dispatch $${(A.usd / comparable).toFixed(5)}`);
|
|
586
|
+
console.log(` B $${B.usd.toFixed(4)} per dispatch $${(B.usd / comparable).toFixed(5)}`);
|
|
587
|
+
const delta = B.usd - A.usd;
|
|
588
|
+
console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (A.usd || 1)).toFixed(1)}%`})`);
|
|
479
589
|
console.log(`\ndecisions changed: ${flips.length}/${comparable} (${pct(flips.length, comparable)}%)`);
|
|
480
590
|
if (args.verbose) {
|
|
481
591
|
for (const f of flips.slice(0, 40)) {
|