auto-model-router 0.3.4 → 0.4.1
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 +29 -4
- package/omp-extension/report-logic.ts +93 -0
- package/omp-extension/router-configure.ts +128 -2
- package/omp-extension/router-embed.ts +9 -6
- package/package.json +1 -1
- package/src/catalog/ollama-catalog.ts +30 -2
- package/src/cli/config-wizard.ts +11 -0
- package/src/cli/report.ts +7 -2
- package/src/config/defaults.ts +17 -0
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +65 -0
- package/src/cost/feedback.ts +81 -0
- package/src/cost/ledger.ts +110 -5
- package/src/cost/report.ts +118 -2
- package/src/cost/types.ts +32 -1
- package/src/router/candidates.ts +13 -4
- package/src/router/classify.ts +13 -0
- package/src/router/features.ts +59 -1
- package/src/router/index.ts +23 -5
- package/src/router/learned.ts +202 -0
- package/src/router/select.ts +64 -8
- package/src/router/types.ts +39 -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/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +2 -0
- package/test/classify.test.ts +13 -0
- package/test/config-wizard.test.ts +8 -6
- package/test/controls.test.ts +238 -0
- package/test/escalate.test.ts +1 -0
- package/test/failover.test.ts +6 -4
- package/test/features.test.ts +63 -0
- package/test/http-resilience.test.ts +1 -1
- package/test/learned.test.ts +61 -0
- package/test/ollama.test.ts +74 -2
- package/test/report-hub.test.ts +6 -2
- package/test/report-logic.test.ts +3 -0
- package/test/report.test.ts +57 -0
- package/test/select.test.ts +126 -1
- package/test/trust-attribution.test.ts +95 -0
- package/test/turn.test.ts +36 -4
- package/tools/replay.ts +267 -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;
|
|
@@ -185,6 +197,7 @@ function requestOf(row: Row, f: Features): NormRequest {
|
|
|
185
197
|
harnessId: row.harness_id,
|
|
186
198
|
ompSessionId: "",
|
|
187
199
|
agentdoxScope: "",
|
|
200
|
+
isSubagent: false,
|
|
188
201
|
requestedModel: row.requested_model,
|
|
189
202
|
messages,
|
|
190
203
|
tools,
|
|
@@ -196,23 +209,34 @@ function requestOf(row: Row, f: Features): NormRequest {
|
|
|
196
209
|
};
|
|
197
210
|
}
|
|
198
211
|
|
|
212
|
+
/** The recorded outcome of a conversation's previous dispatch. */
|
|
213
|
+
interface PriorTurn {
|
|
214
|
+
slug: string | null;
|
|
215
|
+
tier: string;
|
|
216
|
+
promptTokens: number;
|
|
217
|
+
cachedTokens: number;
|
|
218
|
+
spentUsd: number;
|
|
219
|
+
atMs: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
/**
|
|
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.
|
|
223
|
+
* A variant's own trail through a conversation: the model it chose last, the
|
|
224
|
+
* prompt it saw, the tier it holds and until when, and the upgrade it deferred.
|
|
225
|
+
* Evolved PER VARIANT, because every one of these follows from the variant's
|
|
226
|
+
* own decisions; reading them from the recorded outcome would charge a variant
|
|
227
|
+
* for holds and caches it never created and hide the switches it made.
|
|
214
228
|
*/
|
|
215
|
-
|
|
229
|
+
interface VariantTrail {
|
|
230
|
+
slug: string | null;
|
|
231
|
+
promptTokens: number;
|
|
232
|
+
atMs: number;
|
|
233
|
+
tier: Tier | null;
|
|
234
|
+
stickyUntilTurn: number;
|
|
235
|
+
upgradeDeferredTier: Tier | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function stateOf(row: Row, prior: PriorTurn | undefined, trail: VariantTrail | undefined, warmth: Args["warmth"]): ConversationState {
|
|
239
|
+
const warmSlug = warmth === "variant" ? (trail?.slug ?? null) : prior?.cachedTokens !== undefined && prior.cachedTokens > 0 ? prior.slug : null;
|
|
216
240
|
return {
|
|
217
241
|
key: row.conversation_key,
|
|
218
242
|
sessionId: `omp-${row.conversation_key}`,
|
|
@@ -220,64 +244,49 @@ function stateOf(row: Row, prior: PriorTurn | undefined, hold: HoldState | undef
|
|
|
220
244
|
// state `select` sees carries the PREVIOUS turn number. Passing row.turn
|
|
221
245
|
// would expire every hold a turn early.
|
|
222
246
|
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,
|
|
247
|
+
currentSlug: warmth === "variant" ? (trail?.slug ?? null) : (prior?.slug ?? null),
|
|
248
|
+
currentTier: trail?.tier ?? ((prior?.tier as Tier | undefined) ?? null),
|
|
249
|
+
stickyUntilTurn: trail?.stickyUntilTurn ?? 0,
|
|
228
250
|
escalations: 0,
|
|
229
251
|
spentUsd: prior?.spentUsd ?? 0,
|
|
230
|
-
lastPromptTokens: prior?.promptTokens ?? 0,
|
|
231
|
-
cacheWarmSlug:
|
|
232
|
-
cacheWarmAtMs: prior?.atMs ?? 0,
|
|
252
|
+
lastPromptTokens: warmth === "variant" ? (trail?.promptTokens ?? 0) : (prior?.promptTokens ?? 0),
|
|
253
|
+
cacheWarmSlug: warmSlug,
|
|
254
|
+
cacheWarmAtMs: warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0),
|
|
233
255
|
contextVersion: null,
|
|
234
256
|
contextFetchedAtMs: 0,
|
|
235
257
|
// Compaction cannot be replanned offline (messages are not recorded), so
|
|
236
258
|
// replay carries no plan: forced off in the config it replays under.
|
|
237
259
|
compactionPlan: null,
|
|
238
|
-
|
|
260
|
+
upgradeDeferredTier: trail?.upgradeDeferredTier ?? null,
|
|
261
|
+
updatedAtMs: warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0),
|
|
239
262
|
};
|
|
240
263
|
}
|
|
241
264
|
|
|
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
265
|
/**
|
|
270
266
|
* Re-prices a decision against the tokens the turn ACTUALLY used, via the real
|
|
271
|
-
* `computeCost
|
|
272
|
-
*
|
|
267
|
+
* `computeCost`, with the cache split decided by the warmth model: under
|
|
268
|
+
* `variant` warmth a dispatch is warm only when the variant stayed on its own
|
|
269
|
+
* previous model within the TTL, and the previous prompt is the cached prefix.
|
|
273
270
|
*
|
|
274
271
|
* Deliberately NOT the router's own forecast: `candidates.ts` hardcodes
|
|
275
272
|
* `cacheHitRate: 0`, so forecasts overstate absolute cost ~2.8x. Pricing both
|
|
276
273
|
* variants off recorded usage keeps the delta apples-to-apples and grounded.
|
|
277
274
|
*/
|
|
278
|
-
function repriceUsd(
|
|
279
|
-
|
|
280
|
-
|
|
275
|
+
function repriceUsd(
|
|
276
|
+
model: CatalogModel | undefined,
|
|
277
|
+
usage: UsageCounts,
|
|
278
|
+
warm: boolean,
|
|
279
|
+
prevPromptTokens: number,
|
|
280
|
+
recordedSlug: string | null,
|
|
281
|
+
): { usd: number; cold: boolean } {
|
|
282
|
+
if (model === undefined) return { usd: 0, cold: false };
|
|
283
|
+
if (!warm) {
|
|
284
|
+
return { usd: computeCost(model, { ...usage, cachedTokens: 0, cacheWriteTokens: 0 }).total, cold: true };
|
|
285
|
+
}
|
|
286
|
+
// Warm: the recorded cache count when the recorded dispatch was this very
|
|
287
|
+
// model (the provider's own figure), else the previous prompt as prefix.
|
|
288
|
+
const cached = recordedSlug === model.slug && usage.cachedTokens > 0 ? usage.cachedTokens : Math.min(prevPromptTokens, usage.promptTokens);
|
|
289
|
+
return { usd: computeCost(model, { ...usage, cachedTokens: cached, cacheWriteTokens: 0 }).total, cold: false };
|
|
281
290
|
}
|
|
282
291
|
|
|
283
292
|
const DEAD_UPSTREAM: UpstreamClient = {
|
|
@@ -297,24 +306,33 @@ const cfgB = withOverrides(baseCfg, [...forced, ...args.setB]);
|
|
|
297
306
|
const dbPath = args.db !== "" ? args.db : baseCfg.ledger.path;
|
|
298
307
|
const db = new Database(dbPath, { readonly: true });
|
|
299
308
|
const catalog = createCatalog(cfgA, DEAD_UPSTREAM, db);
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
309
|
+
const openrouterSnapshot = catalog.peek();
|
|
310
|
+
if (openrouterSnapshot === null) {
|
|
302
311
|
console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
|
|
303
312
|
process.exit(2);
|
|
304
313
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
314
|
+
const ollamaCache = loadOllamaCatalogCache(db);
|
|
315
|
+
|
|
316
|
+
/** The composite snapshot a variant routes over, with its own Ollama bias stamped on. */
|
|
317
|
+
function snapshotFor(cfg: RouterConfig): CatalogSnapshot {
|
|
318
|
+
if (!cfg.ollama.enabled || ollamaCache.models.length === 0) return openrouterSnapshot as CatalogSnapshot;
|
|
319
|
+
return { ...mergeSnapshots(openrouterSnapshot as CatalogSnapshot, ollamaCache.models), providerBias: { ollama: cfg.ollama.costBias } };
|
|
320
|
+
}
|
|
321
|
+
const snapshotA = snapshotFor(cfgA);
|
|
322
|
+
const snapshotB = snapshotFor(cfgB);
|
|
323
|
+
const bySlug = new Map([...snapshotA.models, ...snapshotB.models].map((m) => [m.slug, m]));
|
|
307
324
|
const ledger = createLedger(db, cfgA);
|
|
308
325
|
|
|
309
326
|
const predicate = args.where === "" ? "" : ` AND (${args.where})`;
|
|
310
327
|
// Newest-first to honour --limit, then flipped to chronological so each row can
|
|
311
|
-
// see the dispatch that preceded it in its conversation.
|
|
328
|
+
// see the dispatch that preceded it in its conversation. Wasted attempts ride
|
|
329
|
+
// along so a served attempt > 0 can see what it escalated from.
|
|
312
330
|
const rows = (
|
|
313
331
|
db
|
|
314
332
|
.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
|
|
333
|
+
`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
334
|
FROM ledger
|
|
317
|
-
WHERE features IS NOT NULL
|
|
335
|
+
WHERE features IS NOT NULL${predicate}
|
|
318
336
|
ORDER BY created_at_ms DESC LIMIT ?`,
|
|
319
337
|
)
|
|
320
338
|
.all(args.limit) as Row[]
|
|
@@ -334,110 +352,193 @@ function profileOf(cfg: RouterConfig, requested: string) {
|
|
|
334
352
|
return first;
|
|
335
353
|
}
|
|
336
354
|
|
|
355
|
+
/** The escalation context `turn.ts` would have passed to `route()` for a served attempt > 0. */
|
|
356
|
+
interface EscalationContext {
|
|
357
|
+
escalateFrom: Tier | undefined;
|
|
358
|
+
excludeSlugs: string[];
|
|
359
|
+
}
|
|
360
|
+
|
|
337
361
|
interface Outcome {
|
|
338
362
|
tier: Tier;
|
|
339
363
|
slug: string;
|
|
340
364
|
usd: number;
|
|
341
|
-
|
|
365
|
+
cold: boolean;
|
|
366
|
+
switched: boolean;
|
|
342
367
|
held: boolean;
|
|
343
|
-
|
|
344
|
-
hold: HoldState;
|
|
368
|
+
trail: VariantTrail;
|
|
345
369
|
}
|
|
346
370
|
|
|
347
|
-
function run(
|
|
371
|
+
function run(
|
|
372
|
+
cfg: RouterConfig,
|
|
373
|
+
snapshot: CatalogSnapshot,
|
|
374
|
+
row: Row,
|
|
375
|
+
usage: UsageCounts,
|
|
376
|
+
prior: PriorTurn | undefined,
|
|
377
|
+
trail: VariantTrail | undefined,
|
|
378
|
+
esc: EscalationContext,
|
|
379
|
+
): Outcome {
|
|
348
380
|
const f = featuresOf(row, usage.promptTokens);
|
|
349
381
|
const req = requestOf(row, f);
|
|
350
|
-
const state = stateOf(row, prior,
|
|
382
|
+
const state = stateOf(row, prior, trail, args.warmth);
|
|
383
|
+
let classification: Classification;
|
|
384
|
+
if (esc.escalateFrom !== undefined) {
|
|
385
|
+
// Mirrors router/index.ts: an escalation forces strictly upward.
|
|
386
|
+
const nextIdx = Math.min(TIER_ORDER.indexOf(esc.escalateFrom) + 1, TIER_ORDER.length - 1);
|
|
387
|
+
const forcedTier = TIER_ORDER[nextIdx] ?? esc.escalateFrom;
|
|
388
|
+
classification = {
|
|
389
|
+
tier: forcedTier,
|
|
390
|
+
task: classifyTask(f),
|
|
391
|
+
confidence: 1,
|
|
392
|
+
source: "escalation",
|
|
393
|
+
score: 1,
|
|
394
|
+
reasons: [`escalated from ${esc.escalateFrom} after attempt ${row.attempt - 1} was rejected`],
|
|
395
|
+
};
|
|
396
|
+
} else {
|
|
397
|
+
classification = scoreHeuristic(f, cfg);
|
|
398
|
+
}
|
|
351
399
|
const decision: Decision = select({
|
|
352
400
|
req,
|
|
353
401
|
features: f,
|
|
354
|
-
classification
|
|
402
|
+
classification,
|
|
355
403
|
profile: profileOf(cfg, row.requested_model),
|
|
356
404
|
state,
|
|
357
|
-
snapshot
|
|
405
|
+
snapshot,
|
|
358
406
|
ledger,
|
|
359
407
|
cfg,
|
|
360
|
-
|
|
408
|
+
// The row's own clock: cache warmth and hold windows are judged against
|
|
409
|
+
// when the turn happened, not against today. Passing Date.now() here made
|
|
410
|
+
// every replayed cache cold and every switch policy a no-op.
|
|
411
|
+
nowMs: row.created_at_ms,
|
|
412
|
+
...(esc.excludeSlugs.length === 0 ? {} : { excludeSlugs: esc.excludeSlugs }),
|
|
361
413
|
});
|
|
362
414
|
|
|
363
415
|
// Re-arm exactly as turn.ts does: only when the served tier CHANGED, because
|
|
364
416
|
// 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
|
|
417
|
+
// downgrades. Only a dispatch that reaches the COMMIT path re-arms: an
|
|
368
418
|
// aborted one never gets there, and 27% of rows abort (omp closing the
|
|
369
419
|
// stream once it has the tool calls). Re-arming on those inflated the hold
|
|
370
420
|
// count roughly 4x against what production recorded.
|
|
371
421
|
const committed = row.error_kind === null;
|
|
372
|
-
const
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
422
|
+
const escalated = esc.escalateFrom !== undefined;
|
|
423
|
+
const tierChanged = committed && (trail?.tier ?? null) !== decision.tier;
|
|
424
|
+
const nextTier = committed ? decision.tier : (trail?.tier ?? null);
|
|
425
|
+
const stickyUntilTurn = tierChanged ? row.turn + resolveHoldTurns(cfg, row.conversation_key, escalated).turns : (trail?.stickyUntilTurn ?? 0);
|
|
426
|
+
|
|
427
|
+
const prevSlug = args.warmth === "variant" ? (trail?.slug ?? null) : (prior?.slug ?? null);
|
|
428
|
+
const prevPrompt = args.warmth === "variant" ? (trail?.promptTokens ?? 0) : (prior?.promptTokens ?? 0);
|
|
429
|
+
const prevAt = args.warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0);
|
|
430
|
+
const warm = prevSlug === decision.slug && prevPrompt > 0 && row.created_at_ms - prevAt <= cfg.hysteresis.cacheWarmTtlMs;
|
|
431
|
+
const priced = repriceUsd(bySlug.get(decision.slug), usage, warm, prevPrompt, row.served_slug);
|
|
376
432
|
|
|
377
433
|
return {
|
|
378
434
|
tier: decision.tier,
|
|
379
435
|
slug: decision.slug,
|
|
380
|
-
usd:
|
|
436
|
+
usd: priced.usd,
|
|
437
|
+
cold: priced.cold,
|
|
438
|
+
switched: prevSlug !== null && prevSlug !== decision.slug,
|
|
381
439
|
held: decision.classification.source === "sticky",
|
|
382
|
-
|
|
440
|
+
trail: {
|
|
441
|
+
slug: decision.slug,
|
|
442
|
+
promptTokens: usage.promptTokens,
|
|
443
|
+
atMs: row.created_at_ms,
|
|
444
|
+
tier: nextTier,
|
|
445
|
+
stickyUntilTurn,
|
|
446
|
+
upgradeDeferredTier: decision.upgradeDeferred,
|
|
447
|
+
},
|
|
383
448
|
};
|
|
384
449
|
}
|
|
385
450
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
451
|
+
interface Tally {
|
|
452
|
+
slugs: Map<string, number>;
|
|
453
|
+
tiers: Map<string, number>;
|
|
454
|
+
usd: number;
|
|
455
|
+
switches: number;
|
|
456
|
+
switchUsd: number;
|
|
457
|
+
cold: number;
|
|
458
|
+
held: number;
|
|
459
|
+
deferred: number;
|
|
460
|
+
}
|
|
461
|
+
const tally = (): Tally => ({ slugs: new Map(), tiers: new Map(), usd: 0, switches: 0, switchUsd: 0, cold: 0, held: 0, deferred: 0 });
|
|
462
|
+
const A = tally();
|
|
463
|
+
const B = tally();
|
|
464
|
+
const REC = tally();
|
|
395
465
|
let fidelitySlug = 0;
|
|
396
466
|
let fidelityTier = 0;
|
|
397
467
|
let comparable = 0;
|
|
468
|
+
let escalationsReplayed = 0;
|
|
398
469
|
const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
|
|
399
470
|
const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
|
|
400
471
|
|
|
401
|
-
// Carries the RECORDED outcome of each conversation's previous dispatch
|
|
402
|
-
//
|
|
472
|
+
// Carries the RECORDED outcome of each conversation's previous served dispatch
|
|
473
|
+
// forward (spend, tier), plus each variant's own trail.
|
|
403
474
|
const priorByConv = new Map<string, PriorTurn>();
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
let heldA = 0;
|
|
409
|
-
let heldB = 0;
|
|
475
|
+
const trailA = new Map<string, VariantTrail>();
|
|
476
|
+
const trailB = new Map<string, VariantTrail>();
|
|
477
|
+
// Wasted attempts of the turn being replayed, keyed by conversation:turn.
|
|
478
|
+
const wastedByTurn = new Map<string, Row[]>();
|
|
410
479
|
|
|
411
480
|
for (const row of rows) {
|
|
481
|
+
const turnKey = `${row.conversation_key}:${row.turn}`;
|
|
482
|
+
if (row.wasted === 1) {
|
|
483
|
+
const list = wastedByTurn.get(turnKey) ?? [];
|
|
484
|
+
list.push(row);
|
|
485
|
+
wastedByTurn.set(turnKey, list);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
412
488
|
const u = JSON.parse(row.usage) as UsageCounts;
|
|
413
489
|
if (!(u.promptTokens > 0)) continue;
|
|
414
490
|
const prior = priorByConv.get(row.conversation_key);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
491
|
+
// Escalation context as turn.ts would have passed it: the last probe-rejected
|
|
492
|
+
// attempt's tier, and every failed attempt's slug excluded.
|
|
493
|
+
const wasted = (wastedByTurn.get(turnKey) ?? []).filter((w) => w.attempt < row.attempt);
|
|
494
|
+
const rejected = wasted.filter((w) => w.escalation_signal !== null);
|
|
495
|
+
const esc: EscalationContext = {
|
|
496
|
+
escalateFrom: rejected.length > 0 ? (rejected[rejected.length - 1]!.tier as Tier) : undefined,
|
|
497
|
+
excludeSlugs: wasted.map((w) => w.served_slug ?? w.slug),
|
|
498
|
+
};
|
|
499
|
+
if (esc.escalateFrom !== undefined) escalationsReplayed++;
|
|
500
|
+
const a = run(cfgA, snapshotA, row, u, prior, trailA.get(row.conversation_key), esc);
|
|
501
|
+
const b = run(cfgB, snapshotB, row, u, prior, trailB.get(row.conversation_key), esc);
|
|
502
|
+
trailA.set(row.conversation_key, a.trail);
|
|
503
|
+
trailB.set(row.conversation_key, b.trail);
|
|
504
|
+
|
|
505
|
+
const recordedSwitch = prior?.slug !== undefined && prior.slug !== null && row.served_slug !== null && prior.slug !== row.served_slug;
|
|
506
|
+
const recordedUsd = row.reported_usd ?? row.predicted_usd;
|
|
421
507
|
priorByConv.set(row.conversation_key, {
|
|
422
508
|
slug: row.served_slug,
|
|
423
509
|
tier: row.tier,
|
|
424
510
|
promptTokens: u.promptTokens,
|
|
425
511
|
cachedTokens: u.cachedTokens,
|
|
426
|
-
spentUsd: (prior?.spentUsd ?? 0) +
|
|
512
|
+
spentUsd: (prior?.spentUsd ?? 0) + recordedUsd,
|
|
427
513
|
atMs: row.created_at_ms,
|
|
428
514
|
});
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
515
|
+
|
|
516
|
+
for (const [t, o] of [
|
|
517
|
+
[A, a],
|
|
518
|
+
[B, b],
|
|
519
|
+
] as const) {
|
|
520
|
+
bump(t.slugs, o.slug);
|
|
521
|
+
bump(t.tiers, o.tier);
|
|
522
|
+
t.usd += o.usd;
|
|
523
|
+
if (o.switched) {
|
|
524
|
+
t.switches++;
|
|
525
|
+
t.switchUsd += o.usd;
|
|
526
|
+
}
|
|
527
|
+
if (o.cold) t.cold++;
|
|
528
|
+
if (o.held) t.held++;
|
|
529
|
+
if (o.trail.upgradeDeferredTier !== null) t.deferred++;
|
|
530
|
+
}
|
|
433
531
|
// The recorded outcome: what the router ACTUALLY did, under whatever code and
|
|
434
532
|
// config were live then. This is the yardstick for fidelity, and it is also
|
|
435
533
|
// how a shipped classifier change shows up — replay runs current code.
|
|
436
|
-
if (row.served_slug !== null) bump(
|
|
437
|
-
bump(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
534
|
+
if (row.served_slug !== null) bump(REC.slugs, row.served_slug);
|
|
535
|
+
bump(REC.tiers, row.tier);
|
|
536
|
+
REC.usd += recordedUsd;
|
|
537
|
+
if (recordedSwitch) {
|
|
538
|
+
REC.switches++;
|
|
539
|
+
REC.switchUsd += recordedUsd;
|
|
540
|
+
}
|
|
541
|
+
if (u.promptTokens > 20_000 && u.cachedTokens < 0.2 * u.promptTokens) REC.cold++;
|
|
441
542
|
comparable++;
|
|
442
543
|
if (row.served_slug !== null && row.served_slug === a.slug) fidelitySlug++;
|
|
443
544
|
if (row.tier === a.tier) fidelityTier++;
|
|
@@ -447,35 +548,45 @@ for (const row of rows) {
|
|
|
447
548
|
}
|
|
448
549
|
|
|
449
550
|
const pct = (n: number, d: number) => (d === 0 ? "0.0" : ((100 * n) / d).toFixed(1));
|
|
450
|
-
console.log(`\nreplayed ${comparable} dispatches from ${dbPath}`);
|
|
551
|
+
console.log(`\nreplayed ${comparable} dispatches from ${dbPath} (${escalationsReplayed} escalations as recorded; warmth: ${args.warmth})`);
|
|
552
|
+
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
553
|
console.log(`variant A overrides: ${args.setA.length ? args.setA.join(" ") : "(config as-is)"}`);
|
|
452
554
|
console.log(`variant B overrides: ${args.setB.length ? args.setB.join(" ") : "(none — A and B identical)"}`);
|
|
453
555
|
console.log(`\nFIDELITY vs what actually ran:`);
|
|
454
556
|
console.log(` same model ${fidelitySlug}/${comparable} (${pct(fidelitySlug, comparable)}%) same tier ${fidelityTier}/${comparable} (${pct(fidelityTier, comparable)}%)`);
|
|
455
557
|
console.log(" Divergence is expected where code has changed since those rows were served");
|
|
456
558
|
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
559
|
|
|
459
|
-
function table(label: string, rec: Map<string, number>,
|
|
460
|
-
const keys = [...new Set([...rec.keys(), ...
|
|
560
|
+
function table(label: string, rec: Map<string, number>, a: Map<string, number>, b: Map<string, number>) {
|
|
561
|
+
const keys = [...new Set([...rec.keys(), ...a.keys(), ...b.keys()])].sort((x, y) => (b.get(y) ?? 0) - (b.get(x) ?? 0));
|
|
461
562
|
console.log(`\n${label.padEnd(32)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
|
|
462
563
|
for (const k of keys) {
|
|
463
564
|
const r = rec.get(k) ?? 0;
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
const d =
|
|
467
|
-
console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(
|
|
565
|
+
const av = a.get(k) ?? 0;
|
|
566
|
+
const bv = b.get(k) ?? 0;
|
|
567
|
+
const d = bv - av;
|
|
568
|
+
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
569
|
}
|
|
469
570
|
}
|
|
470
|
-
table("tier",
|
|
471
|
-
table("model",
|
|
571
|
+
table("tier", REC.tiers, A.tiers, B.tiers);
|
|
572
|
+
table("model", REC.slugs, A.slugs, B.slugs);
|
|
573
|
+
|
|
574
|
+
console.log(`\ncache behaviour (per variant; "actual" cold = recorded <20% cached on a >20k prompt):`);
|
|
575
|
+
console.log(` ${"".padEnd(30)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
|
|
576
|
+
const line = (label: string, r: number, av: number, bv: number) =>
|
|
577
|
+
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)}`);
|
|
578
|
+
line("model switches", REC.switches, A.switches, B.switches);
|
|
579
|
+
line("cold-priced dispatches", REC.cold, A.cold, B.cold);
|
|
580
|
+
line("hysteresis holds", 0, A.held, B.held);
|
|
581
|
+
line("upgrades deferred", 0, A.deferred, B.deferred);
|
|
582
|
+
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
583
|
|
|
473
584
|
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) / (
|
|
585
|
+
console.log(` actual (billed) $${REC.usd.toFixed(4)} per dispatch $${(REC.usd / comparable).toFixed(5)}`);
|
|
586
|
+
console.log(` A $${A.usd.toFixed(4)} per dispatch $${(A.usd / comparable).toFixed(5)}`);
|
|
587
|
+
console.log(` B $${B.usd.toFixed(4)} per dispatch $${(B.usd / comparable).toFixed(5)}`);
|
|
588
|
+
const delta = B.usd - A.usd;
|
|
589
|
+
console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (A.usd || 1)).toFixed(1)}%`})`);
|
|
479
590
|
console.log(`\ndecisions changed: ${flips.length}/${comparable} (${pct(flips.length, comparable)}%)`);
|
|
480
591
|
if (args.verbose) {
|
|
481
592
|
for (const f of flips.slice(0, 40)) {
|