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
package/src/cost/report.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* usage-priced figure the orchestrator computed, else the forecast.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// `num` here is the local thousands formatter for the rendered report, so the
|
|
12
|
+
// coercion helpers are aliased rather than renamed at 40 call sites.
|
|
13
|
+
import { num as asNum, numOrNull as asNumOrNull, type SqlDb } from "../util/sql.ts";
|
|
14
|
+
import type { FeedbackCounts } from "./feedback.ts";
|
|
13
15
|
|
|
14
16
|
export interface ReportTotals {
|
|
15
17
|
dispatches: number;
|
|
@@ -142,9 +144,20 @@ export interface AnatomyShare {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
const USD = "COALESCE(reported_usd, predicted_usd)";
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The usage members this report sums, spelled for the engine in front of it.
|
|
150
|
+
* `json_extract` against `->>` is the whole difference, and getting the
|
|
151
|
+
* BOOLEAN one wrong does not fail — it silently misclassifies every row.
|
|
152
|
+
*/
|
|
153
|
+
function fragments(db: SqlDb): { PT: string; CT: string; COMP: string; EST: string } {
|
|
154
|
+
return {
|
|
155
|
+
PT: db.jsonNum("usage", "promptTokens"),
|
|
156
|
+
CT: db.jsonNum("usage", "cachedTokens"),
|
|
157
|
+
COMP: db.jsonNum("usage", "completionTokens"),
|
|
158
|
+
EST: `${db.jsonBool("usage", "cachedEstimated")} = 1`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
148
161
|
/** Named upstream ids the ledger's provider derivation knows; set by createProviders from the live config. */
|
|
149
162
|
let knownUpstreamIds: () => readonly string[] = () => [];
|
|
150
163
|
/** Ids, or a getter read live so a hot-reloaded list applies; an embedder (the team edition) calls this too, since the registry is per process. */
|
|
@@ -170,11 +183,12 @@ function providerCase(): string {
|
|
|
170
183
|
return `CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ${knownUpstreamIds().map((id) => `WHEN slug LIKE '${id}/%' THEN '${id}'`).join(" ")} ELSE 'openrouter' END`;
|
|
171
184
|
}
|
|
172
185
|
const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
|
|
173
|
-
const EST = "json_extract(usage, '$.cachedEstimated') = 1";
|
|
174
186
|
/** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
|
|
175
187
|
const FORECASTABLE = "reported_usd > 0 AND predicted_usd IS NOT NULL AND wasted = 0 AND error IS NULL AND requested_model <> 'digest'";
|
|
176
188
|
|
|
177
|
-
|
|
189
|
+
function rowSelect(db: SqlDb): string {
|
|
190
|
+
const { PT, CT, COMP, EST } = fragments(db);
|
|
191
|
+
return `
|
|
178
192
|
COUNT(*) AS dispatches,
|
|
179
193
|
COALESCE(SUM(${USD}), 0) AS spend,
|
|
180
194
|
COALESCE(SUM(${PT}), 0) AS prompt_tokens,
|
|
@@ -186,35 +200,44 @@ const ROW_SELECT = `
|
|
|
186
200
|
SUM(CASE WHEN ${STREAMED} AND latency_ms > ttft_ms AND ${COMP} > 0 THEN latency_ms - ttft_ms END) AS elapsed_ms,
|
|
187
201
|
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
188
202
|
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors`;
|
|
203
|
+
}
|
|
189
204
|
|
|
190
205
|
interface RawRow {
|
|
191
206
|
key: string;
|
|
192
|
-
dispatches:
|
|
193
|
-
spend:
|
|
194
|
-
prompt_tokens:
|
|
195
|
-
cached_tokens:
|
|
196
|
-
estimated_rows:
|
|
197
|
-
avg_prompt_tokens:
|
|
198
|
-
ttft_ms:
|
|
199
|
-
ctok_sum:
|
|
200
|
-
elapsed_ms:
|
|
201
|
-
escalations:
|
|
202
|
-
errors:
|
|
207
|
+
dispatches: unknown;
|
|
208
|
+
spend: unknown;
|
|
209
|
+
prompt_tokens: unknown;
|
|
210
|
+
cached_tokens: unknown;
|
|
211
|
+
estimated_rows: unknown;
|
|
212
|
+
avg_prompt_tokens: unknown;
|
|
213
|
+
ttft_ms: unknown;
|
|
214
|
+
ctok_sum: unknown;
|
|
215
|
+
elapsed_ms: unknown;
|
|
216
|
+
escalations: unknown;
|
|
217
|
+
errors: unknown;
|
|
203
218
|
}
|
|
204
219
|
|
|
205
|
-
function toRow(
|
|
220
|
+
function toRow(raw: RawRow, windowSpend: number): ReportRow {
|
|
221
|
+
// Every numeric field goes through `num`: Postgres returns COUNT(*) and
|
|
222
|
+
// BIGINT sums as strings, and arithmetic on those is wrong rather than loud.
|
|
223
|
+
const spend = asNum(raw.spend);
|
|
224
|
+
const promptTokens = asNum(raw.prompt_tokens);
|
|
225
|
+
const cachedTokens = asNum(raw.cached_tokens);
|
|
226
|
+
const ttftMs = asNumOrNull(raw.ttft_ms);
|
|
227
|
+
const elapsedMs = asNumOrNull(raw.elapsed_ms);
|
|
228
|
+
const ctokSum = asNumOrNull(raw.ctok_sum);
|
|
206
229
|
return {
|
|
207
|
-
key:
|
|
208
|
-
dispatches:
|
|
209
|
-
spendUsd:
|
|
210
|
-
share: windowSpend > 0 ?
|
|
211
|
-
cacheHitRate:
|
|
212
|
-
cacheEstimated: (
|
|
213
|
-
avgPromptTokens: Math.round(
|
|
214
|
-
avgTtftMs:
|
|
215
|
-
tokensPerSec:
|
|
216
|
-
escalations:
|
|
217
|
-
errors:
|
|
230
|
+
key: raw.key,
|
|
231
|
+
dispatches: asNum(raw.dispatches),
|
|
232
|
+
spendUsd: spend,
|
|
233
|
+
share: windowSpend > 0 ? spend / windowSpend : 0,
|
|
234
|
+
cacheHitRate: promptTokens > 0 ? cachedTokens / promptTokens : 0,
|
|
235
|
+
cacheEstimated: asNum(raw.estimated_rows) > 0,
|
|
236
|
+
avgPromptTokens: Math.round(asNum(raw.avg_prompt_tokens)),
|
|
237
|
+
avgTtftMs: ttftMs === null ? null : Math.round(ttftMs),
|
|
238
|
+
tokensPerSec: elapsedMs !== null && elapsedMs > 0 && ctokSum !== null ? (ctokSum * 1000) / elapsedMs : null,
|
|
239
|
+
escalations: asNum(raw.escalations),
|
|
240
|
+
errors: asNum(raw.errors),
|
|
218
241
|
};
|
|
219
242
|
}
|
|
220
243
|
|
|
@@ -228,15 +251,45 @@ export function harnessFilter(harnessId: string, param = "$harness"): { sql: str
|
|
|
228
251
|
return { sql: [`harness_id IN (${ids.map((_, i) => `${param}${i}`).join(", ")})`], bind };
|
|
229
252
|
}
|
|
230
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Verdict counts per model, scoped to the judging harness.
|
|
256
|
+
*
|
|
257
|
+
* Read here rather than through `createFeedbackStore`, which still owns the
|
|
258
|
+
* WRITE path on a bun:sqlite handle: the report only ever counted, and a
|
|
259
|
+
* second engine would otherwise need the whole store ported to read two
|
|
260
|
+
* columns. A ledger nobody has judged has no table at all.
|
|
261
|
+
*/
|
|
262
|
+
async function feedbackCounts(db: SqlDb, sinceMs: number, harnessId: string): Promise<Map<string, FeedbackCounts>> {
|
|
263
|
+
const out = new Map<string, FeedbackCounts>();
|
|
264
|
+
if (!(await db.tableExists("feedback"))) return out;
|
|
265
|
+
const hf = harnessFilter(harnessId, "$fh");
|
|
266
|
+
const where = ["f.created_at_ms >= $since", ...hf.sql.map((s) => s.replace(/^harness_id/, "l.harness_id"))].join(" AND ");
|
|
267
|
+
const rows = await db.query<{ slug: string; good: unknown; bad: unknown }>(
|
|
268
|
+
`SELECT f.slug,
|
|
269
|
+
COALESCE(SUM(CASE WHEN f.verdict = 'good' THEN 1 ELSE 0 END), 0) AS good,
|
|
270
|
+
COALESCE(SUM(CASE WHEN f.verdict = 'bad' THEN 1 ELSE 0 END), 0) AS bad
|
|
271
|
+
FROM feedback f LEFT JOIN ledger l ON l.id = f.ledger_id
|
|
272
|
+
WHERE ${where} GROUP BY f.slug`,
|
|
273
|
+
{ $since: sinceMs, ...hf.bind },
|
|
274
|
+
);
|
|
275
|
+
for (const row of rows) out.set(row.slug, { good: asNum(row.good), bad: asNum(row.bad) });
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
231
279
|
/**
|
|
232
280
|
* Builds the report for the last `windowDays`. `harnessId` narrows to one
|
|
233
281
|
* harness (the `X-Omp-Harness` header) or a comma-separated set of them (a
|
|
234
282
|
* team edition group); empty means everything.
|
|
235
283
|
*/
|
|
236
|
-
export function buildUsageReport(
|
|
237
|
-
db:
|
|
284
|
+
export async function buildUsageReport(
|
|
285
|
+
db: SqlDb,
|
|
238
286
|
opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[]; /** Exclusive upper bound; default open-ended. */ untilMs?: number },
|
|
239
|
-
): UsageReport {
|
|
287
|
+
): Promise<UsageReport> {
|
|
288
|
+
const { PT, CT, COMP, EST } = fragments(db);
|
|
289
|
+
const ROW_SELECT = rowSelect(db);
|
|
290
|
+
// A JSON BOOLEAN, like usage.cachedEstimated: the engines disagree on both
|
|
291
|
+
// the accessor and the value's type.
|
|
292
|
+
const subagent = `${db.jsonBool("features", "isSubagent")} = 1`;
|
|
240
293
|
const nowMs = opts.nowMs ?? Date.now();
|
|
241
294
|
const windowDays = Math.max(1, opts.windowDays);
|
|
242
295
|
const sinceMs = nowMs - windowDays * 86_400_000;
|
|
@@ -246,17 +299,16 @@ export function buildUsageReport(
|
|
|
246
299
|
const where = ["created_at_ms >= $since", ...(untilMs === undefined ? [] : ["created_at_ms < $until"]), ...hf.sql].join(" AND ");
|
|
247
300
|
const bind = { $since: sinceMs, ...(untilMs === undefined ? {} : { $until: untilMs }), ...hf.bind };
|
|
248
301
|
|
|
249
|
-
const t = db
|
|
250
|
-
|
|
251
|
-
`SELECT COUNT(*) AS dispatches,
|
|
302
|
+
const t = (await db.one<Record<string, unknown>>(
|
|
303
|
+
`SELECT COUNT(*) AS dispatches,
|
|
252
304
|
COUNT(DISTINCT conversation_key) AS conversations,
|
|
253
305
|
COALESCE(SUM(${USD}), 0) AS spend,
|
|
254
306
|
COALESCE(SUM(${PT}), 0) AS prompt_tokens,
|
|
255
307
|
COALESCE(SUM(${CT}), 0) AS cached_tokens,
|
|
256
308
|
COALESCE(SUM(${COMP}), 0) AS completion_tokens,
|
|
257
309
|
SUM(CASE WHEN ${EST} THEN 1 ELSE 0 END) AS estimated_rows,
|
|
258
|
-
SUM(CASE WHEN
|
|
259
|
-
COALESCE(SUM(CASE WHEN
|
|
310
|
+
SUM(CASE WHEN ${subagent} THEN 1 ELSE 0 END) AS subagent_rows,
|
|
311
|
+
COALESCE(SUM(CASE WHEN ${subagent} THEN ${USD} ELSE 0 END), 0) AS subagent_spend,
|
|
260
312
|
SUM(CASE WHEN requested_model = 'digest' THEN 1 ELSE 0 END) AS digests,
|
|
261
313
|
COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
|
|
262
314
|
COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
|
|
@@ -267,42 +319,22 @@ export function buildUsageReport(
|
|
|
267
319
|
COALESCE(SUM(CASE WHEN ${FORECASTABLE} THEN ABS(predicted_usd - reported_usd) / reported_usd END), 0) AS fc_err,
|
|
268
320
|
SUM(CASE WHEN ${FORECASTABLE} AND predicted_usd > reported_usd THEN 1 ELSE 0 END) AS fc_over,
|
|
269
321
|
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
270
|
-
SUM(CASE WHEN
|
|
322
|
+
SUM(CASE WHEN ${db.contains("reasons", "'failover:'")} THEN 1 ELSE 0 END) AS failovers,
|
|
271
323
|
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
|
|
272
324
|
SUM(CASE WHEN error = 'request aborted' THEN 1 ELSE 0 END) AS aborted
|
|
273
325
|
FROM ledger WHERE ${where}`,
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
conversations: number;
|
|
278
|
-
spend: number;
|
|
279
|
-
prompt_tokens: number;
|
|
280
|
-
cached_tokens: number;
|
|
281
|
-
completion_tokens: number;
|
|
282
|
-
estimated_rows: number | null;
|
|
283
|
-
subagent_rows: number | null;
|
|
284
|
-
subagent_spend: number;
|
|
285
|
-
digests: number | null;
|
|
286
|
-
digest_spend: number;
|
|
287
|
-
digest_input: number;
|
|
288
|
-
digest_reruns: number | null;
|
|
289
|
-
redactions: number;
|
|
290
|
-
redacted_rows: number | null;
|
|
291
|
-
fc_n: number | null;
|
|
292
|
-
fc_err: number;
|
|
293
|
-
fc_over: number | null;
|
|
294
|
-
escalations: number | null;
|
|
295
|
-
failovers: number | null;
|
|
296
|
-
errors: number | null;
|
|
297
|
-
aborted: number | null;
|
|
326
|
+
bind,
|
|
327
|
+
)) as unknown as {
|
|
328
|
+
[key: string]: unknown;
|
|
298
329
|
};
|
|
299
330
|
|
|
300
331
|
// Model switches: consecutive non-wasted rows of one conversation on
|
|
301
332
|
// different slugs. Computed in JS over a slim projection; the window is
|
|
302
333
|
// bounded, and SQLite window functions would make the query less portable.
|
|
303
|
-
const seq = db
|
|
304
|
-
|
|
305
|
-
|
|
334
|
+
const seq = await db.query<{ ck: string; slug: string }>(
|
|
335
|
+
`SELECT conversation_key AS ck, slug FROM ledger WHERE ${where} AND wasted = 0 ORDER BY conversation_key, created_at_ms`,
|
|
336
|
+
bind,
|
|
337
|
+
);
|
|
306
338
|
let switches = 0;
|
|
307
339
|
for (let i = 1; i < seq.length; i++) {
|
|
308
340
|
const a = seq[i - 1]!;
|
|
@@ -310,24 +342,31 @@ export function buildUsageReport(
|
|
|
310
342
|
if (a.ck === b.ck && a.slug !== b.slug) switches++;
|
|
311
343
|
}
|
|
312
344
|
|
|
313
|
-
const windowSpend = t.spend;
|
|
345
|
+
const windowSpend = asNum(t.spend);
|
|
346
|
+
// GROUP BY repeats the expression rather than the alias: Postgres does not
|
|
347
|
+
// accept a select alias there.
|
|
314
348
|
const providers = (
|
|
315
|
-
db.query(
|
|
349
|
+
await db.query<RawRow>(
|
|
350
|
+
`SELECT ${providerCase()} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY ${providerCase()} ORDER BY spend DESC`,
|
|
351
|
+
bind,
|
|
352
|
+
)
|
|
316
353
|
).map((r) => toRow(r, windowSpend));
|
|
317
354
|
|
|
318
|
-
const modelRows = db
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
355
|
+
const modelRows = await db.query<RawRow>(
|
|
356
|
+
`SELECT COALESCE(served_slug, slug) AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY COALESCE(served_slug, slug) ORDER BY spend DESC`,
|
|
357
|
+
bind,
|
|
358
|
+
);
|
|
359
|
+
const tierMix = await db.query<{ key: string; tier: string; n: unknown }>(
|
|
360
|
+
`SELECT COALESCE(served_slug, slug) AS key, tier, COUNT(*) AS n FROM ledger WHERE ${where} GROUP BY COALESCE(served_slug, slug), tier`,
|
|
361
|
+
bind,
|
|
362
|
+
);
|
|
324
363
|
const mixByModel = new Map<string, Record<string, number>>();
|
|
325
364
|
for (const m of tierMix) {
|
|
326
365
|
const rec = mixByModel.get(m.key) ?? {};
|
|
327
|
-
rec[m.tier] = m.n;
|
|
366
|
+
rec[m.tier] = asNum(m.n);
|
|
328
367
|
mixByModel.set(m.key, rec);
|
|
329
368
|
}
|
|
330
|
-
const feedbackBySlug =
|
|
369
|
+
const feedbackBySlug = await feedbackCounts(db, sinceMs, harnessId);
|
|
331
370
|
const models: ModelRow[] = modelRows.map((r) => ({
|
|
332
371
|
...toRow(r, windowSpend),
|
|
333
372
|
// `providerOfSlug`, not a two-way guess: a named upstream's namespace is a provider
|
|
@@ -339,41 +378,44 @@ export function buildUsageReport(
|
|
|
339
378
|
}));
|
|
340
379
|
|
|
341
380
|
const tiers = (
|
|
342
|
-
db.query(`SELECT tier AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY
|
|
381
|
+
await db.query<RawRow>(`SELECT tier AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY tier ORDER BY spend DESC`, bind)
|
|
343
382
|
).map((r) => toRow(r, windowSpend));
|
|
344
383
|
|
|
384
|
+
const dayExpr = db.utcDay("created_at_ms");
|
|
345
385
|
const days = (
|
|
346
|
-
db
|
|
347
|
-
|
|
348
|
-
`SELECT date(created_at_ms / 1000, 'unixepoch') AS day, COUNT(*) AS dispatches, COALESCE(SUM(${USD}), 0) AS spend,
|
|
386
|
+
await db.query<{ day: string; dispatches: unknown; spend: unknown; prompt_tokens: unknown; cached_tokens: unknown }>(
|
|
387
|
+
`SELECT ${dayExpr} AS day, COUNT(*) AS dispatches, COALESCE(SUM(${USD}), 0) AS spend,
|
|
349
388
|
COALESCE(SUM(${PT}), 0) AS prompt_tokens, COALESCE(SUM(${CT}), 0) AS cached_tokens
|
|
350
|
-
FROM ledger WHERE ${where} GROUP BY
|
|
351
|
-
|
|
352
|
-
.all(bind) as { day: string; dispatches: number; spend: number; prompt_tokens: number; cached_tokens: number }[]
|
|
353
|
-
).map((d) => ({
|
|
354
|
-
day: d.day,
|
|
355
|
-
dispatches: d.dispatches,
|
|
356
|
-
spendUsd: d.spend,
|
|
357
|
-
cacheHitRate: d.prompt_tokens > 0 ? d.cached_tokens / d.prompt_tokens : 0,
|
|
358
|
-
}));
|
|
359
|
-
|
|
360
|
-
const an = db
|
|
361
|
-
.query(
|
|
362
|
-
`SELECT COUNT(*) AS rows, AVG(json_extract(features, '$.anatomy.messages')) AS msgs,
|
|
363
|
-
AVG(json_extract(features, '$.anatomy.systemBytes')) AS sys, AVG(json_extract(features, '$.anatomy.userBytes')) AS usr,
|
|
364
|
-
AVG(json_extract(features, '$.anatomy.assistantBytes')) AS asst, AVG(json_extract(features, '$.anatomy.toolBytes')) AS tool,
|
|
365
|
-
AVG(json_extract(features, '$.toolSchemaBytes')) AS schemas,
|
|
366
|
-
AVG(json_extract(features, '$.anatomy.olderHalfBytes')) AS older, AVG(json_extract(features, '$.anatomy.staleToolBytes')) AS stale
|
|
367
|
-
FROM ledger WHERE ${where} AND json_extract(features, '$.anatomy.messages') IS NOT NULL`,
|
|
389
|
+
FROM ledger WHERE ${where} GROUP BY ${dayExpr} ORDER BY 1`,
|
|
390
|
+
bind,
|
|
368
391
|
)
|
|
369
|
-
|
|
392
|
+
).map((d) => {
|
|
393
|
+
const promptTokens = asNum(d.prompt_tokens);
|
|
394
|
+
return {
|
|
395
|
+
day: d.day,
|
|
396
|
+
dispatches: asNum(d.dispatches),
|
|
397
|
+
spendUsd: asNum(d.spend),
|
|
398
|
+
cacheHitRate: promptTokens > 0 ? asNum(d.cached_tokens) / promptTokens : 0,
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const anat = (key: string): string => db.jsonPathNum("features", ["anatomy", key]);
|
|
403
|
+
const an = (await db.one<Record<string, unknown>>(
|
|
404
|
+
`SELECT COUNT(*) AS rows, AVG(${anat("messages")}) AS msgs,
|
|
405
|
+
AVG(${anat("systemBytes")}) AS sys, AVG(${anat("userBytes")}) AS usr,
|
|
406
|
+
AVG(${anat("assistantBytes")}) AS asst, AVG(${anat("toolBytes")}) AS tool,
|
|
407
|
+
AVG(${db.jsonNum("features", "toolSchemaBytes")}) AS schemas,
|
|
408
|
+
AVG(${anat("olderHalfBytes")}) AS older, AVG(${anat("staleToolBytes")}) AS stale
|
|
409
|
+
FROM ledger WHERE ${where} AND ${anat("messages")} IS NOT NULL`,
|
|
410
|
+
bind,
|
|
411
|
+
)) ?? {};
|
|
370
412
|
let anatomy: AnatomyShare | null = null;
|
|
371
|
-
if (an.rows > 0) {
|
|
372
|
-
const total = (an.sys
|
|
373
|
-
const share = (v:
|
|
413
|
+
if (asNum(an.rows) > 0) {
|
|
414
|
+
const total = asNum(an.sys) + asNum(an.usr) + asNum(an.asst) + asNum(an.tool);
|
|
415
|
+
const share = (v: unknown): number => (total > 0 ? asNum(v) / total : 0);
|
|
374
416
|
anatomy = {
|
|
375
|
-
rows: an.rows,
|
|
376
|
-
avgMessages: Math.round(an.msgs
|
|
417
|
+
rows: asNum(an.rows),
|
|
418
|
+
avgMessages: Math.round(asNum(an.msgs)),
|
|
377
419
|
system: share(an.sys),
|
|
378
420
|
user: share(an.usr),
|
|
379
421
|
assistant: share(an.asst),
|
|
@@ -388,9 +430,9 @@ export function buildUsageReport(
|
|
|
388
430
|
// price with the window's own cache hit rate (cached tokens read at the
|
|
389
431
|
// baseline's cache rate, or full price when it publishes none).
|
|
390
432
|
const baselines: BaselineRow[] = (opts.baselines ?? []).map((b) => {
|
|
391
|
-
const fresh = Math.max(0, t.prompt_tokens - t.cached_tokens);
|
|
392
|
-
const usd = fresh * b.prompt + t.cached_tokens * (b.cacheRead ?? b.prompt) + t.completion_tokens * b.completion;
|
|
393
|
-
return { slug: b.slug, usd, savedShare: usd > 0 ? 1 -
|
|
433
|
+
const fresh = Math.max(0, asNum(t.prompt_tokens) - asNum(t.cached_tokens));
|
|
434
|
+
const usd = fresh * b.prompt + asNum(t.cached_tokens) * (b.cacheRead ?? b.prompt) + asNum(t.completion_tokens) * b.completion;
|
|
435
|
+
return { slug: b.slug, usd, savedShare: usd > 0 ? 1 - windowSpend / usd : 0 };
|
|
394
436
|
});
|
|
395
437
|
|
|
396
438
|
return {
|
|
@@ -399,29 +441,29 @@ export function buildUsageReport(
|
|
|
399
441
|
sinceMs,
|
|
400
442
|
harnessId,
|
|
401
443
|
totals: {
|
|
402
|
-
dispatches: t.dispatches,
|
|
403
|
-
conversations: t.conversations,
|
|
404
|
-
spendUsd:
|
|
405
|
-
cacheHitRate: t.prompt_tokens > 0 ? t.cached_tokens / t.prompt_tokens : 0,
|
|
406
|
-
promptTokens: t.prompt_tokens,
|
|
407
|
-
completionTokens: t.completion_tokens,
|
|
408
|
-
escalations: t.escalations
|
|
409
|
-
failovers: t.failovers
|
|
410
|
-
errors: t.errors
|
|
411
|
-
aborted: t.aborted
|
|
444
|
+
dispatches: asNum(t.dispatches),
|
|
445
|
+
conversations: asNum(t.conversations),
|
|
446
|
+
spendUsd: windowSpend,
|
|
447
|
+
cacheHitRate: asNum(t.prompt_tokens) > 0 ? asNum(t.cached_tokens) / asNum(t.prompt_tokens) : 0,
|
|
448
|
+
promptTokens: asNum(t.prompt_tokens),
|
|
449
|
+
completionTokens: asNum(t.completion_tokens),
|
|
450
|
+
escalations: asNum(t.escalations),
|
|
451
|
+
failovers: asNum(t.failovers),
|
|
452
|
+
errors: asNum(t.errors),
|
|
453
|
+
aborted: asNum(t.aborted),
|
|
412
454
|
modelSwitches: switches,
|
|
413
|
-
cacheEstimated: (t.estimated_rows
|
|
414
|
-
subagentDispatches: t.subagent_rows
|
|
415
|
-
subagentSpendUsd: t.subagent_spend,
|
|
416
|
-
digests: t.digests
|
|
417
|
-
digestSpendUsd: t.digest_spend,
|
|
418
|
-
digestInputTokens: t.digest_input,
|
|
419
|
-
digestReruns: t.digest_reruns
|
|
420
|
-
redactions: t.redactions,
|
|
421
|
-
redactedTurns: t.redacted_rows
|
|
422
|
-
forecastSamples: t.fc_n
|
|
423
|
-
forecastMeanError: (t.fc_n
|
|
424
|
-
forecastOverShare: (t.fc_n
|
|
455
|
+
cacheEstimated: asNum(t.estimated_rows) > 0,
|
|
456
|
+
subagentDispatches: asNum(t.subagent_rows),
|
|
457
|
+
subagentSpendUsd: asNum(t.subagent_spend),
|
|
458
|
+
digests: asNum(t.digests),
|
|
459
|
+
digestSpendUsd: asNum(t.digest_spend),
|
|
460
|
+
digestInputTokens: asNum(t.digest_input),
|
|
461
|
+
digestReruns: asNum(t.digest_reruns),
|
|
462
|
+
redactions: asNum(t.redactions),
|
|
463
|
+
redactedTurns: asNum(t.redacted_rows),
|
|
464
|
+
forecastSamples: asNum(t.fc_n),
|
|
465
|
+
forecastMeanError: asNum(t.fc_n) > 0 ? asNum(t.fc_err) / asNum(t.fc_n) : 0,
|
|
466
|
+
forecastOverShare: asNum(t.fc_n) > 0 ? asNum(t.fc_over) / asNum(t.fc_n) : 0,
|
|
425
467
|
},
|
|
426
468
|
providers,
|
|
427
469
|
models,
|
package/src/cost/retention.ts
CHANGED
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
* than a detail of the caller.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type { AsyncLedger, PruneResult } from "./types.ts";
|
|
16
16
|
|
|
17
17
|
/** Floor between two scheduled prunes. A retention window is measured in days; an hour is fine grain for it. */
|
|
18
18
|
export const RETENTION_INTERVAL_MS = 3_600_000;
|
|
19
19
|
|
|
20
20
|
export interface RetentionRunner {
|
|
21
21
|
/** Prunes when the interval has elapsed since the last run; null when it was skipped. */
|
|
22
|
-
maybeRun(nowMs?: number): PruneResult | null
|
|
22
|
+
maybeRun(nowMs?: number): Promise<PruneResult | null>;
|
|
23
23
|
/** Prunes regardless (the route), and satisfies the schedule for the next hour. */
|
|
24
|
-
runNow(nowMs?: number): PruneResult
|
|
24
|
+
runNow(nowMs?: number): Promise<PruneResult>;
|
|
25
25
|
/** The configured window this runner would apply, for a caller that reports it. */
|
|
26
26
|
retentionDays(): number | null;
|
|
27
27
|
}
|
|
@@ -34,24 +34,24 @@ export interface RetentionRunner {
|
|
|
34
34
|
* and so lowering it takes effect on the next tick rather than the next boot.
|
|
35
35
|
*/
|
|
36
36
|
export function createRetentionRunner(opts: {
|
|
37
|
-
ledger:
|
|
37
|
+
ledger: AsyncLedger;
|
|
38
38
|
retentionDays: () => number | null;
|
|
39
39
|
intervalMs?: number;
|
|
40
40
|
}): RetentionRunner {
|
|
41
41
|
const intervalMs = opts.intervalMs ?? RETENTION_INTERVAL_MS;
|
|
42
42
|
// Never run: the first call is always due, so a lowered window applies at boot.
|
|
43
43
|
let lastRunMs: number | null = null;
|
|
44
|
-
const run = (nowMs: number): PruneResult => {
|
|
44
|
+
const run = async (nowMs: number): Promise<PruneResult> => {
|
|
45
45
|
lastRunMs = nowMs;
|
|
46
|
-
return opts.ledger.prune
|
|
46
|
+
return await opts.ledger.prune(opts.retentionDays(), nowMs);
|
|
47
47
|
};
|
|
48
48
|
return {
|
|
49
|
-
maybeRun(nowMs = Date.now()) {
|
|
49
|
+
async maybeRun(nowMs = Date.now()) {
|
|
50
50
|
if (lastRunMs !== null && nowMs - lastRunMs < intervalMs) return null;
|
|
51
|
-
return run(nowMs);
|
|
51
|
+
return await run(nowMs);
|
|
52
52
|
},
|
|
53
|
-
runNow(nowMs = Date.now()) {
|
|
54
|
-
return run(nowMs);
|
|
53
|
+
async runNow(nowMs = Date.now()) {
|
|
54
|
+
return await run(nowMs);
|
|
55
55
|
},
|
|
56
56
|
retentionDays: opts.retentionDays,
|
|
57
57
|
};
|
package/src/cost/summary.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Database } from "bun:sqlite";
|
|
15
|
+
import type { SqlDb } from "../util/sql.ts";
|
|
15
16
|
import { TIER_ORDER } from "../router/types.ts";
|
|
16
17
|
import { buildUsageReport, harnessFilter, type BaselinePrice, type BaselineRow, type UsageReport } from "./report.ts";
|
|
17
18
|
import type { SoftFailureSpike } from "./types.ts";
|
|
@@ -88,13 +89,15 @@ function windowOf(r: UsageReport): SummaryWindow {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/** Counts tier moves up and down between consecutive kept turns of each conversation since `sinceMs`. */
|
|
91
|
-
export function countTierChanges(db:
|
|
92
|
+
export async function countTierChanges(db: SqlDb, sinceMs: number, harnessId: string): Promise<{ up: number; down: number }> {
|
|
92
93
|
const hf = harnessFilter(harnessId);
|
|
93
94
|
const where = ["created_at_ms >= $since", ...hf.sql].join(" AND ");
|
|
94
95
|
const bind = { $since: sinceMs, ...hf.bind };
|
|
95
|
-
const seq = db
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
const seq = await db.query<{ ck: string; tier: string }>(
|
|
97
|
+
`SELECT conversation_key AS ck, tier FROM ledger WHERE ${where} AND wasted = 0 AND requested_model <> 'digest'
|
|
98
|
+
ORDER BY conversation_key, created_at_ms`,
|
|
99
|
+
bind,
|
|
100
|
+
);
|
|
98
101
|
let up = 0;
|
|
99
102
|
let down = 0;
|
|
100
103
|
for (let i = 1; i < seq.length; i++) {
|
|
@@ -110,8 +113,8 @@ export function countTierChanges(db: Database, sinceMs: number, harnessId: strin
|
|
|
110
113
|
return { up, down };
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
export function buildDailySummary(
|
|
114
|
-
db:
|
|
116
|
+
export async function buildDailySummary(
|
|
117
|
+
db: SqlDb,
|
|
115
118
|
opts: {
|
|
116
119
|
harnessId?: string;
|
|
117
120
|
nowMs?: number;
|
|
@@ -119,12 +122,14 @@ export function buildDailySummary(
|
|
|
119
122
|
spikes?: readonly SoftFailureSpike[];
|
|
120
123
|
ollama?: SummaryOllama | null;
|
|
121
124
|
} = {},
|
|
122
|
-
): DailySummary {
|
|
125
|
+
): Promise<DailySummary> {
|
|
123
126
|
const nowMs = opts.nowMs ?? Date.now();
|
|
124
127
|
const harnessId = opts.harnessId ?? "";
|
|
125
128
|
const baselines = opts.baselines ?? [];
|
|
126
|
-
const current = buildUsageReport(db, { windowDays: 1, harnessId, nowMs, baselines });
|
|
127
|
-
|
|
129
|
+
const current = await buildUsageReport(db, { windowDays: 1, harnessId, nowMs, baselines });
|
|
130
|
+
// The previous window ends where this one starts, so the two never
|
|
131
|
+
// double-count a turn on the boundary.
|
|
132
|
+
const previous = await buildUsageReport(db, { windowDays: 1, harnessId, nowMs: nowMs - DAY_MS, untilMs: current.sinceMs });
|
|
128
133
|
return {
|
|
129
134
|
generatedAtMs: nowMs,
|
|
130
135
|
sinceMs: current.sinceMs,
|
|
@@ -132,7 +137,7 @@ export function buildDailySummary(
|
|
|
132
137
|
current: windowOf(current),
|
|
133
138
|
previous: windowOf(previous),
|
|
134
139
|
topModels: current.models.slice(0, TOP_MODELS).map((m) => ({ slug: m.key, spendUsd: m.spendUsd, share: m.share, dispatches: m.dispatches })),
|
|
135
|
-
tierChanges: countTierChanges(db, current.sinceMs, harnessId),
|
|
140
|
+
tierChanges: await countTierChanges(db, current.sinceMs, harnessId),
|
|
136
141
|
baseline: current.baselines[0] ?? null,
|
|
137
142
|
spikes: [...(opts.spikes ?? [])],
|
|
138
143
|
ollama: opts.ollama ?? null,
|
package/src/cost/types.ts
CHANGED
|
@@ -283,66 +283,47 @@ export interface SoftFailureSpike {
|
|
|
283
283
|
baselineRate: number;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
/** Spend since an instant on slugs with a prefix (`ollama/`), for provider-level reconciliation. Optional. */
|
|
330
|
-
providerSpendSince?(slugPrefix: string, sinceMs: number): number;
|
|
331
|
-
/**
|
|
332
|
-
* Models whose soft-failure rate over the last `recentMs` is a spike against
|
|
333
|
-
* their own rate over the preceding `baselineMs`. Optional; visibility only.
|
|
334
|
-
*/
|
|
335
|
-
softFailureSpikes?(nowMs?: number, recentMs?: number, baselineMs?: number): SoftFailureSpike[];
|
|
336
|
-
/** Newest kept (non-wasted) entry for an omp session, for /router why and feedback. Optional so fakes need not implement it. */
|
|
337
|
-
latestForSession?(ompSessionId: string): LedgerEntry | null;
|
|
338
|
-
/** Newest entries for an omp session, newest first. Optional. */
|
|
339
|
-
entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
|
|
340
|
-
/**
|
|
341
|
-
* Deletes ledger rows past the retention window, and the feedback keyed to
|
|
342
|
-
* them (`null` or 0 ⇒ nothing is deleted). Optional so fakes need not
|
|
343
|
-
* implement it.
|
|
344
|
-
*/
|
|
345
|
-
prune?(retentionDays: number | null, nowMs?: number): PruneResult;
|
|
346
|
-
/** Marks one row wasted after the fact (a digest the agent went back on). Optional. */
|
|
347
|
-
markWasted?(id: string): void;
|
|
286
|
+
/**
|
|
287
|
+
* The turn's ledger reads, asynchronously. A local SQLite ledger satisfies this
|
|
288
|
+
* trivially (its reads are already synchronous); a Postgres-backed one cannot
|
|
289
|
+
* be read from inside `select`, so this is the seam the router prefetches
|
|
290
|
+
* through. One implementation per storage engine, and the routing core never
|
|
291
|
+
* learns which it is talking to.
|
|
292
|
+
*/
|
|
293
|
+
export interface LedgerReader {
|
|
294
|
+
signals(slugs: readonly string[], harnessId?: string, task?: string): Promise<Map<string, LedgerSignals>>;
|
|
295
|
+
/** Warm-cache hit rates for the slugs a turn may choose between. */
|
|
296
|
+
cacheReliability(slugs: readonly string[]): Promise<Map<string, ModelCacheReliability>>;
|
|
297
|
+
escalationCost(windowDays: number): Promise<EscalationCost | null>;
|
|
298
|
+
spendSince(sinceMs: number, harnessId?: string): Promise<number>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The ledger. One interface, one implementation (`ledger-sql.ts`), either
|
|
304
|
+
* engine underneath.
|
|
305
|
+
*
|
|
306
|
+
* Every method is a promise because the store may be a shared database rather
|
|
307
|
+
* than a local file, and a Postgres read cannot be made synchronous. Nothing
|
|
308
|
+
* is optional: a caller made to guess which half of an interface it holds is
|
|
309
|
+
* how a signal silently goes missing, and test doubles get their omissions
|
|
310
|
+
* filled by `test/fakes.ts` instead. `LedgerReader` stays the narrow seam the
|
|
311
|
+
* turn path prefetches through; this is the full surface the server and the
|
|
312
|
+
* reports use.
|
|
313
|
+
*/
|
|
314
|
+
export interface AsyncLedger extends LedgerReader {
|
|
315
|
+
record(entry: LedgerEntry): Promise<void>;
|
|
316
|
+
conversationSpend(conversationKey: string): Promise<number>;
|
|
317
|
+
blendedRate(windowDays: number): Promise<BlendedRate | null>;
|
|
318
|
+
trust(slug: string, harnessId?: string, task?: string): Promise<ModelTrust | null>;
|
|
319
|
+
allTrust(): Promise<ModelTrust[]>;
|
|
320
|
+
latency(slug: string, harnessId?: string): Promise<ModelLatency | null>;
|
|
321
|
+
tokenRatio(tokenizer: string): Promise<number | null>;
|
|
322
|
+
recentEntries(limit: number): Promise<LedgerEntry[]>;
|
|
323
|
+
providerSpendSince(slugPrefix: string, sinceMs: number): Promise<number>;
|
|
324
|
+
softFailureSpikes(nowMs?: number, recentMs?: number, baselineMs?: number): Promise<SoftFailureSpike[]>;
|
|
325
|
+
latestForSession(ompSessionId: string): Promise<LedgerEntry | null>;
|
|
326
|
+
entriesForSession(ompSessionId: string, limit: number): Promise<LedgerEntry[]>;
|
|
327
|
+
prune(retentionDays: number | null, nowMs?: number): Promise<PruneResult>;
|
|
328
|
+
markWasted(id: string): Promise<void>;
|
|
348
329
|
}
|