auto-model-router 0.4.1 → 0.4.3
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 +48 -2
- package/omp-extension/digest-logic.ts +53 -0
- package/omp-extension/pi-coding-agent.d.ts +14 -1
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/omp-extension/router-digest.ts +93 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +22 -1
- package/src/config/defaults.ts +20 -0
- package/src/config/schema.ts +18 -1
- package/src/config/types.ts +54 -0
- package/src/cost/ledger.ts +77 -9
- package/src/cost/report.ts +24 -3
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +31 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +1 -0
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +5 -1
- package/src/server/compaction-digest.ts +127 -0
- package/src/server/digest.ts +243 -0
- package/src/server/http.ts +51 -1
- package/src/server/turn.ts +27 -1
- package/src/util/sqlite.ts +7 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +7 -0
- package/test/compaction.test.ts +40 -3
- package/test/controls.test.ts +34 -0
- package/test/digest.test.ts +229 -0
- package/test/embed-lifecycle.test.ts +1 -1
- package/test/failover.test.ts +4 -3
- package/test/learned.test.ts +21 -1
- package/test/report-hub.test.ts +3 -0
- package/test/report-logic.test.ts +11 -1
- package/test/report.test.ts +3 -0
- package/test/select.test.ts +2 -2
- package/test/summary.test.ts +171 -0
- package/test/tokens.test.ts +44 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +69 -3
- package/tools/train-classifier.ts +75 -20
package/src/config/types.ts
CHANGED
|
@@ -232,6 +232,15 @@ export interface FilterConfig {
|
|
|
232
232
|
* once a week of verdicts is in the report.
|
|
233
233
|
*/
|
|
234
234
|
feedbackWeight: number;
|
|
235
|
+
/**
|
|
236
|
+
* Count a verdict toward a model's trust only when routing the same task
|
|
237
|
+
* type the judged turn was (the ledger's `task`: coding, vision,
|
|
238
|
+
* documentation, data, chat). A model that writes good code but bad prose
|
|
239
|
+
* then keeps its coding trust. Verdicts on turns with no recorded task
|
|
240
|
+
* count for every task. Off by default: verdicts are scarce, and pooling
|
|
241
|
+
* them converges sooner.
|
|
242
|
+
*/
|
|
243
|
+
feedbackByTask: boolean;
|
|
235
244
|
/** Attempts required before `minTrust` is enforced against a model. */
|
|
236
245
|
minTrustSamples: number;
|
|
237
246
|
/**
|
|
@@ -554,6 +563,32 @@ export interface CacheConfig {
|
|
|
554
563
|
milestoneTokens: number;
|
|
555
564
|
}
|
|
556
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Tool-result digest: a cheap model condenses large tool outputs before an
|
|
568
|
+
* expensive one reads them (see server/digest.ts and the router-digest omp
|
|
569
|
+
* extension).
|
|
570
|
+
*/
|
|
571
|
+
export interface DigestConfig {
|
|
572
|
+
/** Master switch; the omp extension polls this as its policy. */
|
|
573
|
+
enabled: boolean;
|
|
574
|
+
/** Tool results smaller than this pass through untouched. */
|
|
575
|
+
minBytes: number;
|
|
576
|
+
/** Results larger than this are left alone (too costly even for a cheap model). */
|
|
577
|
+
maxBytes: number;
|
|
578
|
+
/** Tool names (lower-case) whose results may be digested. Never errors, never edits/writes. */
|
|
579
|
+
tools: string[];
|
|
580
|
+
/** Digest only when the session's current model is at or above this tier. */
|
|
581
|
+
fromTier: Tier;
|
|
582
|
+
/** Tier the digest model is picked from (cheapest candidate that fits). */
|
|
583
|
+
tier: Tier;
|
|
584
|
+
/** Pin a specific digest model; empty ⇒ pick from `tier`. */
|
|
585
|
+
model: string;
|
|
586
|
+
maxOutputTokens: number;
|
|
587
|
+
/** Skip when the digest itself would cost more than this, USD. */
|
|
588
|
+
maxCostUsd: number;
|
|
589
|
+
timeoutMs: number;
|
|
590
|
+
}
|
|
591
|
+
|
|
557
592
|
/** Usage-report options. */
|
|
558
593
|
export interface ReportConfig {
|
|
559
594
|
/**
|
|
@@ -563,6 +598,13 @@ export interface ReportConfig {
|
|
|
563
598
|
* are skipped.
|
|
564
599
|
*/
|
|
565
600
|
baselines: string[];
|
|
601
|
+
/**
|
|
602
|
+
* Post a one-screen summary of the last 24 hours (spend, top models, cache
|
|
603
|
+
* hit, escalations, soft-failure spikes, Ollama meter) into the transcript
|
|
604
|
+
* at the first omp session start of each day. `/router summary` shows it
|
|
605
|
+
* on demand regardless.
|
|
606
|
+
*/
|
|
607
|
+
dailySummary: boolean;
|
|
566
608
|
}
|
|
567
609
|
|
|
568
610
|
export interface BudgetConfig {
|
|
@@ -720,6 +762,17 @@ export interface CompactionConfig {
|
|
|
720
762
|
keepTailBytes: number;
|
|
721
763
|
/** Elide an older tool result when a newer call to the same resource supersedes it. */
|
|
722
764
|
elideSupersededReads: boolean;
|
|
765
|
+
/**
|
|
766
|
+
* Summarising compaction: when the plan gains an edit, a cheap model
|
|
767
|
+
* (`digest.tier` / `digest.model`, under `digest.maxCostUsd` and
|
|
768
|
+
* `digest.timeoutMs`) digests the tool result instead of it being cut to
|
|
769
|
+
* head+tail or a stub. The digest is stored on the edit, so the bytes sent
|
|
770
|
+
* stay identical on later turns. Applies when the turn routed at or above
|
|
771
|
+
* `digest.fromTier`; does not need `digest.enabled`.
|
|
772
|
+
*/
|
|
773
|
+
digestToolResults: boolean;
|
|
774
|
+
/** Digests per turn at most; the rest of a plan's new edits stay plain until a later turn. */
|
|
775
|
+
digestMaxPerTurn: number;
|
|
723
776
|
/** Collapse byte-identical repeated tool results to a single copy. */
|
|
724
777
|
collapseDuplicateResults: boolean;
|
|
725
778
|
}
|
|
@@ -741,6 +794,7 @@ export interface RouterConfig {
|
|
|
741
794
|
compaction: CompactionConfig;
|
|
742
795
|
budget: BudgetConfig;
|
|
743
796
|
report: ReportConfig;
|
|
797
|
+
digest: DigestConfig;
|
|
744
798
|
profiles: ProfileConfig[];
|
|
745
799
|
ledger: LedgerConfig;
|
|
746
800
|
/**
|
package/src/cost/ledger.ts
CHANGED
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
ModelCacheReliability,
|
|
28
28
|
ModelLatency,
|
|
29
29
|
ModelTrust,
|
|
30
|
+
SoftFailureSpike,
|
|
30
31
|
UsageCounts,
|
|
31
32
|
} from "./types.ts";
|
|
32
33
|
|
|
@@ -35,6 +36,20 @@ const MIN_CALIBRATION_SAMPLES = 20;
|
|
|
35
36
|
/** Calibration samples outside this bytes-per-token band are provider accounting quirks, not tokenizer facts. */
|
|
36
37
|
const MIN_SANE_BYTES_PER_TOKEN = 1.5;
|
|
37
38
|
const MAX_SANE_BYTES_PER_TOKEN = 8;
|
|
39
|
+
/**
|
|
40
|
+
* Soft-failure spike detection (visibility only). A model is spiking when, over
|
|
41
|
+
* the recent window, it has at least SPIKE_MIN_DISPATCHES dispatches, at least
|
|
42
|
+
* SPIKE_MIN_FAILURES of them failed, its failure rate is at least
|
|
43
|
+
* SPIKE_MIN_RATE, and that rate is at least SPIKE_RATIO × its own baseline
|
|
44
|
+
* rate over the preceding window (a model with no baseline failures spikes on
|
|
45
|
+
* the absolute floor alone).
|
|
46
|
+
*/
|
|
47
|
+
const SPIKE_RECENT_MS = 60 * 60_000;
|
|
48
|
+
const SPIKE_BASELINE_MS = 7 * 24 * 60 * 60_000;
|
|
49
|
+
const SPIKE_MIN_DISPATCHES = 5;
|
|
50
|
+
const SPIKE_MIN_FAILURES = 3;
|
|
51
|
+
const SPIKE_MIN_RATE = 0.25;
|
|
52
|
+
const SPIKE_RATIO = 2;
|
|
38
53
|
/** Escalated attempts needed before their measured cost is trusted. */
|
|
39
54
|
const MIN_ESCALATION_SAMPLES = 10;
|
|
40
55
|
/** The escalation-cost aggregate scans a window of rows; memoised for this long. */
|
|
@@ -334,11 +349,23 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
334
349
|
`SELECT ${FEEDBACK_SELECT} FROM feedback f JOIN ledger l ON l.id = f.ledger_id WHERE f.slug = ? AND l.harness_id = ? AND f.created_at_ms > ?`,
|
|
335
350
|
);
|
|
336
351
|
const allFeedbackStmt = db.query(`SELECT f.slug, ${FEEDBACK_SELECT} FROM feedback f WHERE f.created_at_ms > ? GROUP BY f.slug`);
|
|
337
|
-
|
|
352
|
+
// Task-scoped variants (filters.feedbackByTask): the judged turn's task
|
|
353
|
+
// must match, or be unrecorded (older rows, or a turn that never classified).
|
|
354
|
+
const feedbackTaskStmt = db.query(
|
|
355
|
+
`SELECT ${FEEDBACK_SELECT} FROM feedback f JOIN ledger l ON l.id = f.ledger_id WHERE f.slug = ? AND (l.task = ? OR l.task IS NULL) AND f.created_at_ms > ?`,
|
|
356
|
+
);
|
|
357
|
+
const feedbackHarnessTaskStmt = db.query(
|
|
358
|
+
`SELECT ${FEEDBACK_SELECT} FROM feedback f JOIN ledger l ON l.id = f.ledger_id WHERE f.slug = ? AND l.harness_id = ? AND (l.task = ? OR l.task IS NULL) AND f.created_at_ms > ?`,
|
|
359
|
+
);
|
|
360
|
+
const feedbackFor = (slug: string, harnessId: string | undefined, cutoff: number, task?: string): FeedbackRow | null => {
|
|
338
361
|
if (cfg.filters.feedbackWeight <= 0) return null;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
362
|
+
const byHarness = harnessId !== undefined && harnessId !== "";
|
|
363
|
+
if (cfg.filters.feedbackByTask && task !== undefined && task !== "") {
|
|
364
|
+
return byHarness
|
|
365
|
+
? (feedbackHarnessTaskStmt.get(slug, harnessId, task, cutoff) as FeedbackRow | null)
|
|
366
|
+
: (feedbackTaskStmt.get(slug, task, cutoff) as FeedbackRow | null);
|
|
367
|
+
}
|
|
368
|
+
return byHarness ? (feedbackHarnessStmt.get(slug, harnessId, cutoff) as FeedbackRow | null) : (feedbackStmt.get(slug, cutoff) as FeedbackRow | null);
|
|
342
369
|
};
|
|
343
370
|
const latencyStmt = db.query(
|
|
344
371
|
`SELECT ${LATENCY_SELECT} FROM (SELECT * FROM ledger WHERE slug = ? ORDER BY created_at_ms DESC LIMIT ${LATENCY_WINDOW_ROWS})`,
|
|
@@ -351,7 +378,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
351
378
|
const providerSpendStmt = db.query(
|
|
352
379
|
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
|
|
353
380
|
);
|
|
354
|
-
|
|
381
|
+
// Digest rows (requested_model 'digest') are side calls, not the session's turns.
|
|
382
|
+
const sessionStmt = db.query("SELECT * FROM ledger WHERE omp_session_id = ? AND wasted = 0 AND requested_model <> 'digest' ORDER BY created_at_ms DESC LIMIT ?");
|
|
355
383
|
// What an escalated retry actually bills, per prompt token, over a window.
|
|
356
384
|
// attempt > 0 rows are the re-dispatches that followed a rejected attempt;
|
|
357
385
|
// errored ones carry no usage and are excluded.
|
|
@@ -362,6 +390,19 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
362
390
|
FROM ledger WHERE attempt > 0 AND error IS NULL AND created_at_ms >= ?`,
|
|
363
391
|
);
|
|
364
392
|
let escalationMemo: { atMs: number; windowDays: number; value: EscalationCost | null } | null = null;
|
|
393
|
+
// Per-model failure counts over two adjacent windows: [recentStart, now] and
|
|
394
|
+
// [baselineStart, recentStart). Wasted rows (the failed attempt a retry
|
|
395
|
+
// replaced) stay in: they ARE the soft failures being counted. Digest side
|
|
396
|
+
// calls are excluded: they are not the session's turns.
|
|
397
|
+
const softFailureStmt = db.query(
|
|
398
|
+
`SELECT COALESCE(served_slug, slug) AS slug,
|
|
399
|
+
SUM(CASE WHEN created_at_ms >= $recentStart THEN 1 ELSE 0 END) AS recent_n,
|
|
400
|
+
SUM(CASE WHEN created_at_ms >= $recentStart AND (escalation_signal IS NOT NULL OR (${ATTRIBUTABLE_ERROR})) THEN 1 ELSE 0 END) AS recent_f,
|
|
401
|
+
SUM(CASE WHEN created_at_ms < $recentStart THEN 1 ELSE 0 END) AS base_n,
|
|
402
|
+
SUM(CASE WHEN created_at_ms < $recentStart AND (escalation_signal IS NOT NULL OR (${ATTRIBUTABLE_ERROR})) THEN 1 ELSE 0 END) AS base_f
|
|
403
|
+
FROM ledger WHERE created_at_ms >= $baselineStart AND created_at_ms <= $now AND requested_model <> 'digest'
|
|
404
|
+
GROUP BY COALESCE(served_slug, slug)`,
|
|
405
|
+
);
|
|
365
406
|
const cacheMetaStmt = db.query("SELECT fetched_at_ms FROM catalog_cache WHERE id = 1");
|
|
366
407
|
const cachePayloadStmt = db.query("SELECT payload FROM catalog_cache WHERE id = 1");
|
|
367
408
|
|
|
@@ -465,7 +506,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
465
506
|
return computeBlendedRate(db, cfg, windowDays);
|
|
466
507
|
},
|
|
467
508
|
|
|
468
|
-
trust(slug: string, harnessId?: string): ModelTrust | null {
|
|
509
|
+
trust(slug: string, harnessId?: string, task?: string): ModelTrust | null {
|
|
469
510
|
// Read the window at CALL time, not at construction: hot reload mutates
|
|
470
511
|
// the shared config object in place, so a pinned value would ignore an
|
|
471
512
|
// edit until restart. 0 => cutoff 0 => every row qualifies.
|
|
@@ -475,7 +516,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
475
516
|
? (trustHarnessStmt.get(slug, harnessId, cutoff) as TrustRow | null)
|
|
476
517
|
: (trustStmt.get(slug, cutoff) as TrustRow | null);
|
|
477
518
|
if (row === null || row.attempts === 0) return null;
|
|
478
|
-
return toTrust(slug, row, feedbackFor(slug, harnessId, cutoff), cfg.filters.feedbackWeight);
|
|
519
|
+
return toTrust(slug, row, feedbackFor(slug, harnessId, cutoff, task), cfg.filters.feedbackWeight);
|
|
479
520
|
},
|
|
480
521
|
|
|
481
522
|
allTrust(): ModelTrust[] {
|
|
@@ -496,7 +537,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
496
537
|
if (row === null) return null;
|
|
497
538
|
return toLatency(slug, row);
|
|
498
539
|
},
|
|
499
|
-
signals(slugs: readonly string[], harnessId?: string): Map<string, LedgerSignals> {
|
|
540
|
+
signals(slugs: readonly string[], harnessId?: string, task?: string): Map<string, LedgerSignals> {
|
|
500
541
|
const cutoff = cfg.filters.trustWindowDays > 0 ? Date.now() - cfg.filters.trustWindowDays * DAY_MS : 0;
|
|
501
542
|
const hasHarness = harnessId !== undefined && harnessId !== "";
|
|
502
543
|
const out = new Map<string, LedgerSignals>();
|
|
@@ -508,7 +549,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
508
549
|
? (latencyHarnessStmt.get(slug, harnessId) as LatencyRow | null)
|
|
509
550
|
: (latencyStmt.get(slug) as LatencyRow | null);
|
|
510
551
|
out.set(slug, {
|
|
511
|
-
trust: trustRow === null || trustRow.attempts === 0 ? null : toTrust(slug, trustRow, feedbackFor(slug, harnessId, cutoff), cfg.filters.feedbackWeight),
|
|
552
|
+
trust: trustRow === null || trustRow.attempts === 0 ? null : toTrust(slug, trustRow, feedbackFor(slug, harnessId, cutoff, task), cfg.filters.feedbackWeight),
|
|
512
553
|
latency: latencyRow === null ? null : toLatency(slug, latencyRow),
|
|
513
554
|
});
|
|
514
555
|
}
|
|
@@ -551,6 +592,33 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
551
592
|
const rows = recentStmt.all(limit) as LedgerRow[];
|
|
552
593
|
return rows.map(toEntry);
|
|
553
594
|
},
|
|
595
|
+
softFailureSpikes(nowMs = Date.now(), recentMs = SPIKE_RECENT_MS, baselineMs = SPIKE_BASELINE_MS): SoftFailureSpike[] {
|
|
596
|
+
const rows = softFailureStmt.all({ $now: nowMs, $recentStart: nowMs - recentMs, $baselineStart: nowMs - recentMs - baselineMs }) as {
|
|
597
|
+
slug: string;
|
|
598
|
+
recent_n: number;
|
|
599
|
+
recent_f: number;
|
|
600
|
+
base_n: number;
|
|
601
|
+
base_f: number;
|
|
602
|
+
}[];
|
|
603
|
+
const spikes: SoftFailureSpike[] = [];
|
|
604
|
+
for (const r of rows) {
|
|
605
|
+
if (r.recent_n < SPIKE_MIN_DISPATCHES || r.recent_f < SPIKE_MIN_FAILURES) continue;
|
|
606
|
+
const recentRate = r.recent_f / r.recent_n;
|
|
607
|
+
const baselineRate = r.base_n > 0 ? r.base_f / r.base_n : 0;
|
|
608
|
+
if (recentRate < SPIKE_MIN_RATE || recentRate < SPIKE_RATIO * baselineRate) continue;
|
|
609
|
+
spikes.push({
|
|
610
|
+
slug: r.slug,
|
|
611
|
+
recentDispatches: r.recent_n,
|
|
612
|
+
recentFailures: r.recent_f,
|
|
613
|
+
recentRate,
|
|
614
|
+
baselineDispatches: r.base_n,
|
|
615
|
+
baselineFailures: r.base_f,
|
|
616
|
+
baselineRate,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
spikes.sort((a, b) => b.recentRate - a.recentRate || b.recentFailures - a.recentFailures);
|
|
620
|
+
return spikes;
|
|
621
|
+
},
|
|
554
622
|
providerSpendSince(slugPrefix: string, sinceMs: number): number {
|
|
555
623
|
const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
|
|
556
624
|
return row?.total ?? 0;
|
package/src/cost/report.ts
CHANGED
|
@@ -30,6 +30,10 @@ export interface ReportTotals {
|
|
|
30
30
|
/** Turns from omp subagents (`features.isSubagent`), and their spend. */
|
|
31
31
|
subagentDispatches: number;
|
|
32
32
|
subagentSpendUsd: number;
|
|
33
|
+
/** Tool-result digests (requestedModel "digest"): count, what they cost, bytes they condensed. */
|
|
34
|
+
digests: number;
|
|
35
|
+
digestSpendUsd: number;
|
|
36
|
+
digestInputTokens: number;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface ReportRow {
|
|
@@ -181,14 +185,19 @@ function toRow(r: RawRow, windowSpend: number): ReportRow {
|
|
|
181
185
|
*/
|
|
182
186
|
export function buildUsageReport(
|
|
183
187
|
db: Database,
|
|
184
|
-
opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[] },
|
|
188
|
+
opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[]; /** Exclusive upper bound; default open-ended. */ untilMs?: number },
|
|
185
189
|
): UsageReport {
|
|
186
190
|
const nowMs = opts.nowMs ?? Date.now();
|
|
187
191
|
const windowDays = Math.max(1, opts.windowDays);
|
|
188
192
|
const sinceMs = nowMs - windowDays * 86_400_000;
|
|
189
193
|
const harnessId = opts.harnessId ?? "";
|
|
190
|
-
const
|
|
191
|
-
const
|
|
194
|
+
const untilMs = opts.untilMs;
|
|
195
|
+
const where = [
|
|
196
|
+
"created_at_ms >= $since",
|
|
197
|
+
...(untilMs === undefined ? [] : ["created_at_ms < $until"]),
|
|
198
|
+
...(harnessId === "" ? [] : ["harness_id = $harness"]),
|
|
199
|
+
].join(" AND ");
|
|
200
|
+
const bind = { $since: sinceMs, ...(untilMs === undefined ? {} : { $until: untilMs }), ...(harnessId === "" ? {} : { $harness: harnessId }) };
|
|
192
201
|
|
|
193
202
|
const t = db
|
|
194
203
|
.query(
|
|
@@ -201,6 +210,9 @@ export function buildUsageReport(
|
|
|
201
210
|
SUM(CASE WHEN ${EST} THEN 1 ELSE 0 END) AS estimated_rows,
|
|
202
211
|
SUM(CASE WHEN json_extract(features, '$.isSubagent') = 1 THEN 1 ELSE 0 END) AS subagent_rows,
|
|
203
212
|
COALESCE(SUM(CASE WHEN json_extract(features, '$.isSubagent') = 1 THEN ${USD} ELSE 0 END), 0) AS subagent_spend,
|
|
213
|
+
SUM(CASE WHEN requested_model = 'digest' THEN 1 ELSE 0 END) AS digests,
|
|
214
|
+
COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
|
|
215
|
+
COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
|
|
204
216
|
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
205
217
|
SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
|
|
206
218
|
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
|
|
@@ -217,6 +229,9 @@ export function buildUsageReport(
|
|
|
217
229
|
estimated_rows: number | null;
|
|
218
230
|
subagent_rows: number | null;
|
|
219
231
|
subagent_spend: number;
|
|
232
|
+
digests: number | null;
|
|
233
|
+
digest_spend: number;
|
|
234
|
+
digest_input: number;
|
|
220
235
|
escalations: number | null;
|
|
221
236
|
failovers: number | null;
|
|
222
237
|
errors: number | null;
|
|
@@ -336,6 +351,9 @@ export function buildUsageReport(
|
|
|
336
351
|
cacheEstimated: (t.estimated_rows ?? 0) > 0,
|
|
337
352
|
subagentDispatches: t.subagent_rows ?? 0,
|
|
338
353
|
subagentSpendUsd: t.subagent_spend,
|
|
354
|
+
digests: t.digests ?? 0,
|
|
355
|
+
digestSpendUsd: t.digest_spend,
|
|
356
|
+
digestInputTokens: t.digest_input,
|
|
339
357
|
},
|
|
340
358
|
providers,
|
|
341
359
|
models,
|
|
@@ -400,6 +418,9 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
|
|
|
400
418
|
.join(" · ")}`,
|
|
401
419
|
);
|
|
402
420
|
}
|
|
421
|
+
if (t.digests > 0) {
|
|
422
|
+
summary.push(`digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)}`);
|
|
423
|
+
}
|
|
403
424
|
if (t.subagentDispatches > 0) {
|
|
404
425
|
summary.push(`subagents: ${num(t.subagentDispatches)} dispatches, ${usd(t.subagentSpendUsd)} (${pct(t.spendUsd > 0 ? t.subagentSpendUsd / t.spendUsd : 0)} of spend)`);
|
|
405
426
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily summary: what the router did in the last 24 hours, in a few
|
|
3
|
+
* lines. Posted into the transcript once a day at omp session start
|
|
4
|
+
* (`report.dailySummary`) and on demand via `/router summary` or
|
|
5
|
+
* `GET /v1/router/summary`.
|
|
6
|
+
*
|
|
7
|
+
* Built from the same `buildUsageReport` the report hub uses, over a 1-day
|
|
8
|
+
* window, with the preceding day for comparison, plus the two live signals the
|
|
9
|
+
* report cannot carry: soft-failure spikes (the last hour) and the Ollama
|
|
10
|
+
* meter. The once-a-day gate is a marker in `router_kv`, keyed per harness, so
|
|
11
|
+
* several omp windows on one router share it and a restart does not repeat it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Database } from "bun:sqlite";
|
|
15
|
+
import { TIER_ORDER } from "../router/types.ts";
|
|
16
|
+
import { buildUsageReport, type BaselinePrice, type BaselineRow, type UsageReport } from "./report.ts";
|
|
17
|
+
import type { SoftFailureSpike } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
/** One 24-hour window's headline numbers. */
|
|
20
|
+
export interface SummaryWindow {
|
|
21
|
+
spendUsd: number;
|
|
22
|
+
dispatches: number;
|
|
23
|
+
conversations: number;
|
|
24
|
+
cacheHitRate: number;
|
|
25
|
+
cacheEstimated: boolean;
|
|
26
|
+
escalations: number;
|
|
27
|
+
errors: number;
|
|
28
|
+
modelSwitches: number;
|
|
29
|
+
digests: number;
|
|
30
|
+
digestSpendUsd: number;
|
|
31
|
+
subagentSpendUsd: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SummaryModel {
|
|
35
|
+
slug: string;
|
|
36
|
+
spendUsd: number;
|
|
37
|
+
share: number;
|
|
38
|
+
dispatches: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SummaryOllama {
|
|
42
|
+
plan: string | null;
|
|
43
|
+
usedUsd: number;
|
|
44
|
+
creditsUsd: number;
|
|
45
|
+
/** Days of credits left at the recent burn; null when the burn is zero. */
|
|
46
|
+
runwayDays: number | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DailySummary {
|
|
50
|
+
generatedAtMs: number;
|
|
51
|
+
sinceMs: number;
|
|
52
|
+
/** Empty ⇒ every harness. */
|
|
53
|
+
harnessId: string;
|
|
54
|
+
current: SummaryWindow;
|
|
55
|
+
/** The 24 hours before `sinceMs`. */
|
|
56
|
+
previous: SummaryWindow;
|
|
57
|
+
/** Top models by spend in the current window. */
|
|
58
|
+
topModels: SummaryModel[];
|
|
59
|
+
/** Tier moves between consecutive kept turns of one conversation. */
|
|
60
|
+
tierChanges: { up: number; down: number };
|
|
61
|
+
/** The first configured baseline the catalog knew, when any. */
|
|
62
|
+
baseline: BaselineRow | null;
|
|
63
|
+
spikes: SoftFailureSpike[];
|
|
64
|
+
ollama: SummaryOllama | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DAY_MS = 86_400_000;
|
|
68
|
+
/** How many top models the summary names. */
|
|
69
|
+
const TOP_MODELS = 3;
|
|
70
|
+
|
|
71
|
+
function windowOf(r: UsageReport): SummaryWindow {
|
|
72
|
+
const t = r.totals;
|
|
73
|
+
return {
|
|
74
|
+
spendUsd: t.spendUsd,
|
|
75
|
+
dispatches: t.dispatches,
|
|
76
|
+
conversations: t.conversations,
|
|
77
|
+
cacheHitRate: t.cacheHitRate,
|
|
78
|
+
cacheEstimated: t.cacheEstimated,
|
|
79
|
+
escalations: t.escalations,
|
|
80
|
+
errors: t.errors,
|
|
81
|
+
modelSwitches: t.modelSwitches,
|
|
82
|
+
digests: t.digests,
|
|
83
|
+
digestSpendUsd: t.digestSpendUsd,
|
|
84
|
+
subagentSpendUsd: t.subagentSpendUsd,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Counts tier moves up and down between consecutive kept turns of each conversation since `sinceMs`. */
|
|
89
|
+
export function countTierChanges(db: Database, sinceMs: number, harnessId: string): { up: number; down: number } {
|
|
90
|
+
const where = harnessId === "" ? "created_at_ms >= $since" : "created_at_ms >= $since AND harness_id = $harness";
|
|
91
|
+
const bind = harnessId === "" ? { $since: sinceMs } : { $since: sinceMs, $harness: harnessId };
|
|
92
|
+
const seq = db
|
|
93
|
+
.query(`SELECT conversation_key AS ck, tier FROM ledger WHERE ${where} AND wasted = 0 AND requested_model <> 'digest' ORDER BY conversation_key, created_at_ms`)
|
|
94
|
+
.all(bind) as { ck: string; tier: string }[];
|
|
95
|
+
let up = 0;
|
|
96
|
+
let down = 0;
|
|
97
|
+
for (let i = 1; i < seq.length; i++) {
|
|
98
|
+
const a = seq[i - 1]!;
|
|
99
|
+
const b = seq[i]!;
|
|
100
|
+
if (a.ck !== b.ck) continue;
|
|
101
|
+
const ra = TIER_ORDER.indexOf(a.tier as (typeof TIER_ORDER)[number]);
|
|
102
|
+
const rb = TIER_ORDER.indexOf(b.tier as (typeof TIER_ORDER)[number]);
|
|
103
|
+
if (ra < 0 || rb < 0 || ra === rb) continue;
|
|
104
|
+
if (rb > ra) up++;
|
|
105
|
+
else down++;
|
|
106
|
+
}
|
|
107
|
+
return { up, down };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildDailySummary(
|
|
111
|
+
db: Database,
|
|
112
|
+
opts: {
|
|
113
|
+
harnessId?: string;
|
|
114
|
+
nowMs?: number;
|
|
115
|
+
baselines?: readonly BaselinePrice[];
|
|
116
|
+
spikes?: readonly SoftFailureSpike[];
|
|
117
|
+
ollama?: SummaryOllama | null;
|
|
118
|
+
} = {},
|
|
119
|
+
): DailySummary {
|
|
120
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
121
|
+
const harnessId = opts.harnessId ?? "";
|
|
122
|
+
const baselines = opts.baselines ?? [];
|
|
123
|
+
const current = buildUsageReport(db, { windowDays: 1, harnessId, nowMs, baselines });
|
|
124
|
+
const previous = buildUsageReport(db, { windowDays: 1, harnessId, nowMs: nowMs - DAY_MS, untilMs: current.sinceMs });
|
|
125
|
+
return {
|
|
126
|
+
generatedAtMs: nowMs,
|
|
127
|
+
sinceMs: current.sinceMs,
|
|
128
|
+
harnessId,
|
|
129
|
+
current: windowOf(current),
|
|
130
|
+
previous: windowOf(previous),
|
|
131
|
+
topModels: current.models.slice(0, TOP_MODELS).map((m) => ({ slug: m.key, spendUsd: m.spendUsd, share: m.share, dispatches: m.dispatches })),
|
|
132
|
+
tierChanges: countTierChanges(db, current.sinceMs, harnessId),
|
|
133
|
+
baseline: current.baselines[0] ?? null,
|
|
134
|
+
spikes: [...(opts.spikes ?? [])],
|
|
135
|
+
ollama: opts.ollama ?? null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether the once-a-day auto summary is worth posting: something happened, or something is wrong. */
|
|
140
|
+
export function summaryHasNews(s: DailySummary): boolean {
|
|
141
|
+
return s.current.dispatches > 0 || s.spikes.length > 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const usd = (v: number): string => (v >= 100 ? `$${v.toFixed(0)}` : v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(3)}`);
|
|
145
|
+
const pct = (v: number, estimated = false): string => `${estimated ? "~" : ""}${Math.round(v * 100)}%`;
|
|
146
|
+
|
|
147
|
+
function delta(current: number, previous: number): string {
|
|
148
|
+
if (previous <= 0) return current > 0 ? " (prev 24h: none)" : "";
|
|
149
|
+
const change = (current - previous) / previous;
|
|
150
|
+
const sign = change >= 0 ? "+" : "−";
|
|
151
|
+
return ` (prev 24h ${usd(previous)}, ${sign}${Math.round(Math.abs(change) * 100)}%)`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Renders the summary as a few plain lines for the transcript. */
|
|
155
|
+
export function renderDailySummary(s: DailySummary): string {
|
|
156
|
+
const scope = s.harnessId === "" ? "all harnesses" : `harness ${s.harnessId}`;
|
|
157
|
+
const out: string[] = [`auto-model-router daily summary — last 24h (${scope})`];
|
|
158
|
+
const c = s.current;
|
|
159
|
+
if (c.dispatches === 0) {
|
|
160
|
+
out.push("no routed turns in the last 24h");
|
|
161
|
+
} else {
|
|
162
|
+
out.push(
|
|
163
|
+
`spend ${usd(c.spendUsd)}${delta(c.spendUsd, s.previous.spendUsd)} · ${c.dispatches} turns · ${c.conversations} conversations · ${usd(c.spendUsd / c.dispatches)}/turn`,
|
|
164
|
+
);
|
|
165
|
+
const moves = c.modelSwitches > 0 ? ` (${s.tierChanges.up} tier up, ${s.tierChanges.down} down)` : "";
|
|
166
|
+
out.push(`cache hit ${pct(c.cacheHitRate, c.cacheEstimated)} · ${c.escalations} escalations · ${c.errors} errors · ${c.modelSwitches} model switches${moves}`);
|
|
167
|
+
if (s.topModels.length > 0) {
|
|
168
|
+
out.push(`top models: ${s.topModels.map((m) => `${m.slug} ${usd(m.spendUsd)} (${pct(m.share)}, ${m.dispatches} turns)`).join(" · ")}`);
|
|
169
|
+
}
|
|
170
|
+
if (s.baseline !== null && s.baseline.usd > 0) {
|
|
171
|
+
const b = s.baseline;
|
|
172
|
+
out.push(b.savedShare >= 0 ? `saved ${pct(b.savedShare)} vs ${b.slug} (${usd(b.usd)} at list)` : `cost ${pct(-b.savedShare)} MORE than ${b.slug} (${usd(b.usd)} at list)`);
|
|
173
|
+
}
|
|
174
|
+
const extras: string[] = [];
|
|
175
|
+
if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)}`);
|
|
176
|
+
if (c.subagentSpendUsd > 0) extras.push(`subagents ${usd(c.subagentSpendUsd)}`);
|
|
177
|
+
if (extras.length > 0) out.push(extras.join(" · "));
|
|
178
|
+
}
|
|
179
|
+
if (s.spikes.length === 0) out.push("soft failures: no model spiking in the last hour");
|
|
180
|
+
else {
|
|
181
|
+
out.push(`soft failures SPIKING (${s.spikes.length}):`);
|
|
182
|
+
for (const sp of s.spikes) {
|
|
183
|
+
out.push(` ${sp.slug}: ${pct(sp.recentRate)} of ${sp.recentDispatches} failed in the last 1h (7d baseline ${pct(sp.baselineRate)} of ${sp.baselineDispatches})`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const o = s.ollama;
|
|
187
|
+
if (o !== null) {
|
|
188
|
+
const runway = o.runwayDays === null ? "" : ` · ~${Math.round(o.runwayDays)} days of credits left`;
|
|
189
|
+
out.push(`ollama: ${o.plan === null ? "plan" : `${o.plan} plan`} $${o.usedUsd.toFixed(2)} of $${o.creditsUsd}${runway}`);
|
|
190
|
+
}
|
|
191
|
+
return out.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Once-a-day gate
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** A summary posted less than this long ago is not due again. */
|
|
199
|
+
export const DAILY_SUMMARY_INTERVAL_MS = 20 * 3_600_000;
|
|
200
|
+
|
|
201
|
+
export interface KeyValueStore {
|
|
202
|
+
get(key: string): string | null;
|
|
203
|
+
set(key: string, value: string): void;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A tiny durable key/value store over the `router_kv` table. */
|
|
207
|
+
export function createKv(db: Database): KeyValueStore {
|
|
208
|
+
const getStmt = db.query("SELECT value FROM router_kv WHERE key = $key");
|
|
209
|
+
const setStmt = db.query("INSERT INTO router_kv (key, value, updated_at_ms) VALUES ($key, $value, $at) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at_ms = excluded.updated_at_ms");
|
|
210
|
+
return {
|
|
211
|
+
get(key) {
|
|
212
|
+
const row = getStmt.get({ $key: key }) as { value: string } | null;
|
|
213
|
+
return row === null ? null : row.value;
|
|
214
|
+
},
|
|
215
|
+
set(key, value) {
|
|
216
|
+
setStmt.run({ $key: key, $value: value, $at: Date.now() });
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const shownKey = (harnessId: string): string => `daily_summary_shown:${harnessId}`;
|
|
222
|
+
|
|
223
|
+
/** True when no auto summary has been posted for this harness within the interval. */
|
|
224
|
+
export function summaryDue(kv: KeyValueStore, harnessId: string, nowMs = Date.now()): boolean {
|
|
225
|
+
const last = Number(kv.get(shownKey(harnessId)) ?? "0");
|
|
226
|
+
return !(Number.isFinite(last) && nowMs - last < DAILY_SUMMARY_INTERVAL_MS);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function markSummaryShown(kv: KeyValueStore, harnessId: string, nowMs = Date.now()): void {
|
|
230
|
+
kv.set(shownKey(harnessId), String(nowMs));
|
|
231
|
+
}
|
package/src/cost/types.ts
CHANGED
|
@@ -230,6 +230,25 @@ export interface EscalationCost {
|
|
|
230
230
|
windowDays: number;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* A model whose recent failure rate (probe rejections OpenRouter counts as
|
|
235
|
+
* success, plus attributable transport errors) is well above its own
|
|
236
|
+
* baseline. Visibility only: the ledger data showed soft failures do not
|
|
237
|
+
* cluster tightly enough for a breaker to save money, so the router reports
|
|
238
|
+
* spikes (/health, /router status, the daily summary) rather than acting.
|
|
239
|
+
*/
|
|
240
|
+
export interface SoftFailureSpike {
|
|
241
|
+
slug: string;
|
|
242
|
+
/** Dispatches and failures in the recent window. */
|
|
243
|
+
recentDispatches: number;
|
|
244
|
+
recentFailures: number;
|
|
245
|
+
recentRate: number;
|
|
246
|
+
/** The same, over the baseline window (recent window excluded). */
|
|
247
|
+
baselineDispatches: number;
|
|
248
|
+
baselineFailures: number;
|
|
249
|
+
baselineRate: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
233
252
|
export interface Ledger {
|
|
234
253
|
record(entry: LedgerEntry): void;
|
|
235
254
|
/** Total reported (or predicted, when reported is null) spend for a conversation. */
|
|
@@ -240,8 +259,12 @@ export interface Ledger {
|
|
|
240
259
|
*/
|
|
241
260
|
spendSince(sinceMs: number, harnessId?: string): number;
|
|
242
261
|
blendedRate(windowDays: number): BlendedRate | null;
|
|
243
|
-
/**
|
|
244
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Per-model reliability over the ledger, optionally scoped to a harness.
|
|
264
|
+
* `task` (with `filters.feedbackByTask`) counts only verdicts given on
|
|
265
|
+
* turns of that task type, plus verdicts on turns with no recorded task.
|
|
266
|
+
*/
|
|
267
|
+
trust(slug: string, harnessId?: string, task?: string): ModelTrust | null;
|
|
245
268
|
allTrust(): ModelTrust[];
|
|
246
269
|
/**
|
|
247
270
|
* Per-model responsiveness (mean TTFT + completion throughput), optionally
|
|
@@ -251,7 +274,7 @@ export interface Ledger {
|
|
|
251
274
|
*/
|
|
252
275
|
latency(slug: string, harnessId?: string): ModelLatency | null;
|
|
253
276
|
/** Batch trust and latency for one candidate set; one query per signal kind. Optional — callers can fall back to per-slug calls. */
|
|
254
|
-
signals?(slugs: readonly string[], harnessId?: string): Map<string, LedgerSignals>;
|
|
277
|
+
signals?(slugs: readonly string[], harnessId?: string, task?: string): Map<string, LedgerSignals>;
|
|
255
278
|
/**
|
|
256
279
|
* What an escalated retry actually bills per prompt token, measured over
|
|
257
280
|
* the last `windowDays` of attempt > 0 rows. Null until enough escalated
|
|
@@ -271,6 +294,11 @@ export interface Ledger {
|
|
|
271
294
|
recentEntries(limit: number): LedgerEntry[];
|
|
272
295
|
/** Spend since an instant on slugs with a prefix (`ollama/`), for provider-level reconciliation. Optional. */
|
|
273
296
|
providerSpendSince?(slugPrefix: string, sinceMs: number): number;
|
|
297
|
+
/**
|
|
298
|
+
* Models whose soft-failure rate over the last `recentMs` is a spike against
|
|
299
|
+
* their own rate over the preceding `baselineMs`. Optional; visibility only.
|
|
300
|
+
*/
|
|
301
|
+
softFailureSpikes?(nowMs?: number, recentMs?: number, baselineMs?: number): SoftFailureSpike[];
|
|
274
302
|
/** Newest kept (non-wasted) entry for an omp session, for /router why and feedback. Optional so fakes need not implement it. */
|
|
275
303
|
latestForSession?(ompSessionId: string): LedgerEntry | null;
|
|
276
304
|
/** Newest entries for an omp session, newest first. Optional. */
|
package/src/router/candidates.ts
CHANGED
|
@@ -263,7 +263,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
263
263
|
const signals = args.signals;
|
|
264
264
|
const trust =
|
|
265
265
|
signals?.get(slug)?.trust ??
|
|
266
|
-
ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ??
|
|
266
|
+
ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined, filters.feedbackByTask ? task : undefined) ??
|
|
267
267
|
null;
|
|
268
268
|
if (!relaxTrust && trust !== null && trust.attempts >= filters.minTrustSamples && trust.successRate < filters.minTrust) {
|
|
269
269
|
rejected.push({
|
package/src/router/classify.ts
CHANGED
|
@@ -10,7 +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
|
+
import { learnedRiskName, loadLearnedModel, predictRisk } from "./learned.ts";
|
|
14
14
|
import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
15
15
|
import type { Classification, Features, TaskType, Tier } from "./types.ts";
|
|
16
16
|
|
|
@@ -313,7 +313,7 @@ export async function classify(
|
|
|
313
313
|
if (model !== null) {
|
|
314
314
|
const risk = predictRisk(model, f);
|
|
315
315
|
heuristic.learnedRisk = risk;
|
|
316
|
-
heuristic.reasons.push(`learned: p(
|
|
316
|
+
heuristic.reasons.push(`learned: p(${learnedRiskName(model)})=${risk.toFixed(3)}`);
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
if (cc.ambiguityThreshold <= 0 || heuristic.confidence >= cc.ambiguityThreshold) return heuristic;
|
package/src/router/compaction.ts
CHANGED
|
@@ -33,6 +33,7 @@ const BREADCRUMB_BYTES = 120;
|
|
|
33
33
|
*/
|
|
34
34
|
export function compactedBytes(originalBytes: number, edit: CompactionEdit | undefined): number {
|
|
35
35
|
if (edit === undefined) return originalBytes;
|
|
36
|
+
if (edit.digest !== undefined) return Math.min(originalBytes, Buffer.byteLength(edit.digest));
|
|
36
37
|
const kept = edit.mode === "stub" ? BREADCRUMB_BYTES : edit.keepHead + edit.keepTail + BREADCRUMB_BYTES;
|
|
37
38
|
return Math.min(originalBytes, kept);
|
|
38
39
|
}
|
package/src/router/learned.ts
CHANGED
|
@@ -77,8 +77,13 @@ export function learnedVector(f: Partial<Features>): number[] {
|
|
|
77
77
|
];
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** What a learned model's positive class means. */
|
|
81
|
+
export type LearnedLabel = "escalation" | "feedback";
|
|
82
|
+
|
|
80
83
|
export interface LearnedModel {
|
|
81
84
|
version: number;
|
|
85
|
+
/** Positive class: the turn escalated (default), or the user judged it bad. */
|
|
86
|
+
label?: LearnedLabel;
|
|
82
87
|
trainedAtMs: number;
|
|
83
88
|
rows: number;
|
|
84
89
|
positives: number;
|
|
@@ -93,7 +98,12 @@ export interface LearnedModel {
|
|
|
93
98
|
|
|
94
99
|
const sigmoid = (z: number): number => 1 / (1 + Math.exp(-z));
|
|
95
100
|
|
|
96
|
-
/**
|
|
101
|
+
/** The positive-class name a decision reason should print for a model. */
|
|
102
|
+
export function learnedRiskName(model: LearnedModel): string {
|
|
103
|
+
return model.label === "feedback" ? "bad" : "escalate";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** P(positive class) for one turn under a model: p(escalate), or p(bad) for a feedback-labelled model. */
|
|
97
107
|
export function predictRisk(model: LearnedModel, f: Partial<Features>): number {
|
|
98
108
|
const x = learnedVector(f);
|
|
99
109
|
let z = model.bias;
|