auto-model-router 0.30.3 → 0.32.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 +32 -2
- package/omp-extension/router-configure.ts +9 -7
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +8 -7
- package/src/cli/explain.ts +10 -5
- package/src/cli/export.ts +6 -5
- package/src/cli/models.ts +10 -7
- package/src/cli/report.ts +6 -1
- package/src/cli/stats.ts +7 -7
- package/src/config/load.ts +10 -1
- package/src/config/types.ts +10 -1
- package/src/context/bridge.ts +7 -7
- package/src/context/index.ts +3 -3
- package/src/context/store.ts +39 -56
- package/src/context/types.ts +7 -6
- package/src/cost/blended.ts +28 -7
- package/src/cost/feedback.ts +33 -37
- package/src/cost/ledger-sql.ts +547 -0
- package/src/cost/ledger.ts +30 -459
- package/src/cost/report.ts +171 -129
- package/src/cost/retention.ts +10 -10
- package/src/cost/summary.ts +15 -10
- package/src/cost/types.ts +43 -62
- package/src/cost/views.ts +79 -49
- package/src/eval/calibrate.ts +47 -12
- package/src/eval/run.ts +18 -2
- package/src/lib.ts +6 -2
- package/src/router/candidates.ts +7 -15
- package/src/router/classify.ts +6 -4
- package/src/router/index.ts +95 -9
- package/src/router/select.ts +38 -21
- package/src/router/state.ts +90 -102
- package/src/router/types.ts +11 -5
- package/src/server/advise.ts +6 -4
- package/src/server/compaction-digest.ts +1 -1
- package/src/server/digest.ts +9 -10
- package/src/server/http.ts +109 -46
- package/src/server/providers.ts +18 -4
- package/src/server/turn.ts +32 -9
- package/src/tokens/estimate.ts +16 -6
- package/src/upstream/ollama-usage.ts +21 -11
- package/src/util/schema.ts +201 -0
- package/src/util/sql.ts +246 -0
- package/src/wire/anthropic/messages.ts +3 -4
- package/src/wire/openai/request.ts +1 -0
- package/src/wire/types.ts +7 -0
- package/test/anthropic-wire.test.ts +9 -9
- package/test/benchmark-feeds.test.ts +7 -7
- package/test/cache-control.test.ts +7 -7
- package/test/cache-estimate.test.ts +5 -5
- package/test/catalog-view.test.ts +4 -4
- package/test/catalog.test.ts +11 -11
- package/test/classify.test.ts +24 -24
- package/test/compaction.test.ts +20 -20
- package/test/config-wizard.test.ts +32 -32
- package/test/config.test.ts +10 -10
- package/test/connect-harnesses.test.ts +11 -11
- package/test/context-bridge.test.ts +40 -30
- package/test/context-prune.test.ts +43 -36
- package/test/context-query.test.ts +8 -8
- package/test/controls.test.ts +54 -27
- package/test/cost.test.ts +12 -12
- package/test/digest.test.ts +55 -44
- package/test/embed-lifecycle.test.ts +5 -5
- package/test/embed-logic.test.ts +26 -26
- package/test/escalate.test.ts +17 -17
- package/test/eval.test.ts +73 -16
- package/test/executable.test.ts +6 -6
- package/test/exploration.test.ts +19 -20
- package/test/failover.test.ts +22 -21
- package/test/fakes.ts +105 -0
- package/test/features.test.ts +21 -21
- package/test/harness-requests.test.ts +3 -3
- package/test/harness-switch.test.ts +5 -5
- package/test/hold-exploration.test.ts +13 -13
- package/test/hot-reload.test.ts +5 -5
- package/test/learned.test.ts +5 -5
- package/test/ledger-sql.test.ts +342 -0
- package/test/mcp-entry.test.ts +5 -5
- package/test/migrations.test.ts +28 -22
- package/test/models-yml.test.ts +18 -18
- package/test/ollama.test.ts +40 -34
- package/test/omp-credentials.test.ts +16 -16
- package/test/policy.test.ts +3 -3
- package/test/reconfigure.test.ts +4 -4
- package/test/redaction.test.ts +41 -35
- package/test/remote.test.ts +12 -12
- package/test/report-logic.test.ts +8 -8
- package/test/report.test.ts +95 -87
- package/test/retention.test.ts +79 -66
- package/test/schema.test.ts +123 -0
- package/test/scope.test.ts +8 -8
- package/test/select.test.ts +216 -257
- package/test/skills.test.ts +3 -3
- package/test/sql-shim.test.ts +154 -0
- package/test/state.test.ts +43 -36
- package/test/summary.test.ts +38 -27
- package/test/tier-plan.test.ts +45 -62
- package/test/toast-logic.test.ts +31 -31
- package/test/tokens.test.ts +95 -80
- package/test/trust-attribution.test.ts +217 -187
- package/test/trust-window.test.ts +37 -32
- package/test/turn.test.ts +55 -23
- package/test/upstreams.test.ts +13 -13
- package/test/views.test.ts +81 -59
- package/test/wire-request.test.ts +17 -17
- package/test/wire-responses.test.ts +4 -4
- package/tools/agentdox-e2e.ts +5 -2
- package/tools/export-benchmarks.ts +5 -5
- package/tools/ledger-parity.ts +266 -0
- package/tools/replay.ts +16 -8
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger, once, over either engine.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the pair of backends this repo briefly carried (a synchronous
|
|
5
|
+
* SQLite ledger and a Postgres one): two implementations of the same meaning
|
|
6
|
+
* drift, and the ways they drift are silent — a `SUM()` returned as a string
|
|
7
|
+
* skews a trust score by three points without raising anything, and a JSON
|
|
8
|
+
* column stored double-encoded makes an escalation-cost term quietly null.
|
|
9
|
+
* One implementation over `util/sql.ts` cannot diverge from itself.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is async, because Postgres cannot be read synchronously and
|
|
12
|
+
* a ledger that is only sometimes awaitable pushes that distinction into every
|
|
13
|
+
* caller. `select` no longer reads the ledger at all — `router/index.ts`
|
|
14
|
+
* prefetches through `LedgerReader` before ranking — so the turn path awaits
|
|
15
|
+
* these once, concurrently, rather than per candidate.
|
|
16
|
+
*
|
|
17
|
+
* Row shapes and every row→value helper are shared with the original
|
|
18
|
+
* implementation (`toTrust`, `toLatency`, `toEntry`, `foldBlendSamples`), so
|
|
19
|
+
* what a trust score MEANS is defined in exactly one place.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { CatalogModel } from "../catalog/types.ts";
|
|
23
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
24
|
+
import { consumePendingEstimate } from "../tokens/estimate.ts";
|
|
25
|
+
import { jsonParam, jsonValue, num, numOrNull, type SqlDb } from "../util/sql.ts";
|
|
26
|
+
import { foldBlendSamples, type BlendSample } from "./blended.ts";
|
|
27
|
+
import { computeCost } from "./forecast.ts";
|
|
28
|
+
import {
|
|
29
|
+
ATTRIBUTABLE_ERROR,
|
|
30
|
+
CACHE_RELIABILITY_MEMO_MS,
|
|
31
|
+
CACHE_RELIABILITY_ROWS,
|
|
32
|
+
DAY_MS,
|
|
33
|
+
errorKindOf,
|
|
34
|
+
ESCALATION_COST_MEMO_MS,
|
|
35
|
+
LATENCY_WINDOW_ROWS,
|
|
36
|
+
MAX_SANE_BYTES_PER_TOKEN,
|
|
37
|
+
MIN_CALIBRATION_SAMPLES,
|
|
38
|
+
MIN_ESCALATION_SAMPLES,
|
|
39
|
+
MIN_SANE_BYTES_PER_TOKEN,
|
|
40
|
+
SPIKE_BASELINE_MS,
|
|
41
|
+
SPIKE_MIN_DISPATCHES,
|
|
42
|
+
SPIKE_MIN_FAILURES,
|
|
43
|
+
SPIKE_MIN_RATE,
|
|
44
|
+
SPIKE_RATIO,
|
|
45
|
+
SPIKE_RECENT_MS,
|
|
46
|
+
toEntry,
|
|
47
|
+
toLatency,
|
|
48
|
+
toTrust,
|
|
49
|
+
TRUST_SELECT,
|
|
50
|
+
type LedgerRow,
|
|
51
|
+
} from "./ledger.ts";
|
|
52
|
+
import type {
|
|
53
|
+
AsyncLedger,
|
|
54
|
+
BlendedRate,
|
|
55
|
+
CostBreakdown,
|
|
56
|
+
EscalationCost,
|
|
57
|
+
LedgerEntry,
|
|
58
|
+
LedgerSignals,
|
|
59
|
+
ModelCacheReliability,
|
|
60
|
+
ModelLatency,
|
|
61
|
+
ModelTrust,
|
|
62
|
+
PruneResult,
|
|
63
|
+
SoftFailureSpike,
|
|
64
|
+
UsageCounts,
|
|
65
|
+
} from "./types.ts";
|
|
66
|
+
|
|
67
|
+
// The spike thresholds belong to the reader they describe: importing them
|
|
68
|
+
// keeps one meaning across both handles rather than two sets that can drift.
|
|
69
|
+
|
|
70
|
+
interface TrustRowRaw {
|
|
71
|
+
attempts: unknown;
|
|
72
|
+
escalations: unknown;
|
|
73
|
+
errors: unknown;
|
|
74
|
+
failures: unknown;
|
|
75
|
+
mean_cost_error: unknown;
|
|
76
|
+
}
|
|
77
|
+
interface LatencyRowRaw {
|
|
78
|
+
samples: unknown;
|
|
79
|
+
ttft_ms: unknown;
|
|
80
|
+
ctok_sum: unknown;
|
|
81
|
+
elapsed_ms_sum: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Postgres returns counts and BIGINT sums as strings; the helpers do arithmetic. */
|
|
85
|
+
function trustRow(raw: TrustRowRaw): Parameters<typeof toTrust>[1] {
|
|
86
|
+
return {
|
|
87
|
+
attempts: num(raw.attempts),
|
|
88
|
+
escalations: num(raw.escalations),
|
|
89
|
+
errors: num(raw.errors),
|
|
90
|
+
failures: num(raw.failures),
|
|
91
|
+
mean_cost_error: numOrNull(raw.mean_cost_error),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function latencyRow(raw: LatencyRowRaw): Parameters<typeof toLatency>[1] {
|
|
96
|
+
return {
|
|
97
|
+
samples: num(raw.samples),
|
|
98
|
+
ttft_ms: numOrNull(raw.ttft_ms),
|
|
99
|
+
ctok_sum: numOrNull(raw.ctok_sum),
|
|
100
|
+
elapsed_ms_sum: numOrNull(raw.elapsed_ms_sum),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The feedback aggregate, coerced the same way. */
|
|
105
|
+
function feedbackRow(raw: { good: unknown; bad: unknown }): { good: number; bad: number } {
|
|
106
|
+
return { good: num(raw.good), bad: num(raw.bad) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `LATENCY_SELECT` with the JSON access the dialect needs. */
|
|
110
|
+
function latencySelect(db: SqlDb): string {
|
|
111
|
+
const ctok = db.jsonNum("usage", "completionTokens");
|
|
112
|
+
return `COUNT(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL THEN 1 END) AS samples,
|
|
113
|
+
AVG(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL THEN ttft_ms END) AS ttft_ms,
|
|
114
|
+
SUM(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL AND latency_ms > ttft_ms
|
|
115
|
+
AND ${ctok} > 0 THEN ${ctok} END) AS ctok_sum,
|
|
116
|
+
SUM(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL AND latency_ms > ttft_ms
|
|
117
|
+
AND ${ctok} > 0 THEN latency_ms - ttft_ms END) AS elapsed_ms_sum`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const FEEDBACK_AGG = `COALESCE(SUM(CASE WHEN f.verdict = 'good' THEN 1 ELSE 0 END), 0) AS good,
|
|
121
|
+
COALESCE(SUM(CASE WHEN f.verdict = 'bad' THEN 1 ELSE 0 END), 0) AS bad`;
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ledger rows → entries, from either engine.
|
|
126
|
+
*
|
|
127
|
+
* `toEntry` parses the JSON columns because SQLite stores them as TEXT;
|
|
128
|
+
* Postgres hands them back already parsed, and its integers and sums arrive as
|
|
129
|
+
* strings. Normalising here keeps ONE definition of what a ledger entry is, so
|
|
130
|
+
* a front door reading a file and a router reading a database cannot disagree
|
|
131
|
+
* about a turn.
|
|
132
|
+
*/
|
|
133
|
+
export function entriesOf(rows: readonly unknown[]): LedgerEntry[] {
|
|
134
|
+
return rows.map((raw) => {
|
|
135
|
+
const row = raw as Record<string, unknown>;
|
|
136
|
+
const asText = (value: unknown): string | null =>
|
|
137
|
+
value === null || value === undefined ? null : JSON.stringify(jsonValue(value));
|
|
138
|
+
return toEntry({
|
|
139
|
+
...row,
|
|
140
|
+
reasons: JSON.stringify(jsonValue<string[]>(row.reasons) ?? []),
|
|
141
|
+
usage: JSON.stringify(jsonValue<UsageCounts>(row.usage) ?? {}),
|
|
142
|
+
features: asText(row.features),
|
|
143
|
+
classifier_reasons: asText(row.classifier_reasons),
|
|
144
|
+
cost_breakdown: asText(row.cost_breakdown),
|
|
145
|
+
created_at_ms: num(row.created_at_ms),
|
|
146
|
+
turn: num(row.turn),
|
|
147
|
+
predicted_usd: num(row.predicted_usd),
|
|
148
|
+
reported_usd: numOrNull(row.reported_usd),
|
|
149
|
+
attempt: num(row.attempt),
|
|
150
|
+
latency_ms: numOrNull(row.latency_ms),
|
|
151
|
+
ttft_ms: numOrNull(row.ttft_ms),
|
|
152
|
+
wasted: num(row.wasted),
|
|
153
|
+
prompt_tokens_saved: numOrNull(row.prompt_tokens_saved),
|
|
154
|
+
redactions: numOrNull(row.redactions),
|
|
155
|
+
score: numOrNull(row.score),
|
|
156
|
+
confidence: numOrNull(row.confidence),
|
|
157
|
+
} as unknown as LedgerRow);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Where the price for a row's cost breakdown comes from. */
|
|
162
|
+
export interface LedgerDeps {
|
|
163
|
+
/** The router's live catalog. A shared ledger has no catalog cache of its own. */
|
|
164
|
+
findModel(slug: string): CatalogModel | null | undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps): AsyncLedger {
|
|
168
|
+
const { sql } = db;
|
|
169
|
+
const latSelect = latencySelect(db);
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
// Cast suffix for a nullable parameter, so Postgres can type a NULL
|
|
173
|
+
// placeholder; empty on sqlite. Injected as SQL text, not bound.
|
|
174
|
+
const nullText = db.nullableText;
|
|
175
|
+
const promptTokens = db.jsonNum("usage", "promptTokens");
|
|
176
|
+
const cachedTokens = db.jsonNum("usage", "cachedTokens");
|
|
177
|
+
const cachedEstimated = db.jsonBool("usage", "cachedEstimated");
|
|
178
|
+
let cacheMemo: { atMs: number; map: Map<string, ModelCacheReliability> } | null = null;
|
|
179
|
+
let escalationMemo: { atMs: number; windowDays: number; value: EscalationCost | null } | null = null;
|
|
180
|
+
|
|
181
|
+
/** Read at call time: hot reload mutates the config object in place. */
|
|
182
|
+
const cutoffOf = (nowMs: number): number => (cfg.filters.trustWindowDays > 0 ? nowMs - cfg.filters.trustWindowDays * DAY_MS : 0);
|
|
183
|
+
|
|
184
|
+
const feedbackFor = async (
|
|
185
|
+
slugs: readonly string[],
|
|
186
|
+
harnessId: string | undefined,
|
|
187
|
+
cutoff: number,
|
|
188
|
+
task?: string,
|
|
189
|
+
): Promise<Map<string, { good: number; bad: number }>> => {
|
|
190
|
+
const out = new Map<string, { good: number; bad: number }>();
|
|
191
|
+
if (cfg.filters.feedbackWeight <= 0 || slugs.length === 0) return out;
|
|
192
|
+
const harness = harnessId !== undefined && harnessId !== "" ? harnessId : null;
|
|
193
|
+
const scopedTask = cfg.filters.feedbackByTask && task !== undefined && task !== "" ? task : null;
|
|
194
|
+
// The verdict's own age bounds the window; the judged turn supplies the
|
|
195
|
+
// harness and task scoping. A verdict on a turn of another task type, or
|
|
196
|
+
// from another harness, is not evidence about THIS request.
|
|
197
|
+
const rows = (await sql`
|
|
198
|
+
SELECT f.slug, ${sql.unsafe(FEEDBACK_AGG)}
|
|
199
|
+
FROM feedback f
|
|
200
|
+
LEFT JOIN ledger l ON l.id = f.ledger_id
|
|
201
|
+
WHERE f.slug IN ${sql([...slugs])} AND f.created_at_ms > ${cutoff}
|
|
202
|
+
AND (${harness}${sql.unsafe(nullText)} IS NULL OR l.harness_id = ${harness})
|
|
203
|
+
AND (${scopedTask}${sql.unsafe(nullText)} IS NULL OR l.task = ${scopedTask} OR l.task IS NULL)
|
|
204
|
+
GROUP BY f.slug`) as { slug: string; good: unknown; bad: unknown }[];
|
|
205
|
+
for (const row of rows) out.set(row.slug, feedbackRow(row));
|
|
206
|
+
return out;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const trustFor = async (
|
|
210
|
+
slugs: readonly string[],
|
|
211
|
+
harnessId: string | undefined,
|
|
212
|
+
cutoff: number,
|
|
213
|
+
): Promise<Map<string, Parameters<typeof toTrust>[1]>> => {
|
|
214
|
+
const out = new Map<string, Parameters<typeof toTrust>[1]>();
|
|
215
|
+
if (slugs.length === 0) return out;
|
|
216
|
+
const harness = harnessId !== undefined && harnessId !== "" ? harnessId : null;
|
|
217
|
+
const rows = (await sql`
|
|
218
|
+
SELECT slug, ${sql.unsafe(TRUST_SELECT)}
|
|
219
|
+
FROM ledger
|
|
220
|
+
WHERE slug IN ${sql([...slugs])} AND created_at_ms > ${cutoff}
|
|
221
|
+
AND (${harness}${sql.unsafe(nullText)} IS NULL OR harness_id = ${harness})
|
|
222
|
+
GROUP BY slug`) as (TrustRowRaw & { slug: string })[];
|
|
223
|
+
for (const row of rows) out.set(row.slug, trustRow(row));
|
|
224
|
+
return out;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const latencyFor = async (slugs: readonly string[], harnessId: string | undefined): Promise<Map<string, ModelLatency>> => {
|
|
228
|
+
const out = new Map<string, ModelLatency>();
|
|
229
|
+
if (slugs.length === 0) return out;
|
|
230
|
+
const harness = harnessId !== undefined && harnessId !== "" ? harnessId : null;
|
|
231
|
+
// The window is the newest LATENCY_WINDOW_ROWS rows PER SLUG, so it is a
|
|
232
|
+
// ROW_NUMBER partition rather than one global LIMIT.
|
|
233
|
+
const rows = (await sql`
|
|
234
|
+
WITH windowed AS (
|
|
235
|
+
SELECT slug, ttft_ms, latency_ms, error, usage,
|
|
236
|
+
ROW_NUMBER() OVER (PARTITION BY slug ORDER BY created_at_ms DESC) AS rn
|
|
237
|
+
FROM ledger
|
|
238
|
+
WHERE slug IN ${sql([...slugs])} AND (${harness}${sql.unsafe(nullText)} IS NULL OR harness_id = ${harness})
|
|
239
|
+
)
|
|
240
|
+
SELECT slug, ${sql.unsafe(latSelect)} FROM windowed WHERE rn <= ${LATENCY_WINDOW_ROWS} GROUP BY slug`) as (LatencyRowRaw & {
|
|
241
|
+
slug: string;
|
|
242
|
+
})[];
|
|
243
|
+
for (const row of rows) {
|
|
244
|
+
const value = toLatency(row.slug, latencyRow(row));
|
|
245
|
+
if (value !== null) out.set(row.slug, value);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const cacheMap = async (): Promise<Map<string, ModelCacheReliability>> => {
|
|
251
|
+
const nowMs = Date.now();
|
|
252
|
+
if (cacheMemo !== null && nowMs - cacheMemo.atMs <= CACHE_RELIABILITY_MEMO_MS) return cacheMemo.map;
|
|
253
|
+
// Pair each turn with the previous turn of its conversation and measure
|
|
254
|
+
// the hit rate only where a warm cache was actually expected. Rows whose
|
|
255
|
+
// cache count the router ESTIMATED are excluded: they would measure our
|
|
256
|
+
// own guess. `cachedEstimated` is a boolean in the usage JSON, so it is
|
|
257
|
+
// compared as text rather than cast to an integer.
|
|
258
|
+
const rows = (await sql`
|
|
259
|
+
WITH recent AS (
|
|
260
|
+
SELECT conversation_key AS ck, created_at_ms AS t, COALESCE(served_slug, slug) AS s,
|
|
261
|
+
${sql.unsafe(promptTokens)} AS p, ${sql.unsafe(cachedTokens)} AS c,
|
|
262
|
+
${sql.unsafe(cachedEstimated)} AS est
|
|
263
|
+
FROM ledger WHERE wasted = 0 AND error IS NULL
|
|
264
|
+
ORDER BY created_at_ms DESC, id DESC LIMIT ${CACHE_RELIABILITY_ROWS}),
|
|
265
|
+
seq AS (
|
|
266
|
+
SELECT s, p, c, est, t,
|
|
267
|
+
LAG(s) OVER w AS prev_s, LAG(p) OVER w AS prev_p, LAG(t) OVER w AS prev_t
|
|
268
|
+
FROM recent WINDOW w AS (PARTITION BY ck ORDER BY t))
|
|
269
|
+
SELECT s AS slug, COUNT(*) AS samples,
|
|
270
|
+
AVG(${sql.unsafe(db.least("1.0", `c * 1.0 / NULLIF(${db.least("prev_p", "p")}, 0)`))}) AS hit
|
|
271
|
+
FROM seq
|
|
272
|
+
WHERE prev_s = s AND p > 1000 AND prev_p > 1000 AND t - prev_t <= ${cfg.hysteresis.cacheWarmTtlMs} AND est = 0
|
|
273
|
+
GROUP BY s`) as { slug: string; samples: unknown; hit: unknown }[];
|
|
274
|
+
const map = new Map<string, ModelCacheReliability>();
|
|
275
|
+
for (const row of rows) {
|
|
276
|
+
map.set(row.slug, { slug: row.slug, samples: num(row.samples), hitRate: Math.min(1, Math.max(0, num(row.hit))) });
|
|
277
|
+
}
|
|
278
|
+
cacheMemo = { atMs: nowMs, map };
|
|
279
|
+
return map;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const entriesFrom = entriesOf;
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
async record(entry: LedgerEntry): Promise<void> {
|
|
287
|
+
const model =
|
|
288
|
+
entry.priceModel ??
|
|
289
|
+
(entry.servedSlug !== null ? deps.findModel(entry.servedSlug) : undefined) ??
|
|
290
|
+
deps.findModel(entry.slug) ??
|
|
291
|
+
null;
|
|
292
|
+
const breakdown = model !== null ? computeCost(model, entry.usage) : null;
|
|
293
|
+
await sql`
|
|
294
|
+
INSERT INTO ledger (
|
|
295
|
+
id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id,
|
|
296
|
+
slug, served_slug, tier, classification_source, reasons, predicted_usd, reported_usd, usage,
|
|
297
|
+
cost_breakdown, attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted,
|
|
298
|
+
upstream_generation_id, error, error_kind, features, score, confidence, task, classifier_reasons,
|
|
299
|
+
explored_from, hold_arm, prompt_tokens_saved, scope, redactions
|
|
300
|
+
) VALUES (
|
|
301
|
+
${entry.id}, ${entry.createdAtMs}, ${entry.conversationKey}, ${entry.sessionId}, ${entry.turn},
|
|
302
|
+
${entry.requestedModel}, ${entry.harnessId}, ${entry.ompSessionId}, ${entry.slug}, ${entry.servedSlug},
|
|
303
|
+
${entry.tier}, ${entry.classificationSource}, ${jsonParam(db, entry.reasons)}, ${entry.predictedUsd},
|
|
304
|
+
${entry.reportedUsd}, ${jsonParam(db, entry.usage)}, ${jsonParam(db, breakdown)}, ${entry.attempt},
|
|
305
|
+
${entry.escalationSignal}, ${entry.latencyMs}, ${entry.ttftMs}, ${entry.finishReason},
|
|
306
|
+
${entry.wasted ? 1 : 0}, ${entry.upstreamGenerationId}, ${entry.error}, ${errorKindOf(entry.error)},
|
|
307
|
+
${jsonParam(db, entry.features)}, ${entry.score}, ${entry.confidence}, ${entry.task},
|
|
308
|
+
${jsonParam(db, entry.classifierReasons)}, ${entry.exploredFrom}, ${entry.holdArm},
|
|
309
|
+
${entry.promptTokensSaved},
|
|
310
|
+
${entry.scope === undefined || entry.scope === "" ? null : entry.scope}, ${entry.redactions ?? null}
|
|
311
|
+
)
|
|
312
|
+
ON CONFLICT (id) DO NOTHING`;
|
|
313
|
+
// Always consume the pending estimate, even when the turn failed, so a
|
|
314
|
+
// dead turn's bytes can never pair with a later turn's tokens.
|
|
315
|
+
const pending = consumePendingEstimate(entry.conversationKey);
|
|
316
|
+
if (entry.usage.promptTokens > 0 && pending !== null) {
|
|
317
|
+
const tokenizer = (model?.tokenizer ?? pending.tokenizer).trim().toLowerCase();
|
|
318
|
+
// Reject samples no real tokenizer could produce: the rows are
|
|
319
|
+
// running sums, so one provider reporting inflated counts poisons a
|
|
320
|
+
// whole family for thousands of samples.
|
|
321
|
+
const bytesPerToken = pending.bytes / entry.usage.promptTokens;
|
|
322
|
+
if (bytesPerToken >= MIN_SANE_BYTES_PER_TOKEN && bytesPerToken <= MAX_SANE_BYTES_PER_TOKEN) {
|
|
323
|
+
await sql`
|
|
324
|
+
INSERT INTO token_calibration (tokenizer, est_bytes, actual_tokens, samples)
|
|
325
|
+
VALUES (${tokenizer}, ${pending.bytes}, ${entry.usage.promptTokens}, 1)
|
|
326
|
+
ON CONFLICT (tokenizer) DO UPDATE SET
|
|
327
|
+
est_bytes = token_calibration.est_bytes + ${pending.bytes},
|
|
328
|
+
actual_tokens = token_calibration.actual_tokens + ${entry.usage.promptTokens},
|
|
329
|
+
samples = token_calibration.samples + 1`;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async conversationSpend(conversationKey: string): Promise<number> {
|
|
335
|
+
const rows = (await sql`
|
|
336
|
+
SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total
|
|
337
|
+
FROM ledger WHERE conversation_key = ${conversationKey}`) as { total: unknown }[];
|
|
338
|
+
return num(rows[0]?.total);
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
async spendSince(sinceMs: number, harnessId?: string): Promise<number> {
|
|
342
|
+
const harness = harnessId !== undefined && harnessId !== "" ? harnessId : null;
|
|
343
|
+
const rows = (await sql`
|
|
344
|
+
SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total
|
|
345
|
+
FROM ledger
|
|
346
|
+
WHERE created_at_ms >= ${sinceMs} AND (${harness}${sql.unsafe(nullText)} IS NULL OR harness_id = ${harness})`) as { total: unknown }[];
|
|
347
|
+
return num(rows[0]?.total);
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async blendedRate(windowDays: number): Promise<BlendedRate | null> {
|
|
351
|
+
const rows = (await sql`
|
|
352
|
+
SELECT usage, reported_usd, cost_breakdown FROM ledger
|
|
353
|
+
WHERE created_at_ms >= ${Date.now() - windowDays * DAY_MS}
|
|
354
|
+
AND reported_usd IS NOT NULL AND cost_breakdown IS NOT NULL`) as {
|
|
355
|
+
usage: unknown;
|
|
356
|
+
reported_usd: unknown;
|
|
357
|
+
cost_breakdown: unknown;
|
|
358
|
+
}[];
|
|
359
|
+
const samples: BlendSample[] = [];
|
|
360
|
+
for (const row of rows) {
|
|
361
|
+
const usage = jsonValue<UsageCounts>(row.usage);
|
|
362
|
+
const breakdown = jsonValue<CostBreakdown>(row.cost_breakdown);
|
|
363
|
+
if (usage === null || breakdown === null) continue;
|
|
364
|
+
samples.push({ usage, reportedUsd: num(row.reported_usd), breakdown });
|
|
365
|
+
}
|
|
366
|
+
return foldBlendSamples(samples, cfg, windowDays);
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async trust(slug: string, harnessId?: string, task?: string): Promise<ModelTrust | null> {
|
|
370
|
+
const cutoff = cutoffOf(Date.now());
|
|
371
|
+
const rows = await trustFor([slug], harnessId, cutoff);
|
|
372
|
+
const row = rows.get(slug);
|
|
373
|
+
if (row === undefined || row.attempts === 0) return null;
|
|
374
|
+
const fb = await feedbackFor([slug], harnessId, cutoff, task);
|
|
375
|
+
return toTrust(slug, row, fb.get(slug) ?? null, cfg.filters.feedbackWeight);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
async allTrust(): Promise<ModelTrust[]> {
|
|
379
|
+
const cutoff = cutoffOf(Date.now());
|
|
380
|
+
const rows = (await sql`
|
|
381
|
+
SELECT slug, ${sql.unsafe(TRUST_SELECT)} FROM ledger WHERE created_at_ms > ${cutoff} GROUP BY slug`) as (TrustRowRaw & {
|
|
382
|
+
slug: string;
|
|
383
|
+
})[];
|
|
384
|
+
const slugs = rows.map((r) => r.slug);
|
|
385
|
+
const fb = await feedbackFor(slugs, undefined, cutoff);
|
|
386
|
+
return rows.map((row) => toTrust(row.slug, trustRow(row), fb.get(row.slug) ?? null, cfg.filters.feedbackWeight));
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
async latency(slug: string, harnessId?: string): Promise<ModelLatency | null> {
|
|
390
|
+
return (await latencyFor([slug], harnessId)).get(slug) ?? null;
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
async signals(slugs: readonly string[], harnessId?: string, task?: string): Promise<Map<string, LedgerSignals>> {
|
|
394
|
+
const out = new Map<string, LedgerSignals>();
|
|
395
|
+
if (slugs.length === 0) return out;
|
|
396
|
+
const cutoff = cutoffOf(Date.now());
|
|
397
|
+
// One query per signal kind for the whole candidate set, concurrently.
|
|
398
|
+
const [trust, latency, fb] = await Promise.all([
|
|
399
|
+
trustFor(slugs, harnessId, cutoff),
|
|
400
|
+
latencyFor(slugs, harnessId),
|
|
401
|
+
feedbackFor(slugs, harnessId, cutoff, task),
|
|
402
|
+
]);
|
|
403
|
+
for (const slug of slugs) {
|
|
404
|
+
const t = trust.get(slug);
|
|
405
|
+
out.set(slug, {
|
|
406
|
+
trust: t === undefined || t.attempts === 0 ? null : toTrust(slug, t, fb.get(slug) ?? null, cfg.filters.feedbackWeight),
|
|
407
|
+
latency: latency.get(slug) ?? null,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
async cacheReliability(slugs: readonly string[]): Promise<Map<string, ModelCacheReliability>> {
|
|
414
|
+
const map = await cacheMap();
|
|
415
|
+
const out = new Map<string, ModelCacheReliability>();
|
|
416
|
+
for (const slug of slugs) {
|
|
417
|
+
const hit = map.get(slug);
|
|
418
|
+
if (hit !== undefined) out.set(slug, hit);
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
async escalationCost(windowDays: number): Promise<EscalationCost | null> {
|
|
424
|
+
const nowMs = Date.now();
|
|
425
|
+
if (escalationMemo !== null && escalationMemo.windowDays === windowDays && nowMs - escalationMemo.atMs < ESCALATION_COST_MEMO_MS) {
|
|
426
|
+
return escalationMemo.value;
|
|
427
|
+
}
|
|
428
|
+
const rows = (await sql`
|
|
429
|
+
SELECT COUNT(*) AS samples,
|
|
430
|
+
COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS usd,
|
|
431
|
+
COALESCE(SUM(${sql.unsafe(promptTokens)}), 0) AS prompt_tokens
|
|
432
|
+
FROM ledger WHERE attempt > 0 AND error IS NULL AND created_at_ms >= ${nowMs - windowDays * DAY_MS}`) as {
|
|
433
|
+
samples: unknown;
|
|
434
|
+
usd: unknown;
|
|
435
|
+
prompt_tokens: unknown;
|
|
436
|
+
}[];
|
|
437
|
+
const row = rows[0];
|
|
438
|
+
const samples = num(row?.samples);
|
|
439
|
+
const tokens = num(row?.prompt_tokens);
|
|
440
|
+
const value: EscalationCost | null =
|
|
441
|
+
row === undefined || samples < MIN_ESCALATION_SAMPLES || tokens <= 0
|
|
442
|
+
? null
|
|
443
|
+
: { usdPerPromptToken: num(row.usd) / tokens, samples, windowDays };
|
|
444
|
+
escalationMemo = { atMs: nowMs, windowDays, value };
|
|
445
|
+
return value;
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
async tokenRatio(tokenizer: string): Promise<number | null> {
|
|
449
|
+
const rows = (await sql`
|
|
450
|
+
SELECT est_bytes, actual_tokens, samples FROM token_calibration
|
|
451
|
+
WHERE tokenizer = ${tokenizer.trim().toLowerCase()}`) as { est_bytes: unknown; actual_tokens: unknown; samples: unknown }[];
|
|
452
|
+
const row = rows[0];
|
|
453
|
+
if (row === undefined) return null;
|
|
454
|
+
const actual = num(row.actual_tokens);
|
|
455
|
+
if (num(row.samples) < MIN_CALIBRATION_SAMPLES || actual <= 0) return null;
|
|
456
|
+
return num(row.est_bytes) / actual;
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
async recentEntries(limit: number): Promise<LedgerEntry[]> {
|
|
460
|
+
return entriesFrom((await sql`SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ${limit}`) as unknown[]);
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
async softFailureSpikes(nowMs = Date.now(), recentMs = SPIKE_RECENT_MS, baselineMs = SPIKE_BASELINE_MS): Promise<SoftFailureSpike[]> {
|
|
464
|
+
const recentStart = nowMs - recentMs;
|
|
465
|
+
const baselineStart = nowMs - recentMs - baselineMs;
|
|
466
|
+
// Wasted rows stay in: they ARE the soft failures being counted.
|
|
467
|
+
// Digest side calls are excluded — they are not the session's turns.
|
|
468
|
+
const rows = (await sql`
|
|
469
|
+
SELECT COALESCE(served_slug, slug) AS slug,
|
|
470
|
+
SUM(CASE WHEN created_at_ms >= ${recentStart} THEN 1 ELSE 0 END) AS recent_n,
|
|
471
|
+
SUM(CASE WHEN created_at_ms >= ${recentStart} AND (escalation_signal IS NOT NULL OR (${sql.unsafe(ATTRIBUTABLE_ERROR)})) THEN 1 ELSE 0 END) AS recent_f,
|
|
472
|
+
SUM(CASE WHEN created_at_ms < ${recentStart} THEN 1 ELSE 0 END) AS base_n,
|
|
473
|
+
SUM(CASE WHEN created_at_ms < ${recentStart} AND (escalation_signal IS NOT NULL OR (${sql.unsafe(ATTRIBUTABLE_ERROR)})) THEN 1 ELSE 0 END) AS base_f
|
|
474
|
+
FROM ledger
|
|
475
|
+
WHERE created_at_ms >= ${baselineStart} AND created_at_ms <= ${nowMs} AND requested_model <> 'digest'
|
|
476
|
+
GROUP BY COALESCE(served_slug, slug)`) as {
|
|
477
|
+
slug: string;
|
|
478
|
+
recent_n: unknown;
|
|
479
|
+
recent_f: unknown;
|
|
480
|
+
base_n: unknown;
|
|
481
|
+
base_f: unknown;
|
|
482
|
+
}[];
|
|
483
|
+
const spikes: SoftFailureSpike[] = [];
|
|
484
|
+
for (const raw of rows) {
|
|
485
|
+
const recentDispatches = num(raw.recent_n);
|
|
486
|
+
const recentFailures = num(raw.recent_f);
|
|
487
|
+
const baselineDispatches = num(raw.base_n);
|
|
488
|
+
const baselineFailures = num(raw.base_f);
|
|
489
|
+
if (recentDispatches < SPIKE_MIN_DISPATCHES || recentFailures < SPIKE_MIN_FAILURES) continue;
|
|
490
|
+
const recentRate = recentFailures / recentDispatches;
|
|
491
|
+
const baselineRate = baselineDispatches > 0 ? baselineFailures / baselineDispatches : 0;
|
|
492
|
+
if (recentRate < SPIKE_MIN_RATE || recentRate < SPIKE_RATIO * baselineRate) continue;
|
|
493
|
+
spikes.push({ slug: raw.slug, recentDispatches, recentFailures, recentRate, baselineDispatches, baselineFailures, baselineRate });
|
|
494
|
+
}
|
|
495
|
+
spikes.sort((a, b) => b.recentRate - a.recentRate || b.recentFailures - a.recentFailures);
|
|
496
|
+
return spikes;
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
async providerSpendSince(slugPrefix: string, sinceMs: number): Promise<number> {
|
|
500
|
+
const rows = (await sql`
|
|
501
|
+
SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total
|
|
502
|
+
FROM ledger WHERE created_at_ms >= ${sinceMs} AND COALESCE(served_slug, slug) LIKE ${`${slugPrefix}%`}`) as {
|
|
503
|
+
total: unknown;
|
|
504
|
+
}[];
|
|
505
|
+
return num(rows[0]?.total);
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
async prune(retentionDays: number | null, nowMs = Date.now()): Promise<PruneResult> {
|
|
509
|
+
const oldest = async (): Promise<number | null> => {
|
|
510
|
+
const rows = (await sql`SELECT MIN(created_at_ms) AS oldest FROM ledger`) as { oldest: unknown }[];
|
|
511
|
+
return numOrNull(rows[0]?.oldest);
|
|
512
|
+
};
|
|
513
|
+
// null and 0 are the same instruction: keep everything. Still reports
|
|
514
|
+
// how far back the ledger goes, which is what the caller asked.
|
|
515
|
+
if (retentionDays === null || retentionDays <= 0) return { deleted: 0, oldestKeptMs: await oldest() };
|
|
516
|
+
const cutoff = nowMs - retentionDays * DAY_MS;
|
|
517
|
+
// Dependants first: a verdict on a forgotten turn is a note about a
|
|
518
|
+
// conversation the operator asked us to forget. Matched by the row it
|
|
519
|
+
// points at AND its own age, so verdicts orphaned by an older prune are
|
|
520
|
+
// swept up too.
|
|
521
|
+
await sql`DELETE FROM feedback WHERE created_at_ms < ${cutoff} OR ledger_id IN (SELECT id FROM ledger WHERE created_at_ms < ${cutoff})`;
|
|
522
|
+
await sql`DELETE FROM ollama_meter_samples WHERE at_ms < ${cutoff}`;
|
|
523
|
+
const deleted = (await sql`DELETE FROM ledger WHERE created_at_ms < ${cutoff} RETURNING id`) as { id: string }[];
|
|
524
|
+
return { deleted: deleted.length, oldestKeptMs: await oldest() };
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
async markWasted(id: string): Promise<void> {
|
|
528
|
+
await sql`UPDATE ledger SET wasted = 1 WHERE id = ${id}`;
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
async latestForSession(ompSessionId: string): Promise<LedgerEntry | null> {
|
|
532
|
+
if (ompSessionId === "") return null;
|
|
533
|
+
const rows = (await sql`
|
|
534
|
+
SELECT * FROM ledger WHERE omp_session_id = ${ompSessionId} AND wasted = 0 AND requested_model <> 'digest'
|
|
535
|
+
ORDER BY created_at_ms DESC LIMIT 1`) as unknown[];
|
|
536
|
+
return entriesFrom(rows)[0] ?? null;
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
async entriesForSession(ompSessionId: string, limit: number): Promise<LedgerEntry[]> {
|
|
540
|
+
if (ompSessionId === "") return [];
|
|
541
|
+
const rows = (await sql`
|
|
542
|
+
SELECT * FROM ledger WHERE omp_session_id = ${ompSessionId} AND wasted = 0 AND requested_model <> 'digest'
|
|
543
|
+
ORDER BY created_at_ms DESC LIMIT ${Math.max(1, limit)}`) as unknown[];
|
|
544
|
+
return entriesFrom(rows);
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|