auto-model-router 0.3.3 → 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 +25 -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 +9 -0
- package/src/cli/report.ts +7 -2
- package/src/config/defaults.ts +12 -0
- package/src/config/schema.ts +6 -0
- package/src/config/types.ts +54 -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 +107 -8
- package/src/router/state.ts +6 -2
- package/src/router/types.ts +40 -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 +18 -1
- package/src/upstream/ollama-usage.ts +79 -2
- package/src/util/sqlite.ts +38 -1
- package/test/config-wizard.test.ts +2 -1
- package/test/controls.test.ts +223 -0
- package/test/failover.test.ts +5 -3
- 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 +180 -1
- package/test/trust-attribution.test.ts +58 -2
- package/test/turn.test.ts +35 -3
- package/tools/replay.ts +266 -156
- package/tools/train-classifier.ts +111 -0
package/src/config/types.ts
CHANGED
|
@@ -264,6 +264,14 @@ export interface FilterConfig {
|
|
|
264
264
|
latencyReferenceTokensPerSec: number;
|
|
265
265
|
/** Streamed samples required before latency is scored against a model. */
|
|
266
266
|
latencyMinSamples: number;
|
|
267
|
+
/**
|
|
268
|
+
* Warm-expected samples a model needs before its observed cache hit rate
|
|
269
|
+
* (ledger `cacheReliability`) discounts the "stay warm" price in the
|
|
270
|
+
* stay/switch comparison. Below it, and when 0, a cache is assumed fully
|
|
271
|
+
* reliable. Measured 2026-09-06: same-model short-gap turns still ran cold
|
|
272
|
+
* 5-6% on glm/gemini, 11% on ling and 50% on nex.
|
|
273
|
+
*/
|
|
274
|
+
cacheReliabilityMinSamples: number;
|
|
267
275
|
/**
|
|
268
276
|
* Absolute expected-wait ceiling (ms). A hard drop, mirroring the price
|
|
269
277
|
* ceiling: any model whose expected total wait (TTFT + streaming the expected
|
|
@@ -277,6 +285,13 @@ export interface FilterConfig {
|
|
|
277
285
|
* Undefined ⇒ off (the default).
|
|
278
286
|
*/
|
|
279
287
|
maxExpectedWaitMs?: number;
|
|
288
|
+
/**
|
|
289
|
+
* Latency weight for tool-result continuations (the agent loop's own
|
|
290
|
+
* follow-ups, where no person is waiting on first token). Unset ⇒
|
|
291
|
+
* `latencyWeight` applies to every turn. Lower it to spend speed only on
|
|
292
|
+
* user-facing turns.
|
|
293
|
+
*/
|
|
294
|
+
latencyWeightContinuation?: number;
|
|
280
295
|
/**
|
|
281
296
|
* How much of a model's measured escalation risk to price into its effective
|
|
282
297
|
* cost, 0-1. 0 (the default) disables the term.
|
|
@@ -305,6 +320,13 @@ export interface ClassifierConfig {
|
|
|
305
320
|
ambiguityThreshold: number;
|
|
306
321
|
/** Slug used for adjudication. Must be cheap and fast. */
|
|
307
322
|
model: string;
|
|
323
|
+
/**
|
|
324
|
+
* Path of a model written by `tools/train-classifier.ts`. When set, every
|
|
325
|
+
* heuristic classification also carries the learned P(escalate) in its
|
|
326
|
+
* reasons (`learned: p(escalate)=…`) and `Classification.learnedRisk`.
|
|
327
|
+
* Advisory: it never moves a tier. Empty ⇒ off.
|
|
328
|
+
*/
|
|
329
|
+
learnedModelPath: string;
|
|
308
330
|
/** Skip adjudication when it would exceed this fraction of the forecast turn cost. */
|
|
309
331
|
maxCostFraction: number;
|
|
310
332
|
/** Absolute per-call ceiling, USD. */
|
|
@@ -392,6 +414,19 @@ export interface HysteresisConfig {
|
|
|
392
414
|
* dispatches per user-visible turn, so single digits are conservative.
|
|
393
415
|
*/
|
|
394
416
|
switchHorizonTurns: number;
|
|
417
|
+
/**
|
|
418
|
+
* A heuristic tier UPGRADE whose classification confidence is below this
|
|
419
|
+
* waits one turn when the current model's cache is warm; a second
|
|
420
|
+
* consecutive upgrade classification confirms it. 0 disables. Escalations,
|
|
421
|
+
* explicit high reasoning and failing tool loops bypass the wait.
|
|
422
|
+
*
|
|
423
|
+
* Measured on 7 days of live traffic: 65 of 67 moderate→hard upgrades
|
|
424
|
+
* bounced back within 3 turns, 50 of them below 0.6 confidence, costing
|
|
425
|
+
* $17.90 in cold hard-tier prompt reads against $0.23 for staying warm.
|
|
426
|
+
* The stay/switch comparison never sees these because the warm cheap
|
|
427
|
+
* model is below the new tier's floor.
|
|
428
|
+
*/
|
|
429
|
+
confirmUpgradesBelowConfidence: number;
|
|
395
430
|
/** Assume a warm cache expires after this long. OpenRouter sticky sessions: 5-10 min. */
|
|
396
431
|
cacheWarmTtlMs: number;
|
|
397
432
|
/** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
|
|
@@ -495,6 +530,17 @@ export interface CacheConfig {
|
|
|
495
530
|
milestoneTokens: number;
|
|
496
531
|
}
|
|
497
532
|
|
|
533
|
+
/** Usage-report options. */
|
|
534
|
+
export interface ReportConfig {
|
|
535
|
+
/**
|
|
536
|
+
* Models to price the window's traffic on as if every turn had used that
|
|
537
|
+
* one model, at its list price with the window's own cache hit rate: the
|
|
538
|
+
* "what the router saved" counterfactual. Slugs missing from the catalog
|
|
539
|
+
* are skipped.
|
|
540
|
+
*/
|
|
541
|
+
baselines: string[];
|
|
542
|
+
}
|
|
543
|
+
|
|
498
544
|
export interface BudgetConfig {
|
|
499
545
|
/** Reject or downgrade when a turn's cold forecast exceeds this, USD. */
|
|
500
546
|
perTurnUsd?: number;
|
|
@@ -502,6 +548,13 @@ export interface BudgetConfig {
|
|
|
502
548
|
perConversationUsd?: number;
|
|
503
549
|
/** Rolling 24h ceiling, USD. */
|
|
504
550
|
perDayUsd?: number;
|
|
551
|
+
/**
|
|
552
|
+
* Calendar-month (UTC) target, USD. Paced: the per-day ceiling becomes
|
|
553
|
+
* min(perDayUsd, remaining ÷ days left in the month), so a month that runs
|
|
554
|
+
* ahead of pace tightens automatically instead of failing on its last day.
|
|
555
|
+
* Scoped per harness like perDayUsd.
|
|
556
|
+
*/
|
|
557
|
+
perMonthUsd?: number;
|
|
505
558
|
/** At the ceiling: drop to the cheapest viable model, or fail the request outright. */
|
|
506
559
|
onExceeded: "downgrade" | "reject";
|
|
507
560
|
}
|
|
@@ -663,6 +716,7 @@ export interface RouterConfig {
|
|
|
663
716
|
context: ContextConfig;
|
|
664
717
|
compaction: CompactionConfig;
|
|
665
718
|
budget: BudgetConfig;
|
|
719
|
+
report: ReportConfig;
|
|
666
720
|
profiles: ProfileConfig[];
|
|
667
721
|
ledger: LedgerConfig;
|
|
668
722
|
/**
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User verdicts on routed turns, from omp (`/router feedback good|bad`).
|
|
3
|
+
*
|
|
4
|
+
* The router otherwise learns only from escalation signals. A person saying
|
|
5
|
+
* a cheap model's answer was wrong — or that it was fine — is the label the
|
|
6
|
+
* de-escalation question needs. Each verdict is tied to the ledger row it
|
|
7
|
+
* judged, so it aggregates by served model, tier and task.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
|
|
12
|
+
export type Verdict = "good" | "bad";
|
|
13
|
+
|
|
14
|
+
export interface FeedbackRecord {
|
|
15
|
+
ledgerId: string;
|
|
16
|
+
ompSessionId: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
tier: string;
|
|
19
|
+
verdict: Verdict;
|
|
20
|
+
note: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FeedbackCounts {
|
|
24
|
+
good: number;
|
|
25
|
+
bad: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FeedbackStore {
|
|
29
|
+
record(rec: FeedbackRecord, nowMs?: number): string;
|
|
30
|
+
/** Verdict counts per served slug since `sinceMs`. */
|
|
31
|
+
countsBySlug(sinceMs: number, harnessId?: string): Map<string, FeedbackCounts>;
|
|
32
|
+
/** Verdicts for one ledger row (a user may re-judge). */
|
|
33
|
+
forLedgerId(ledgerId: string): Array<{ verdict: Verdict; note: string; createdAtMs: number }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createFeedbackStore(db: Database): FeedbackStore {
|
|
37
|
+
const insert = db.query(
|
|
38
|
+
`INSERT INTO feedback (id, ledger_id, omp_session_id, slug, tier, verdict, note, created_at_ms)
|
|
39
|
+
VALUES ($id, $ledgerId, $ompSessionId, $slug, $tier, $verdict, $note, $createdAtMs)`,
|
|
40
|
+
);
|
|
41
|
+
const bySlug = db.query(
|
|
42
|
+
`SELECT f.slug, f.verdict, COUNT(*) AS n FROM feedback f
|
|
43
|
+
LEFT JOIN ledger l ON l.id = f.ledger_id
|
|
44
|
+
WHERE f.created_at_ms >= $since AND ($harness = '' OR l.harness_id = $harness)
|
|
45
|
+
GROUP BY f.slug, f.verdict`,
|
|
46
|
+
);
|
|
47
|
+
const forRow = db.query("SELECT verdict, note, created_at_ms FROM feedback WHERE ledger_id = ? ORDER BY created_at_ms DESC");
|
|
48
|
+
return {
|
|
49
|
+
record(rec, nowMs = Date.now()) {
|
|
50
|
+
const id = crypto.randomUUID();
|
|
51
|
+
insert.run({
|
|
52
|
+
$id: id,
|
|
53
|
+
$ledgerId: rec.ledgerId,
|
|
54
|
+
$ompSessionId: rec.ompSessionId,
|
|
55
|
+
$slug: rec.slug,
|
|
56
|
+
$tier: rec.tier,
|
|
57
|
+
$verdict: rec.verdict,
|
|
58
|
+
$note: rec.note.slice(0, 500),
|
|
59
|
+
$createdAtMs: nowMs,
|
|
60
|
+
});
|
|
61
|
+
return id;
|
|
62
|
+
},
|
|
63
|
+
countsBySlug(sinceMs, harnessId = "") {
|
|
64
|
+
const out = new Map<string, FeedbackCounts>();
|
|
65
|
+
for (const r of bySlug.all({ $since: sinceMs, $harness: harnessId }) as { slug: string; verdict: string; n: number }[]) {
|
|
66
|
+
const c = out.get(r.slug) ?? { good: 0, bad: 0 };
|
|
67
|
+
if (r.verdict === "good") c.good += r.n;
|
|
68
|
+
else c.bad += r.n;
|
|
69
|
+
out.set(r.slug, c);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
},
|
|
73
|
+
forLedgerId(ledgerId) {
|
|
74
|
+
return (forRow.all(ledgerId) as { verdict: Verdict; note: string; created_at_ms: number }[]).map((r) => ({
|
|
75
|
+
verdict: r.verdict,
|
|
76
|
+
note: r.note,
|
|
77
|
+
createdAtMs: r.created_at_ms,
|
|
78
|
+
}));
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/cost/ledger.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
Ledger,
|
|
25
25
|
LedgerEntry,
|
|
26
26
|
LedgerSignals,
|
|
27
|
+
ModelCacheReliability,
|
|
27
28
|
ModelLatency,
|
|
28
29
|
ModelTrust,
|
|
29
30
|
UsageCounts,
|
|
@@ -38,6 +39,14 @@ const MAX_SANE_BYTES_PER_TOKEN = 8;
|
|
|
38
39
|
const MIN_ESCALATION_SAMPLES = 10;
|
|
39
40
|
/** The escalation-cost aggregate scans a window of rows; memoised for this long. */
|
|
40
41
|
const ESCALATION_COST_MEMO_MS = 60_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cache reliability is one window-function pass over the newest rows, memoised
|
|
45
|
+
* for a minute: the previous kept turn of each conversation is found with LAG,
|
|
46
|
+
* and a row counts when that turn was on the same model within the warm TTL.
|
|
47
|
+
*/
|
|
48
|
+
const CACHE_RELIABILITY_ROWS = 6_000;
|
|
49
|
+
const CACHE_RELIABILITY_MEMO_MS = 60_000;
|
|
41
50
|
const DAY_MS = 86_400_000;
|
|
42
51
|
|
|
43
52
|
// Row shapes below are fixed by our own schema in util/sqlite.ts.
|
|
@@ -236,7 +245,42 @@ function toEntry(row: LedgerRow): LedgerEntry {
|
|
|
236
245
|
};
|
|
237
246
|
}
|
|
238
247
|
|
|
248
|
+
export interface CacheReliabilityRow {
|
|
249
|
+
slug: string;
|
|
250
|
+
samples: number;
|
|
251
|
+
hit: number;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Observed cache hit rates when a warm cache was expected, per served slug.
|
|
256
|
+
* `sinceMs` bounds the rows scanned (0 ⇒ the newest `limitRows`). Rows whose
|
|
257
|
+
* cache count the router estimated (`usage.cachedEstimated`) are excluded.
|
|
258
|
+
*/
|
|
259
|
+
export function queryCacheReliability(db: Database, opts: { warmTtlMs: number; sinceMs?: number; limitRows?: number }): CacheReliabilityRow[] {
|
|
260
|
+
const sinceMs = opts.sinceMs ?? 0;
|
|
261
|
+
const limitRows = opts.limitRows ?? CACHE_RELIABILITY_ROWS;
|
|
262
|
+
return db
|
|
263
|
+
.query(
|
|
264
|
+
`WITH recent AS (
|
|
265
|
+
SELECT conversation_key AS ck, created_at_ms AS t, COALESCE(served_slug, slug) AS s,
|
|
266
|
+
json_extract(usage, '$.promptTokens') AS p, json_extract(usage, '$.cachedTokens') AS c,
|
|
267
|
+
COALESCE(json_extract(usage, '$.cachedEstimated'), 0) AS est
|
|
268
|
+
FROM ledger WHERE wasted = 0 AND error IS NULL AND created_at_ms >= $since
|
|
269
|
+
ORDER BY created_at_ms DESC LIMIT $limit),
|
|
270
|
+
seq AS (
|
|
271
|
+
SELECT s, p, c, est, t,
|
|
272
|
+
LAG(s) OVER w AS prev_s, LAG(p) OVER w AS prev_p, LAG(t) OVER w AS prev_t
|
|
273
|
+
FROM recent WINDOW w AS (PARTITION BY ck ORDER BY t))
|
|
274
|
+
SELECT s AS slug, COUNT(*) AS samples, AVG(MIN(1.0, c * 1.0 / MIN(prev_p, p))) AS hit
|
|
275
|
+
FROM seq
|
|
276
|
+
WHERE prev_s = s AND p > 1000 AND prev_p > 1000 AND t - prev_t <= $ttl AND est = 0
|
|
277
|
+
GROUP BY s`,
|
|
278
|
+
)
|
|
279
|
+
.all({ $since: sinceMs, $limit: limitRows, $ttl: opts.warmTtlMs }) as CacheReliabilityRow[];
|
|
280
|
+
}
|
|
281
|
+
|
|
239
282
|
export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
283
|
+
let cacheMemo: { atMs: number; map: Map<string, ModelCacheReliability> } | null = null;
|
|
240
284
|
// Prepared once: record() runs on every turn.
|
|
241
285
|
const insertStmt = db.query(
|
|
242
286
|
`INSERT INTO ledger (
|
|
@@ -276,6 +320,10 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
276
320
|
);
|
|
277
321
|
const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
|
|
278
322
|
const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
|
|
323
|
+
const providerSpendStmt = db.query(
|
|
324
|
+
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
|
|
325
|
+
);
|
|
326
|
+
const sessionStmt = db.query("SELECT * FROM ledger WHERE omp_session_id = ? AND wasted = 0 ORDER BY created_at_ms DESC LIMIT ?");
|
|
279
327
|
// What an escalated retry actually bills, per prompt token, over a window.
|
|
280
328
|
// attempt > 0 rows are the re-dispatches that followed a rejected attempt;
|
|
281
329
|
// errored ones carry no usage and are excluded.
|
|
@@ -435,6 +483,18 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
435
483
|
return out;
|
|
436
484
|
},
|
|
437
485
|
|
|
486
|
+
cacheReliability(slug: string): ModelCacheReliability | null {
|
|
487
|
+
const now = Date.now();
|
|
488
|
+
if (cacheMemo === null || now - cacheMemo.atMs > CACHE_RELIABILITY_MEMO_MS) {
|
|
489
|
+
const map = new Map<string, ModelCacheReliability>();
|
|
490
|
+
for (const r of queryCacheReliability(db, { warmTtlMs: cfg.hysteresis.cacheWarmTtlMs })) {
|
|
491
|
+
map.set(r.slug, { slug: r.slug, samples: r.samples, hitRate: Math.min(1, Math.max(0, r.hit)) });
|
|
492
|
+
}
|
|
493
|
+
cacheMemo = { atMs: now, map };
|
|
494
|
+
}
|
|
495
|
+
return cacheMemo.map.get(slug) ?? null;
|
|
496
|
+
},
|
|
497
|
+
|
|
438
498
|
escalationCost(windowDays: number): EscalationCost | null {
|
|
439
499
|
const now = Date.now();
|
|
440
500
|
if (escalationMemo !== null && escalationMemo.windowDays === windowDays && now - escalationMemo.atMs < ESCALATION_COST_MEMO_MS) {
|
|
@@ -459,5 +519,18 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
459
519
|
const rows = recentStmt.all(limit) as LedgerRow[];
|
|
460
520
|
return rows.map(toEntry);
|
|
461
521
|
},
|
|
522
|
+
providerSpendSince(slugPrefix: string, sinceMs: number): number {
|
|
523
|
+
const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
|
|
524
|
+
return row?.total ?? 0;
|
|
525
|
+
},
|
|
526
|
+
latestForSession(ompSessionId: string): LedgerEntry | null {
|
|
527
|
+
if (ompSessionId === "") return null;
|
|
528
|
+
const row = sessionStmt.get(ompSessionId, 1) as LedgerRow | null;
|
|
529
|
+
return row === null ? null : toEntry(row);
|
|
530
|
+
},
|
|
531
|
+
entriesForSession(ompSessionId: string, limit: number): LedgerEntry[] {
|
|
532
|
+
if (ompSessionId === "") return [];
|
|
533
|
+
return (sessionStmt.all(ompSessionId, Math.max(1, limit)) as LedgerRow[]).map(toEntry);
|
|
534
|
+
},
|
|
462
535
|
};
|
|
463
536
|
}
|
package/src/cost/report.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Database } from "bun:sqlite";
|
|
12
|
+
import { createFeedbackStore, type FeedbackCounts } from "./feedback.ts";
|
|
12
13
|
|
|
13
14
|
export interface ReportTotals {
|
|
14
15
|
dispatches: number;
|
|
@@ -50,6 +51,8 @@ export interface ModelRow extends ReportRow {
|
|
|
50
51
|
provider: string;
|
|
51
52
|
/** Dispatch counts per tier, e.g. `{ trivial: 12, moderate: 3 }`. */
|
|
52
53
|
tiers: Record<string, number>;
|
|
54
|
+
/** User verdicts from /router good|bad on turns this model served, in the window. */
|
|
55
|
+
feedback: FeedbackCounts;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
export interface DayRow {
|
|
@@ -71,6 +74,50 @@ export interface UsageReport {
|
|
|
71
74
|
models: ModelRow[];
|
|
72
75
|
tiers: ReportRow[];
|
|
73
76
|
days: DayRow[];
|
|
77
|
+
/** Mean prompt composition over rows that recorded it; null when none did. */
|
|
78
|
+
anatomy: AnatomyShare | null;
|
|
79
|
+
/** What the window would have cost on one model throughout, per configured baseline. */
|
|
80
|
+
baselines: BaselineRow[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface BaselineRow {
|
|
84
|
+
slug: string;
|
|
85
|
+
usd: number;
|
|
86
|
+
/** 1 − routed spend ÷ baseline spend; negative when the router cost more. */
|
|
87
|
+
savedShare: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolves configured baseline slugs against a catalog lookup; unknown slugs are skipped. */
|
|
91
|
+
export function baselinePrices(slugs: readonly string[], find: (slug: string) => { price: { prompt: number; completion: number; cacheRead?: number } } | undefined): BaselinePrice[] {
|
|
92
|
+
const out: BaselinePrice[] = [];
|
|
93
|
+
for (const slug of slugs) {
|
|
94
|
+
const m = find(slug);
|
|
95
|
+
if (m === undefined) continue;
|
|
96
|
+
out.push({ slug, prompt: m.price.prompt, completion: m.price.completion, ...(m.price.cacheRead === undefined ? {} : { cacheRead: m.price.cacheRead }) });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A baseline's prices per token (the catalog's `Price`, or a subset of it). */
|
|
102
|
+
export interface BaselinePrice {
|
|
103
|
+
slug: string;
|
|
104
|
+
prompt: number;
|
|
105
|
+
completion: number;
|
|
106
|
+
cacheRead?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Shares of prompt bytes, 0-1, averaged over the window's dispatches. */
|
|
110
|
+
export interface AnatomyShare {
|
|
111
|
+
rows: number;
|
|
112
|
+
avgMessages: number;
|
|
113
|
+
system: number;
|
|
114
|
+
user: number;
|
|
115
|
+
assistant: number;
|
|
116
|
+
tool: number;
|
|
117
|
+
/** Tool schemas relative to prompt bytes (they ride in the tools param, not the messages). */
|
|
118
|
+
schemas: number;
|
|
119
|
+
olderHalf: number;
|
|
120
|
+
staleTool: number;
|
|
74
121
|
}
|
|
75
122
|
|
|
76
123
|
const USD = "COALESCE(reported_usd, predicted_usd)";
|
|
@@ -129,7 +176,10 @@ function toRow(r: RawRow, windowSpend: number): ReportRow {
|
|
|
129
176
|
* Builds the report for the last `windowDays`. `harnessId` narrows to one
|
|
130
177
|
* harness (the `X-Omp-Harness` header); empty means everything.
|
|
131
178
|
*/
|
|
132
|
-
export function buildUsageReport(
|
|
179
|
+
export function buildUsageReport(
|
|
180
|
+
db: Database,
|
|
181
|
+
opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[] },
|
|
182
|
+
): UsageReport {
|
|
133
183
|
const nowMs = opts.nowMs ?? Date.now();
|
|
134
184
|
const windowDays = Math.max(1, opts.windowDays);
|
|
135
185
|
const sinceMs = nowMs - windowDays * 86_400_000;
|
|
@@ -196,10 +246,12 @@ export function buildUsageReport(db: Database, opts: { windowDays: number; harne
|
|
|
196
246
|
rec[m.tier] = m.n;
|
|
197
247
|
mixByModel.set(m.key, rec);
|
|
198
248
|
}
|
|
249
|
+
const feedbackBySlug = createFeedbackStore(db).countsBySlug(sinceMs, harnessId);
|
|
199
250
|
const models: ModelRow[] = modelRows.map((r) => ({
|
|
200
251
|
...toRow(r, windowSpend),
|
|
201
252
|
provider: r.key.startsWith("ollama/") ? "ollama" : "openrouter",
|
|
202
253
|
tiers: mixByModel.get(r.key) ?? {},
|
|
254
|
+
feedback: feedbackBySlug.get(r.key) ?? { good: 0, bad: 0 },
|
|
203
255
|
}));
|
|
204
256
|
|
|
205
257
|
const tiers = (
|
|
@@ -221,6 +273,42 @@ export function buildUsageReport(db: Database, opts: { windowDays: number; harne
|
|
|
221
273
|
cacheHitRate: d.prompt_tokens > 0 ? d.cached_tokens / d.prompt_tokens : 0,
|
|
222
274
|
}));
|
|
223
275
|
|
|
276
|
+
const an = db
|
|
277
|
+
.query(
|
|
278
|
+
`SELECT COUNT(*) AS rows, AVG(json_extract(features, '$.anatomy.messages')) AS msgs,
|
|
279
|
+
AVG(json_extract(features, '$.anatomy.systemBytes')) AS sys, AVG(json_extract(features, '$.anatomy.userBytes')) AS usr,
|
|
280
|
+
AVG(json_extract(features, '$.anatomy.assistantBytes')) AS asst, AVG(json_extract(features, '$.anatomy.toolBytes')) AS tool,
|
|
281
|
+
AVG(json_extract(features, '$.toolSchemaBytes')) AS schemas,
|
|
282
|
+
AVG(json_extract(features, '$.anatomy.olderHalfBytes')) AS older, AVG(json_extract(features, '$.anatomy.staleToolBytes')) AS stale
|
|
283
|
+
FROM ledger WHERE ${where} AND json_extract(features, '$.anatomy.messages') IS NOT NULL`,
|
|
284
|
+
)
|
|
285
|
+
.get(bind) as { rows: number; msgs: number | null; sys: number | null; usr: number | null; asst: number | null; tool: number | null; schemas: number | null; older: number | null; stale: number | null };
|
|
286
|
+
let anatomy: AnatomyShare | null = null;
|
|
287
|
+
if (an.rows > 0) {
|
|
288
|
+
const total = (an.sys ?? 0) + (an.usr ?? 0) + (an.asst ?? 0) + (an.tool ?? 0);
|
|
289
|
+
const share = (v: number | null): number => (total > 0 ? (v ?? 0) / total : 0);
|
|
290
|
+
anatomy = {
|
|
291
|
+
rows: an.rows,
|
|
292
|
+
avgMessages: Math.round(an.msgs ?? 0),
|
|
293
|
+
system: share(an.sys),
|
|
294
|
+
user: share(an.usr),
|
|
295
|
+
assistant: share(an.asst),
|
|
296
|
+
tool: share(an.tool),
|
|
297
|
+
schemas: share(an.schemas),
|
|
298
|
+
olderHalf: share(an.older),
|
|
299
|
+
staleTool: share(an.stale),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Counterfactual: the window's tokens on one model throughout, at list
|
|
304
|
+
// price with the window's own cache hit rate (cached tokens read at the
|
|
305
|
+
// baseline's cache rate, or full price when it publishes none).
|
|
306
|
+
const baselines: BaselineRow[] = (opts.baselines ?? []).map((b) => {
|
|
307
|
+
const fresh = Math.max(0, t.prompt_tokens - t.cached_tokens);
|
|
308
|
+
const usd = fresh * b.prompt + t.cached_tokens * (b.cacheRead ?? b.prompt) + t.completion_tokens * b.completion;
|
|
309
|
+
return { slug: b.slug, usd, savedShare: usd > 0 ? 1 - t.spend / usd : 0 };
|
|
310
|
+
});
|
|
311
|
+
|
|
224
312
|
return {
|
|
225
313
|
generatedAtMs: nowMs,
|
|
226
314
|
windowDays,
|
|
@@ -244,6 +332,8 @@ export function buildUsageReport(db: Database, opts: { windowDays: number; harne
|
|
|
244
332
|
models,
|
|
245
333
|
tiers,
|
|
246
334
|
days,
|
|
335
|
+
anatomy,
|
|
336
|
+
baselines,
|
|
247
337
|
};
|
|
248
338
|
}
|
|
249
339
|
|
|
@@ -294,6 +384,19 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
|
|
|
294
384
|
`spend ${usd(t.spendUsd)} over ${num(t.dispatches)} dispatches in ${num(t.conversations)} conversations · ${usd(t.dispatches > 0 ? t.spendUsd / t.dispatches : 0)}/dispatch`,
|
|
295
385
|
`prompt ${num(t.promptTokens)} tok (cache hit ${pct(t.cacheHitRate, t.cacheEstimated)}) · completion ${num(t.completionTokens)} tok · switches ${num(t.modelSwitches)} · escalations ${num(t.escalations)} · failovers ${num(t.failovers)} · errors ${num(t.errors)} (${num(t.aborted)} aborted)`,
|
|
296
386
|
];
|
|
387
|
+
if (r.baselines.length > 0 && t.dispatches > 0) {
|
|
388
|
+
summary.push(
|
|
389
|
+
`same traffic on one model: ${r.baselines
|
|
390
|
+
.map((b) => `${b.slug} ${usd(b.usd)} (router ${b.savedShare >= 0 ? "saved" : "cost extra"} ${pct(Math.abs(b.savedShare))})`)
|
|
391
|
+
.join(" · ")}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const a = r.anatomy;
|
|
395
|
+
if (a !== null) {
|
|
396
|
+
summary.push(
|
|
397
|
+
`prompt anatomy (mean of ${num(a.rows)}): tool results ${pct(a.tool)} · assistant ${pct(a.assistant)} · user ${pct(a.user)} · system ${pct(a.system)} · tool schemas +${pct(a.schemas)} · older half ${pct(a.olderHalf)} · stale tool results ${pct(a.staleTool)} · ${num(a.avgMessages)} messages`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
297
400
|
const tables: ReportTable[] = [];
|
|
298
401
|
if (r.providers.length > 0) {
|
|
299
402
|
tables.push({
|
|
@@ -307,7 +410,7 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
|
|
|
307
410
|
tables.push({
|
|
308
411
|
id: "models",
|
|
309
412
|
title: `models (top ${Math.min(maxModels, r.models.length)} of ${r.models.length} by spend)`,
|
|
310
|
-
headers: ["model", "dispatches", "spend", "share", "cache", "ttft", "speed", "tiers"],
|
|
413
|
+
headers: ["model", "dispatches", "spend", "share", "cache", "ttft", "speed", "feedback", "tiers"],
|
|
311
414
|
rows: r.models.slice(0, maxModels).map((m) => [
|
|
312
415
|
m.key,
|
|
313
416
|
num(m.dispatches),
|
|
@@ -316,6 +419,7 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
|
|
|
316
419
|
pct(m.cacheHitRate, m.cacheEstimated),
|
|
317
420
|
ms(m.avgTtftMs),
|
|
318
421
|
tps(m.tokensPerSec),
|
|
422
|
+
m.feedback.good + m.feedback.bad === 0 ? "" : `+${m.feedback.good}/-${m.feedback.bad}`,
|
|
319
423
|
Object.entries(m.tiers)
|
|
320
424
|
.sort((a, b) => b[1] - a[1])
|
|
321
425
|
.map(([k, v]) => `${k}:${v}`)
|
package/src/cost/types.ts
CHANGED
|
@@ -200,9 +200,24 @@ export interface ModelLatency {
|
|
|
200
200
|
tokensPerSec: number;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* How often a model's prompt cache actually hit when the router expected it
|
|
205
|
+
* warm: the previous kept turn of the conversation was on the same model
|
|
206
|
+
* within `hysteresis.cacheWarmTtlMs`. Provider-side misses (a model whose
|
|
207
|
+
* cache is flaky, or absent) show up here as a low rate. Router-estimated
|
|
208
|
+
* cache counts (Ollama) are excluded: they are constructed, not observed.
|
|
209
|
+
*/
|
|
210
|
+
export interface ModelCacheReliability {
|
|
211
|
+
slug: string;
|
|
212
|
+
samples: number;
|
|
213
|
+
/** Mean cached / expected-cached over those samples, 0-1. */
|
|
214
|
+
hitRate: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
203
217
|
export interface LedgerSignals {
|
|
204
218
|
trust: ModelTrust | null;
|
|
205
219
|
latency: ModelLatency | null;
|
|
220
|
+
cache?: ModelCacheReliability | null;
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
/** Measured price of a probe escalation: what the retry billed per prompt token of the failed turn. */
|
|
@@ -241,7 +256,20 @@ export interface Ledger {
|
|
|
241
256
|
* escalation-cost term in candidate scoring is inert without it.
|
|
242
257
|
*/
|
|
243
258
|
escalationCost?(windowDays: number): EscalationCost | null;
|
|
259
|
+
/**
|
|
260
|
+
* Observed cache hit rate when a warm cache was expected (see
|
|
261
|
+
* ModelCacheReliability). Null until any sample exists. Optional so fakes
|
|
262
|
+
* need not implement it; the stay/switch comparison assumes a reliable
|
|
263
|
+
* cache without it.
|
|
264
|
+
*/
|
|
265
|
+
cacheReliability?(slug: string): ModelCacheReliability | null;
|
|
244
266
|
/** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
|
|
245
267
|
tokenRatio(tokenizer: string): number | null;
|
|
246
268
|
recentEntries(limit: number): LedgerEntry[];
|
|
269
|
+
/** Spend since an instant on slugs with a prefix (`ollama/`), for provider-level reconciliation. Optional. */
|
|
270
|
+
providerSpendSince?(slugPrefix: string, sinceMs: number): number;
|
|
271
|
+
/** Newest kept (non-wasted) entry for an omp session, for /router why and feedback. Optional so fakes need not implement it. */
|
|
272
|
+
latestForSession?(ompSessionId: string): LedgerEntry | null;
|
|
273
|
+
/** Newest entries for an omp session, newest first. Optional. */
|
|
274
|
+
entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
|
|
247
275
|
}
|
package/src/router/candidates.ts
CHANGED
|
@@ -111,12 +111,21 @@ function expectedWaitMs(latency: ModelLatency, expectedCompletionTokens: number)
|
|
|
111
111
|
* tiny cost and capped at LATENCY_EXCESS_CAP, so the model stays cheapest. That is
|
|
112
112
|
* `filters.maxExpectedWaitMs`'s job — a hard drop, applied in buildCandidates.
|
|
113
113
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
/**
|
|
115
|
+
* The latency weight in force for a turn: the continuation weight on a
|
|
116
|
+
* tool-result continuation when one is configured, else the general one.
|
|
117
|
+
* A person waits on first token only when the turn is theirs.
|
|
118
|
+
*/
|
|
119
|
+
export function latencyWeightFor(filters: FilterConfig, isToolResultContinuation: boolean): number {
|
|
120
|
+
return isToolResultContinuation && filters.latencyWeightContinuation !== undefined ? filters.latencyWeightContinuation : filters.latencyWeight;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig, expectedCompletionTokens: number, weight = filters.latencyWeight): number {
|
|
124
|
+
if (latency === null || weight <= 0 || latency.samples < filters.latencyMinSamples) return 1;
|
|
116
125
|
const waitMs = expectedWaitMs(latency, expectedCompletionTokens);
|
|
117
126
|
const refWaitMs = filters.latencyReferenceMs + (expectedCompletionTokens / filters.latencyReferenceTokensPerSec) * 1000;
|
|
118
127
|
const excess = refWaitMs > 0 ? Math.max(0, (waitMs - refWaitMs) / refWaitMs) : 0;
|
|
119
|
-
return 1 +
|
|
128
|
+
return 1 + weight * Math.min(excess, LATENCY_EXCESS_CAP);
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candidate[]; rejected: Rejection[] } {
|
|
@@ -320,7 +329,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
320
329
|
// 20% of the time really costs ~25% more in retries. Latency does the same
|
|
321
330
|
// for slowness (TTFT over the reference). qualityExponent 0 makes this
|
|
322
331
|
// "cheapest above the floor"; the floor does the quality work.
|
|
323
|
-
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
|
|
332
|
+
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens, latencyWeightFor(filters, features.isToolResultContinuation));
|
|
324
333
|
// Escalation-cost term: the trust divisor prices a failure as a retry of
|
|
325
334
|
// THIS model, but a probe escalation re-dispatches the whole prompt on
|
|
326
335
|
// the next tier's model — measured at ~700x a cheap model's own turn cost.
|
package/src/router/classify.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Ledger } from "../cost/types.ts";
|
|
|
10
10
|
import { estimateTokens } from "../tokens/estimate.ts";
|
|
11
11
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
12
12
|
import { sha256Hex } from "../util/hash.ts";
|
|
13
|
+
import { loadLearnedModel, predictRisk } from "./learned.ts";
|
|
13
14
|
import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
14
15
|
import type { Classification, Features, TaskType, Tier } from "./types.ts";
|
|
15
16
|
|
|
@@ -303,6 +304,15 @@ export async function classify(
|
|
|
303
304
|
): Promise<Classification> {
|
|
304
305
|
const heuristic = scoreHeuristic(f, cfg);
|
|
305
306
|
const cc = cfg.classifier;
|
|
307
|
+
// Advisory learned risk: recorded beside the decision, never acted on here.
|
|
308
|
+
if (cc.learnedModelPath !== "") {
|
|
309
|
+
const model = await loadLearnedModel(cc.learnedModelPath);
|
|
310
|
+
if (model !== null) {
|
|
311
|
+
const risk = predictRisk(model, f);
|
|
312
|
+
heuristic.learnedRisk = risk;
|
|
313
|
+
heuristic.reasons.push(`learned: p(escalate)=${risk.toFixed(3)}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
306
316
|
if (cc.ambiguityThreshold <= 0 || heuristic.confidence >= cc.ambiguityThreshold) return heuristic;
|
|
307
317
|
|
|
308
318
|
const digest = buildDigest(req, f);
|
package/src/router/features.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { NormMessage, NormRequest } from "../wire/types.ts";
|
|
11
|
-
import type { Features } from "./types.ts";
|
|
11
|
+
import type { Features, PromptAnatomy } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Complexity signals. Deliberately small: each hit pushes the turn toward a
|
|
@@ -224,6 +224,24 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
|
|
|
224
224
|
codeBlocks === 0 &&
|
|
225
225
|
(terminators === null ? 0 : terminators.length) <= 1;
|
|
226
226
|
|
|
227
|
+
// Prompt anatomy: where the bytes sit. Cheap (one pass over textBytes) and
|
|
228
|
+
// content-free, so it is safe to record on every row.
|
|
229
|
+
const anatomy: PromptAnatomy = { messages: 0, systemBytes: 0, userBytes: 0, assistantBytes: 0, toolBytes: 0, olderHalfBytes: 0, staleToolBytes: 0 };
|
|
230
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
231
|
+
const olderHalfEnd = Math.floor(nonSystem.length / 2);
|
|
232
|
+
const staleEnd = Math.max(0, nonSystem.length - 20);
|
|
233
|
+
nonSystem.forEach((m, i) => {
|
|
234
|
+
if (i < olderHalfEnd) anatomy.olderHalfBytes += m.textBytes;
|
|
235
|
+
if (m.role === "tool" && i < staleEnd) anatomy.staleToolBytes += m.textBytes;
|
|
236
|
+
});
|
|
237
|
+
for (const m of messages) {
|
|
238
|
+
anatomy.messages++;
|
|
239
|
+
if (m.role === "system") anatomy.systemBytes += m.textBytes;
|
|
240
|
+
else if (m.role === "user") anatomy.userBytes += m.textBytes;
|
|
241
|
+
else if (m.role === "assistant") anatomy.assistantBytes += m.textBytes;
|
|
242
|
+
else anatomy.toolBytes += m.textBytes;
|
|
243
|
+
}
|
|
244
|
+
|
|
227
245
|
return {
|
|
228
246
|
promptTokens,
|
|
229
247
|
newContentTokens,
|
|
@@ -246,5 +264,6 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
|
|
|
246
264
|
requestedReasoning: req.reasoning,
|
|
247
265
|
questionCount,
|
|
248
266
|
isTerseInstruction,
|
|
267
|
+
anatomy,
|
|
249
268
|
};
|
|
250
269
|
}
|
package/src/router/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export function createRouter(deps: RouterDeps): Router {
|
|
|
52
52
|
return {
|
|
53
53
|
async route(
|
|
54
54
|
req: NormRequest,
|
|
55
|
-
opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] },
|
|
55
|
+
opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[]; forceTier?: Tier; forceSlug?: string },
|
|
56
56
|
): Promise<Decision> {
|
|
57
57
|
const state = conversations.get(req.conversationKey) ?? conversations.load(req.conversationKey);
|
|
58
58
|
const snapshot = await catalog.get();
|
|
@@ -78,6 +78,16 @@ export function createRouter(deps: RouterDeps): Router {
|
|
|
78
78
|
score: 1,
|
|
79
79
|
reasons: [`escalated from ${opts.escalateFrom} after attempt ${opts.attempt - 1} was rejected`],
|
|
80
80
|
};
|
|
81
|
+
} else if (opts.forceTier !== undefined) {
|
|
82
|
+
// A session override from omp: the user chose the tier for a while.
|
|
83
|
+
classification = {
|
|
84
|
+
tier: opts.forceTier,
|
|
85
|
+
task: classifyTask(features),
|
|
86
|
+
confidence: 1,
|
|
87
|
+
source: "forced",
|
|
88
|
+
score: 1,
|
|
89
|
+
reasons: [`tier ${opts.forceTier} forced by session override (/router tier)`],
|
|
90
|
+
};
|
|
81
91
|
} else {
|
|
82
92
|
classification = await classify(req, features, config, { upstream, ledger, catalog });
|
|
83
93
|
}
|
|
@@ -93,6 +103,7 @@ export function createRouter(deps: RouterDeps): Router {
|
|
|
93
103
|
cfg: config,
|
|
94
104
|
nowMs: Date.now(),
|
|
95
105
|
...(opts.excludeSlugs === undefined ? {} : { excludeSlugs: opts.excludeSlugs }),
|
|
106
|
+
...(opts.forceSlug === undefined ? {} : { forceSlug: opts.forceSlug }),
|
|
96
107
|
});
|
|
97
108
|
},
|
|
98
109
|
};
|