auto-model-router 0.2.32 → 0.3.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 +208 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +115 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +189 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +43 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +27 -0
- package/src/config/types.ts +114 -2
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +340 -0
- package/src/cost/types.ts +33 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +52 -4
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +10 -0
- package/src/server/http.ts +47 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +122 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +157 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +506 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +341 -0
- package/test/report-logic.test.ts +92 -0
- package/test/report.test.ts +217 -0
- package/test/select.test.ts +151 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +124 -7
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage analytics over the ledger, for `/router report` in omp and the
|
|
3
|
+
* `auto-model-router report` CLI (and `GET /v1/router/report`).
|
|
4
|
+
*
|
|
5
|
+
* Everything here is SQL over the `ledger` table the router already writes;
|
|
6
|
+
* nothing is sampled or estimated beyond what the rows carry. Costs are the
|
|
7
|
+
* ledger's own rule: reported when the provider gave one, else the
|
|
8
|
+
* usage-priced figure the orchestrator computed, else the forecast.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
|
|
13
|
+
export interface ReportTotals {
|
|
14
|
+
dispatches: number;
|
|
15
|
+
conversations: number;
|
|
16
|
+
spendUsd: number;
|
|
17
|
+
/** Cached prompt tokens over all prompt tokens, 0-1. */
|
|
18
|
+
cacheHitRate: number;
|
|
19
|
+
promptTokens: number;
|
|
20
|
+
completionTokens: number;
|
|
21
|
+
escalations: number;
|
|
22
|
+
failovers: number;
|
|
23
|
+
errors: number;
|
|
24
|
+
aborted: number;
|
|
25
|
+
/** Turns that switched model mid-conversation. */
|
|
26
|
+
modelSwitches: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReportRow {
|
|
30
|
+
key: string;
|
|
31
|
+
dispatches: number;
|
|
32
|
+
spendUsd: number;
|
|
33
|
+
/** Share of window spend, 0-1. */
|
|
34
|
+
share: number;
|
|
35
|
+
cacheHitRate: number;
|
|
36
|
+
avgPromptTokens: number;
|
|
37
|
+
/** Mean time to first token, ms, over streamed non-error rows; null without samples. */
|
|
38
|
+
avgTtftMs: number | null;
|
|
39
|
+
/** Completion tokens per second after first token; null without samples. */
|
|
40
|
+
tokensPerSec: number | null;
|
|
41
|
+
escalations: number;
|
|
42
|
+
errors: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ModelRow extends ReportRow {
|
|
46
|
+
provider: string;
|
|
47
|
+
/** Dispatch counts per tier, e.g. `{ trivial: 12, moderate: 3 }`. */
|
|
48
|
+
tiers: Record<string, number>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DayRow {
|
|
52
|
+
/** UTC calendar day, `YYYY-MM-DD`. */
|
|
53
|
+
day: string;
|
|
54
|
+
dispatches: number;
|
|
55
|
+
spendUsd: number;
|
|
56
|
+
cacheHitRate: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UsageReport {
|
|
60
|
+
generatedAtMs: number;
|
|
61
|
+
windowDays: number;
|
|
62
|
+
sinceMs: number;
|
|
63
|
+
/** Restricted to one harness when given; empty ⇒ all. */
|
|
64
|
+
harnessId: string;
|
|
65
|
+
totals: ReportTotals;
|
|
66
|
+
providers: ReportRow[];
|
|
67
|
+
models: ModelRow[];
|
|
68
|
+
tiers: ReportRow[];
|
|
69
|
+
days: DayRow[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const USD = "COALESCE(reported_usd, predicted_usd)";
|
|
73
|
+
const PT = "json_extract(usage, '$.promptTokens')";
|
|
74
|
+
const CT = "json_extract(usage, '$.cachedTokens')";
|
|
75
|
+
const COMP = "json_extract(usage, '$.completionTokens')";
|
|
76
|
+
const PROVIDER = "CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ELSE 'openrouter' END";
|
|
77
|
+
const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
|
|
78
|
+
|
|
79
|
+
const ROW_SELECT = `
|
|
80
|
+
COUNT(*) AS dispatches,
|
|
81
|
+
COALESCE(SUM(${USD}), 0) AS spend,
|
|
82
|
+
COALESCE(SUM(${PT}), 0) AS prompt_tokens,
|
|
83
|
+
COALESCE(SUM(${CT}), 0) AS cached_tokens,
|
|
84
|
+
COALESCE(AVG(${PT}), 0) AS avg_prompt_tokens,
|
|
85
|
+
AVG(CASE WHEN ${STREAMED} THEN ttft_ms END) AS ttft_ms,
|
|
86
|
+
SUM(CASE WHEN ${STREAMED} AND latency_ms > ttft_ms AND ${COMP} > 0 THEN ${COMP} END) AS ctok_sum,
|
|
87
|
+
SUM(CASE WHEN ${STREAMED} AND latency_ms > ttft_ms AND ${COMP} > 0 THEN latency_ms - ttft_ms END) AS elapsed_ms,
|
|
88
|
+
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
89
|
+
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors`;
|
|
90
|
+
|
|
91
|
+
interface RawRow {
|
|
92
|
+
key: string;
|
|
93
|
+
dispatches: number;
|
|
94
|
+
spend: number;
|
|
95
|
+
prompt_tokens: number;
|
|
96
|
+
cached_tokens: number;
|
|
97
|
+
avg_prompt_tokens: number;
|
|
98
|
+
ttft_ms: number | null;
|
|
99
|
+
ctok_sum: number | null;
|
|
100
|
+
elapsed_ms: number | null;
|
|
101
|
+
escalations: number;
|
|
102
|
+
errors: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toRow(r: RawRow, windowSpend: number): ReportRow {
|
|
106
|
+
return {
|
|
107
|
+
key: r.key,
|
|
108
|
+
dispatches: r.dispatches,
|
|
109
|
+
spendUsd: r.spend,
|
|
110
|
+
share: windowSpend > 0 ? r.spend / windowSpend : 0,
|
|
111
|
+
cacheHitRate: r.prompt_tokens > 0 ? r.cached_tokens / r.prompt_tokens : 0,
|
|
112
|
+
avgPromptTokens: Math.round(r.avg_prompt_tokens),
|
|
113
|
+
avgTtftMs: r.ttft_ms === null ? null : Math.round(r.ttft_ms),
|
|
114
|
+
tokensPerSec: r.elapsed_ms !== null && r.elapsed_ms > 0 && r.ctok_sum !== null ? (r.ctok_sum * 1000) / r.elapsed_ms : null,
|
|
115
|
+
escalations: r.escalations,
|
|
116
|
+
errors: r.errors,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Builds the report for the last `windowDays`. `harnessId` narrows to one
|
|
122
|
+
* harness (the `X-Omp-Harness` header); empty means everything.
|
|
123
|
+
*/
|
|
124
|
+
export function buildUsageReport(db: Database, opts: { windowDays: number; harnessId?: string; nowMs?: number }): UsageReport {
|
|
125
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
126
|
+
const windowDays = Math.max(1, opts.windowDays);
|
|
127
|
+
const sinceMs = nowMs - windowDays * 86_400_000;
|
|
128
|
+
const harnessId = opts.harnessId ?? "";
|
|
129
|
+
const where = harnessId === "" ? "created_at_ms >= $since" : "created_at_ms >= $since AND harness_id = $harness";
|
|
130
|
+
const bind = harnessId === "" ? { $since: sinceMs } : { $since: sinceMs, $harness: harnessId };
|
|
131
|
+
|
|
132
|
+
const t = db
|
|
133
|
+
.query(
|
|
134
|
+
`SELECT COUNT(*) AS dispatches,
|
|
135
|
+
COUNT(DISTINCT conversation_key) AS conversations,
|
|
136
|
+
COALESCE(SUM(${USD}), 0) AS spend,
|
|
137
|
+
COALESCE(SUM(${PT}), 0) AS prompt_tokens,
|
|
138
|
+
COALESCE(SUM(${CT}), 0) AS cached_tokens,
|
|
139
|
+
COALESCE(SUM(${COMP}), 0) AS completion_tokens,
|
|
140
|
+
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
141
|
+
SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
|
|
142
|
+
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
|
|
143
|
+
SUM(CASE WHEN error = 'request aborted' THEN 1 ELSE 0 END) AS aborted
|
|
144
|
+
FROM ledger WHERE ${where}`,
|
|
145
|
+
)
|
|
146
|
+
.get(bind) as {
|
|
147
|
+
dispatches: number;
|
|
148
|
+
conversations: number;
|
|
149
|
+
spend: number;
|
|
150
|
+
prompt_tokens: number;
|
|
151
|
+
cached_tokens: number;
|
|
152
|
+
completion_tokens: number;
|
|
153
|
+
escalations: number | null;
|
|
154
|
+
failovers: number | null;
|
|
155
|
+
errors: number | null;
|
|
156
|
+
aborted: number | null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Model switches: consecutive non-wasted rows of one conversation on
|
|
160
|
+
// different slugs. Computed in JS over a slim projection; the window is
|
|
161
|
+
// bounded, and SQLite window functions would make the query less portable.
|
|
162
|
+
const seq = db
|
|
163
|
+
.query(`SELECT conversation_key AS ck, slug FROM ledger WHERE ${where} AND wasted = 0 ORDER BY conversation_key, created_at_ms`)
|
|
164
|
+
.all(bind) as { ck: string; slug: string }[];
|
|
165
|
+
let switches = 0;
|
|
166
|
+
for (let i = 1; i < seq.length; i++) {
|
|
167
|
+
const a = seq[i - 1]!;
|
|
168
|
+
const b = seq[i]!;
|
|
169
|
+
if (a.ck === b.ck && a.slug !== b.slug) switches++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const windowSpend = t.spend;
|
|
173
|
+
const providers = (
|
|
174
|
+
db.query(`SELECT ${PROVIDER} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
|
|
175
|
+
).map((r) => toRow(r, windowSpend));
|
|
176
|
+
|
|
177
|
+
const modelRows = db
|
|
178
|
+
.query(`SELECT COALESCE(served_slug, slug) AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`)
|
|
179
|
+
.all(bind) as RawRow[];
|
|
180
|
+
const tierMix = db
|
|
181
|
+
.query(`SELECT COALESCE(served_slug, slug) AS key, tier, COUNT(*) AS n FROM ledger WHERE ${where} GROUP BY key, tier`)
|
|
182
|
+
.all(bind) as { key: string; tier: string; n: number }[];
|
|
183
|
+
const mixByModel = new Map<string, Record<string, number>>();
|
|
184
|
+
for (const m of tierMix) {
|
|
185
|
+
const rec = mixByModel.get(m.key) ?? {};
|
|
186
|
+
rec[m.tier] = m.n;
|
|
187
|
+
mixByModel.set(m.key, rec);
|
|
188
|
+
}
|
|
189
|
+
const models: ModelRow[] = modelRows.map((r) => ({
|
|
190
|
+
...toRow(r, windowSpend),
|
|
191
|
+
provider: r.key.startsWith("ollama/") ? "ollama" : "openrouter",
|
|
192
|
+
tiers: mixByModel.get(r.key) ?? {},
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
const tiers = (
|
|
196
|
+
db.query(`SELECT tier AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
|
|
197
|
+
).map((r) => toRow(r, windowSpend));
|
|
198
|
+
|
|
199
|
+
const days = (
|
|
200
|
+
db
|
|
201
|
+
.query(
|
|
202
|
+
`SELECT date(created_at_ms / 1000, 'unixepoch') AS day, COUNT(*) AS dispatches, COALESCE(SUM(${USD}), 0) AS spend,
|
|
203
|
+
COALESCE(SUM(${PT}), 0) AS prompt_tokens, COALESCE(SUM(${CT}), 0) AS cached_tokens
|
|
204
|
+
FROM ledger WHERE ${where} GROUP BY day ORDER BY day`,
|
|
205
|
+
)
|
|
206
|
+
.all(bind) as { day: string; dispatches: number; spend: number; prompt_tokens: number; cached_tokens: number }[]
|
|
207
|
+
).map((d) => ({
|
|
208
|
+
day: d.day,
|
|
209
|
+
dispatches: d.dispatches,
|
|
210
|
+
spendUsd: d.spend,
|
|
211
|
+
cacheHitRate: d.prompt_tokens > 0 ? d.cached_tokens / d.prompt_tokens : 0,
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
generatedAtMs: nowMs,
|
|
216
|
+
windowDays,
|
|
217
|
+
sinceMs,
|
|
218
|
+
harnessId,
|
|
219
|
+
totals: {
|
|
220
|
+
dispatches: t.dispatches,
|
|
221
|
+
conversations: t.conversations,
|
|
222
|
+
spendUsd: t.spend,
|
|
223
|
+
cacheHitRate: t.prompt_tokens > 0 ? t.cached_tokens / t.prompt_tokens : 0,
|
|
224
|
+
promptTokens: t.prompt_tokens,
|
|
225
|
+
completionTokens: t.completion_tokens,
|
|
226
|
+
escalations: t.escalations ?? 0,
|
|
227
|
+
failovers: t.failovers ?? 0,
|
|
228
|
+
errors: t.errors ?? 0,
|
|
229
|
+
aborted: t.aborted ?? 0,
|
|
230
|
+
modelSwitches: switches,
|
|
231
|
+
},
|
|
232
|
+
providers,
|
|
233
|
+
models,
|
|
234
|
+
tiers,
|
|
235
|
+
days,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Rendering (shared by the omp command and the CLI)
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const usd = (v: number): string => (v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(4)}`);
|
|
244
|
+
const pct = (v: number): string => `${(v * 100).toFixed(0)}%`;
|
|
245
|
+
const num = (v: number): string => v.toLocaleString("en-US");
|
|
246
|
+
const ms = (v: number | null): string => (v === null ? "–" : v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`);
|
|
247
|
+
const tps = (v: number | null): string => (v === null ? "–" : `${v.toFixed(0)} tok/s`);
|
|
248
|
+
|
|
249
|
+
/** One aligned table of a report: header row, then data rows. */
|
|
250
|
+
export interface ReportTable {
|
|
251
|
+
/** Stable id: `providers` | `models` | `tiers` | `days`. */
|
|
252
|
+
id: string;
|
|
253
|
+
title: string;
|
|
254
|
+
headers: string[];
|
|
255
|
+
rows: string[][];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Everything a renderer needs, already formatted: summary lines and tables. */
|
|
259
|
+
export interface ReportView {
|
|
260
|
+
/** `last 7d · harness omp · 2026-09-06 14:18Z`. */
|
|
261
|
+
heading: string;
|
|
262
|
+
summary: string[];
|
|
263
|
+
tables: ReportTable[];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Column-aligns a table into fixed-width lines: header, rule, rows. The first
|
|
268
|
+
* column is left-aligned, the rest right-aligned. Cells are plain text, so
|
|
269
|
+
* the caller can style whole lines without breaking the alignment.
|
|
270
|
+
*/
|
|
271
|
+
export function formatTable(headers: string[], rows: string[][]): string[] {
|
|
272
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
273
|
+
const line = (cells: string[]): string => cells.map((c, i) => (i === 0 ? c.padEnd(widths[i]!) : c.padStart(widths[i]!))).join(" ");
|
|
274
|
+
return [line(headers), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Formats a report into summary lines and tables, shared by every renderer. */
|
|
278
|
+
export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): ReportView {
|
|
279
|
+
const maxModels = opts.maxModels ?? 12;
|
|
280
|
+
const t = r.totals;
|
|
281
|
+
const heading = `last ${r.windowDays}d${r.harnessId === "" ? "" : ` · harness ${r.harnessId}`} · ${new Date(r.generatedAtMs).toISOString().slice(0, 16).replace("T", " ")}Z`;
|
|
282
|
+
const summary = [
|
|
283
|
+
`spend ${usd(t.spendUsd)} over ${num(t.dispatches)} dispatches in ${num(t.conversations)} conversations · ${usd(t.dispatches > 0 ? t.spendUsd / t.dispatches : 0)}/dispatch`,
|
|
284
|
+
`prompt ${num(t.promptTokens)} tok (cache hit ${pct(t.cacheHitRate)}) · completion ${num(t.completionTokens)} tok · switches ${num(t.modelSwitches)} · escalations ${num(t.escalations)} · failovers ${num(t.failovers)} · errors ${num(t.errors)} (${num(t.aborted)} aborted)`,
|
|
285
|
+
];
|
|
286
|
+
const tables: ReportTable[] = [];
|
|
287
|
+
if (r.providers.length > 0) {
|
|
288
|
+
tables.push({
|
|
289
|
+
id: "providers",
|
|
290
|
+
title: "providers",
|
|
291
|
+
headers: ["provider", "dispatches", "spend", "share", "cache", "ttft", "speed", "esc", "err"],
|
|
292
|
+
rows: r.providers.map((p) => [p.key, num(p.dispatches), usd(p.spendUsd), pct(p.share), pct(p.cacheHitRate), ms(p.avgTtftMs), tps(p.tokensPerSec), num(p.escalations), num(p.errors)]),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (r.models.length > 0) {
|
|
296
|
+
tables.push({
|
|
297
|
+
id: "models",
|
|
298
|
+
title: `models (top ${Math.min(maxModels, r.models.length)} of ${r.models.length} by spend)`,
|
|
299
|
+
headers: ["model", "dispatches", "spend", "share", "cache", "ttft", "speed", "tiers"],
|
|
300
|
+
rows: r.models.slice(0, maxModels).map((m) => [
|
|
301
|
+
m.key,
|
|
302
|
+
num(m.dispatches),
|
|
303
|
+
usd(m.spendUsd),
|
|
304
|
+
pct(m.share),
|
|
305
|
+
pct(m.cacheHitRate),
|
|
306
|
+
ms(m.avgTtftMs),
|
|
307
|
+
tps(m.tokensPerSec),
|
|
308
|
+
Object.entries(m.tiers)
|
|
309
|
+
.sort((a, b) => b[1] - a[1])
|
|
310
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
311
|
+
.join(" "),
|
|
312
|
+
]),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (r.tiers.length > 0) {
|
|
316
|
+
tables.push({
|
|
317
|
+
id: "tiers",
|
|
318
|
+
title: "tiers",
|
|
319
|
+
headers: ["tier", "dispatches", "spend", "share", "cache", "avg prompt", "esc"],
|
|
320
|
+
rows: r.tiers.map((x) => [x.key, num(x.dispatches), usd(x.spendUsd), pct(x.share), pct(x.cacheHitRate), num(x.avgPromptTokens), num(x.escalations)]),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (r.days.length > 1) {
|
|
324
|
+
tables.push({
|
|
325
|
+
id: "days",
|
|
326
|
+
title: "by day (UTC)",
|
|
327
|
+
headers: ["day", "dispatches", "spend", "cache"],
|
|
328
|
+
rows: r.days.map((d) => [d.day, num(d.dispatches), usd(d.spendUsd), pct(d.cacheHitRate)]),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { heading, summary, tables };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Plain-text rendering: fixed-width tables, no markup, fits a TUI panel. */
|
|
335
|
+
export function renderUsageReport(r: UsageReport, opts: { maxModels?: number } = {}): string {
|
|
336
|
+
const v = reportView(r, opts);
|
|
337
|
+
const out: string[] = [`auto-model-router · ${v.heading}`, "", ...v.summary];
|
|
338
|
+
for (const t of v.tables) out.push("", t.title, ...formatTable(t.headers, t.rows));
|
|
339
|
+
return out.join("\n");
|
|
340
|
+
}
|
package/src/cost/types.ts
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
* - **predicted**: our arithmetic over the catalog, computed *before* dispatch.
|
|
6
6
|
* Drives routing and budget enforcement.
|
|
7
7
|
* - **reported**: `usage.cost` returned by OpenRouter, authoritative after the
|
|
8
|
-
* fact. Drives the ledger, `stats`, and prediction-error calibration.
|
|
8
|
+
* fact. Drives the ledger, `stats`, and prediction-error calibration. A
|
|
9
|
+
* provider that returns usage but no cost (Ollama) has its ACTUAL tokens
|
|
10
|
+
* priced at the catalog rate and recorded here — still after the fact, and
|
|
11
|
+
* still not the forecast.
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
import type { CatalogModel } from "../catalog/types.ts";
|
|
15
|
+
|
|
11
16
|
/** Token counts for one upstream generation. */
|
|
12
17
|
export interface UsageCounts {
|
|
13
18
|
/** Total prompt tokens, *including* `cachedTokens` (OpenAI/OpenRouter convention). */
|
|
@@ -135,6 +140,12 @@ export interface LedgerEntry {
|
|
|
135
140
|
error: string | null;
|
|
136
141
|
/** Prompt tokens removed by compaction before dispatch. 0 when none. NULL before v12. */
|
|
137
142
|
promptTokensSaved: number;
|
|
143
|
+
/**
|
|
144
|
+
* The catalog model that served, for the cost split. The ledger can price
|
|
145
|
+
* OpenRouter slugs from its own cached catalog payload; a model from another
|
|
146
|
+
* provider (Ollama) exists only in memory, so the orchestrator hands it over.
|
|
147
|
+
*/
|
|
148
|
+
priceModel?: CatalogModel;
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
/** Rolling blended rate used to keep omp's cost display honest. */
|
|
@@ -183,6 +194,18 @@ export interface ModelLatency {
|
|
|
183
194
|
tokensPerSec: number;
|
|
184
195
|
}
|
|
185
196
|
|
|
197
|
+
export interface LedgerSignals {
|
|
198
|
+
trust: ModelTrust | null;
|
|
199
|
+
latency: ModelLatency | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Measured price of a probe escalation: what the retry billed per prompt token of the failed turn. */
|
|
203
|
+
export interface EscalationCost {
|
|
204
|
+
usdPerPromptToken: number;
|
|
205
|
+
samples: number;
|
|
206
|
+
windowDays: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
186
209
|
export interface Ledger {
|
|
187
210
|
record(entry: LedgerEntry): void;
|
|
188
211
|
/** Total reported (or predicted, when reported is null) spend for a conversation. */
|
|
@@ -203,6 +226,15 @@ export interface Ledger {
|
|
|
203
226
|
* how fast the body streams once it starts.
|
|
204
227
|
*/
|
|
205
228
|
latency(slug: string, harnessId?: string): ModelLatency | null;
|
|
229
|
+
/** Batch trust and latency for one candidate set; one query per signal kind. Optional — callers can fall back to per-slug calls. */
|
|
230
|
+
signals?(slugs: readonly string[], harnessId?: string): Map<string, LedgerSignals>;
|
|
231
|
+
/**
|
|
232
|
+
* What an escalated retry actually bills per prompt token, measured over
|
|
233
|
+
* the last `windowDays` of attempt > 0 rows. Null until enough escalated
|
|
234
|
+
* attempts exist to measure. Optional so fakes need not implement it; the
|
|
235
|
+
* escalation-cost term in candidate scoring is inert without it.
|
|
236
|
+
*/
|
|
237
|
+
escalationCost?(windowDays: number): EscalationCost | null;
|
|
206
238
|
/** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
|
|
207
239
|
tokenRatio(tokenizer: string): number | null;
|
|
208
240
|
recentEntries(limit: number): LedgerEntry[];
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { parseArgv } from "./cli/args.ts";
|
|
|
12
12
|
import { configCommand } from "./cli/config-cmd.ts";
|
|
13
13
|
import { explainCommand } from "./cli/explain.ts";
|
|
14
14
|
import { modelsCommand } from "./cli/models.ts";
|
|
15
|
+
import { reportCommand } from "./cli/report.ts";
|
|
15
16
|
import { serveCommand } from "./cli/serve.ts";
|
|
16
17
|
import { statsCommand } from "./cli/stats.ts";
|
|
17
18
|
|
|
@@ -21,6 +22,7 @@ Usage: auto-model-router <command> [options]
|
|
|
21
22
|
|
|
22
23
|
serve Run the router as a standalone process (for non-omp harnesses)
|
|
23
24
|
stats Show routed spend, per-model share, and escalation rates
|
|
25
|
+
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
24
26
|
models Show what each complexity tier would consider, and why
|
|
25
27
|
explain Route a saved request without dispatching it, and explain the decision
|
|
26
28
|
config Interactive wizard over the router's own config.yml
|
|
@@ -33,6 +35,7 @@ Global options:
|
|
|
33
35
|
|
|
34
36
|
serve --port <n> --host <addr> --log <level>
|
|
35
37
|
stats --days <n> --json
|
|
38
|
+
report --days <n> --harness <id> --json
|
|
36
39
|
models --tier <trivial|simple|moderate|hard> --limit <n> --json
|
|
37
40
|
explain --file <request.json> --json (reads stdin when --file is absent)
|
|
38
41
|
config --print --write --path <models.yml> --config <router-config.yml>
|
|
@@ -67,14 +70,8 @@ async function main(): Promise<number> {
|
|
|
67
70
|
case "stats":
|
|
68
71
|
await statsCommand(args);
|
|
69
72
|
return 0;
|
|
70
|
-
case "
|
|
71
|
-
|
|
72
|
-
case "serve":
|
|
73
|
-
// Resolves once listening; the server itself keeps the loop alive.
|
|
74
|
-
await serveCommand(args);
|
|
75
|
-
return 0;
|
|
76
|
-
case "stats":
|
|
77
|
-
await statsCommand(args);
|
|
73
|
+
case "report":
|
|
74
|
+
await reportCommand(args);
|
|
78
75
|
return 0;
|
|
79
76
|
case "models":
|
|
80
77
|
await modelsCommand(args);
|
package/src/router/candidates.ts
CHANGED
|
@@ -7,12 +7,20 @@
|
|
|
7
7
|
import type { CatalogModel, CatalogSnapshot } from "../catalog/types.ts";
|
|
8
8
|
import type { FilterConfig, QualityAxis, RouterConfig } from "../config/types.ts";
|
|
9
9
|
import { forecast, priceAt } from "../cost/forecast.ts";
|
|
10
|
-
import type { Ledger, ModelLatency } from "../cost/types.ts";
|
|
10
|
+
import type { Ledger, LedgerSignals, ModelLatency } from "../cost/types.ts";
|
|
11
11
|
import type { NormRequest } from "../wire/types.ts";
|
|
12
12
|
import { effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "./tier-plan.ts";
|
|
13
13
|
import type { Candidate, Features, Rejection, TaskType, Tier } from "./types.ts";
|
|
14
14
|
|
|
15
15
|
export interface BuildCandidatesArgs {
|
|
16
|
+
/** Pre-fetched trust/latency signals for all candidate slugs. When provided, buildCandidates uses these instead of per-slug ledger calls. */
|
|
17
|
+
signals?: Map<string, LedgerSignals>;
|
|
18
|
+
/**
|
|
19
|
+
* Measured cost of an escalated retry per prompt token (from
|
|
20
|
+
* `Ledger.escalationCost`). With `filters.escalationCostWeight` > 0 a
|
|
21
|
+
* model's measured escalation rate is priced at this; absent ⇒ term inert.
|
|
22
|
+
*/
|
|
23
|
+
escalationUsdPerPromptToken?: number;
|
|
16
24
|
req: NormRequest;
|
|
17
25
|
features: Features;
|
|
18
26
|
tier: Tier;
|
|
@@ -68,6 +76,16 @@ function resolveQuality(model: CatalogModel, axis: QualityAxis): { score: number
|
|
|
68
76
|
/** Neutral trust prior for models our ledger has never observed. */
|
|
69
77
|
const UNMEASURED_TRUST = 0.9;
|
|
70
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Prior on the escalation rate, as pseudo-counts: an unobserved model is
|
|
81
|
+
* assumed to escalate ESCALATION_PRIOR times in ESCALATION_PRIOR_N attempts
|
|
82
|
+
* (0.25%), so a new cheap model is not priced out before it has been tried,
|
|
83
|
+
* and a model with a handful of attempts is pulled toward that rather than
|
|
84
|
+
* toward 0% or 100%.
|
|
85
|
+
*/
|
|
86
|
+
const ESCALATION_PRIOR = 0.05;
|
|
87
|
+
const ESCALATION_PRIOR_N = 20;
|
|
88
|
+
|
|
71
89
|
/** Excess-ratio cap so one very slow model cannot be penalised into oblivion. */
|
|
72
90
|
const LATENCY_EXCESS_CAP = 3;
|
|
73
91
|
|
|
@@ -230,8 +248,14 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
230
248
|
continue;
|
|
231
249
|
}
|
|
232
250
|
|
|
251
|
+
// Use pre-fetched signals when available (batch lookup, one query per
|
|
252
|
+
// signal kind for the entire candidate set instead of one per model).
|
|
253
|
+
// Falls back to per-slug calls when signals is not provided (e.g. tests).
|
|
254
|
+
const signals = args.signals;
|
|
233
255
|
const trust =
|
|
234
|
-
|
|
256
|
+
signals?.get(slug)?.trust ??
|
|
257
|
+
ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ??
|
|
258
|
+
null;
|
|
235
259
|
if (!relaxTrust && trust !== null && trust.attempts >= filters.minTrustSamples && trust.successRate < filters.minTrust) {
|
|
236
260
|
rejected.push({
|
|
237
261
|
slug,
|
|
@@ -249,7 +273,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
249
273
|
// cold-start turns to accumulate samples. Relaxed with trust in rescue.
|
|
250
274
|
const needLatency = filters.latencyWeight > 0 || filters.maxExpectedWaitMs !== undefined;
|
|
251
275
|
const latency = needLatency
|
|
252
|
-
? (
|
|
276
|
+
? (signals?.get(slug)?.latency ??
|
|
277
|
+
ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ??
|
|
278
|
+
null)
|
|
253
279
|
: null;
|
|
254
280
|
if (
|
|
255
281
|
!relaxTrust &&
|
|
@@ -295,7 +321,25 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
295
321
|
// for slowness (TTFT over the reference). qualityExponent 0 makes this
|
|
296
322
|
// "cheapest above the floor"; the floor does the quality work.
|
|
297
323
|
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
|
|
298
|
-
|
|
324
|
+
// Escalation-cost term: the trust divisor prices a failure as a retry of
|
|
325
|
+
// THIS model, but a probe escalation re-dispatches the whole prompt on
|
|
326
|
+
// the next tier's model — measured at ~700x a cheap model's own turn cost.
|
|
327
|
+
// Price the measured rate at what an escalated retry actually bills.
|
|
328
|
+
const escalationRate =
|
|
329
|
+
trust !== null && trust.attempts > 0
|
|
330
|
+
? (trust.escalations + ESCALATION_PRIOR) / (trust.attempts + ESCALATION_PRIOR_N)
|
|
331
|
+
: ESCALATION_PRIOR / ESCALATION_PRIOR_N;
|
|
332
|
+
const escalationUsd =
|
|
333
|
+
filters.escalationCostWeight > 0 && args.escalationUsdPerPromptToken !== undefined
|
|
334
|
+
? filters.escalationCostWeight * escalationRate * args.escalationUsdPerPromptToken * features.promptTokens
|
|
335
|
+
: 0;
|
|
336
|
+
// Provider bias: an Ollama plan's included credits are money already
|
|
337
|
+
// spent, so an operator may value them below list price in ranking. The
|
|
338
|
+
// ledger still records list price.
|
|
339
|
+
// The snapshot carries the LIVE bias (credit-aware); the static config
|
|
340
|
+
// value is the fallback for snapshots built without one.
|
|
341
|
+
const providerBias = snapshot.providerBias?.[model.provider] ?? (model.provider === "ollama" ? cfg.ollama.costBias : 1);
|
|
342
|
+
const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5) + escalationUsd) * latencyMult * providerBias;
|
|
299
343
|
// Score is assigned in a SECOND PASS below: both qualityNormalization and
|
|
300
344
|
// capabilityFloorUsd are properties of the candidate SET, not of one
|
|
301
345
|
// model, so no per-model value can be computed here. Placeholder only.
|
|
@@ -310,6 +354,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
310
354
|
: `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
|
|
311
355
|
`expected $${fc.expectedUsd.toFixed(6)}`,
|
|
312
356
|
];
|
|
357
|
+
if (escalationUsd > 0) {
|
|
358
|
+
reasons.push(`escalation risk +$${escalationUsd.toFixed(6)} (rate ${(escalationRate * 100).toFixed(2)}% × measured retry cost)`);
|
|
359
|
+
}
|
|
360
|
+
if (providerBias !== 1) reasons.push(`provider bias ×${providerBias} (ollama.costBias, plan credits remaining)`);
|
|
313
361
|
if (latencyMult > 1 && latency !== null) {
|
|
314
362
|
reasons.push(
|
|
315
363
|
`latency penalty ×${latencyMult.toFixed(2)} (ttft ${Math.round(latency.ttftMs)}ms, ${latency.tokensPerSec.toFixed(0)} tok/s over ${latency.samples} samples)`,
|
package/src/router/classify.ts
CHANGED
|
@@ -23,7 +23,11 @@ import type { Classification, Features, TaskType, Tier } from "./types.ts";
|
|
|
23
23
|
* - Failure signals cost money twice: a cheap model that flounders gets
|
|
24
24
|
* escalated and the turn is paid for twice. So a failed tool result, a
|
|
25
25
|
* repeated tool call, complexity keywords, and an explicit reasoning
|
|
26
|
-
* request carry the dominant POSITIVE weights.
|
|
26
|
+
* request carry the dominant POSITIVE weights — on a FRESH turn. On a
|
|
27
|
+
* tool-result continuation the failed-tool and circular-call weights are
|
|
28
|
+
* damped by `classifier.mechanicalRetryFactor` (0.2 shipped), because a
|
|
29
|
+
* mechanical retry loop was buying the hard tier for work that never
|
|
30
|
+
* needed it; there they are a nudge, not a driver.
|
|
27
31
|
*/
|
|
28
32
|
const BASE = 0.3;
|
|
29
33
|
const W_TOOL_CONTINUATION = -0.28; // dominant negative
|
|
@@ -31,8 +35,8 @@ const W_COMPLEXITY_KEYWORD = 0.1;
|
|
|
31
35
|
const CAP_COMPLEXITY = 0.3;
|
|
32
36
|
const W_TRIVIALITY_KEYWORD = -0.09;
|
|
33
37
|
const CAP_TRIVIALITY = -0.27;
|
|
34
|
-
const W_TOOL_FAILED = 0.26; // dominant positive
|
|
35
|
-
const W_CIRCULAR_LOOP = 0.24; // a re-issued (circular) tool call: the model is stuck
|
|
38
|
+
const W_TOOL_FAILED = 0.26; // dominant positive on a fresh turn; damped on continuations
|
|
39
|
+
const W_CIRCULAR_LOOP = 0.24; // a re-issued (circular) tool call: the model is stuck; damped on continuations
|
|
36
40
|
const W_TERSE = -0.1;
|
|
37
41
|
const W_CODE_BLOCK = 0.04;
|
|
38
42
|
const CAP_CODE = 0.08;
|
|
@@ -118,8 +122,25 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
|
|
|
118
122
|
Math.max(f.trivialityKeywords.length * W_TRIVIALITY_KEYWORD, CAP_TRIVIALITY),
|
|
119
123
|
`triviality keywords [${f.trivialityKeywords.join(", ")}]`,
|
|
120
124
|
);
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
// Stuck-loop signals on a tool-result continuation are damped: a retry after
|
|
126
|
+
// a failed call is the MOST mechanical turn there is (no new user intent,
|
|
127
|
+
// same prompt prefix, the harness just re-asks), and the flat weight let an
|
|
128
|
+
// automated retry loop buy the hard tier ($7.02 of one measured day vs $0.19
|
|
129
|
+
// for the same rows as moderate picks). A circular call measured the same
|
|
130
|
+
// way: hard escalations driven by it NEVER shortened the loop (chains from
|
|
131
|
+
// hard and from moderate both averaged 5.74 turns) — the loop ends when the
|
|
132
|
+
// underlying state changes, not because a pricier model re-read the same
|
|
133
|
+
// result. Off a continuation (a fresh user turn, the failure the human just
|
|
134
|
+
// saw) both keep their full weight: that genuinely changes what the turn
|
|
135
|
+
// needs.
|
|
136
|
+
const factor = cfg.classifier.mechanicalRetryFactor;
|
|
137
|
+
const damped = f.isToolResultContinuation && factor < 1;
|
|
138
|
+
const damp = (w: number): number => (damped ? w * factor : w);
|
|
139
|
+
const dampNote = damped ? ` (mechanical retry, damped x${factor})` : "";
|
|
140
|
+
if (f.lastToolFailed) add(damp(W_TOOL_FAILED), `last tool result failed${dampNote}`);
|
|
141
|
+
if (f.circularToolCall) {
|
|
142
|
+
add(damp(W_CIRCULAR_LOOP), damped ? `circular tool call${dampNote}` : "circular tool call (re-issued a prior call; stuck)");
|
|
143
|
+
}
|
|
123
144
|
const rw = reasoningWeight(f.requestedReasoning, cfg);
|
|
124
145
|
if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
|
|
125
146
|
if (f.isTerseInstruction) add(W_TERSE, "terse instruction");
|
|
@@ -137,7 +158,13 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
|
|
|
137
158
|
`autonomous loop depth ${f.toolLoopDepth} (sustained task)`,
|
|
138
159
|
);
|
|
139
160
|
}
|
|
140
|
-
|
|
161
|
+
// Only an image the human JUST supplied is visual work. A screenshot that
|
|
162
|
+
// entered the conversation long ago rides along in every later mechanical
|
|
163
|
+
// continuation (measured: 5,603 of ~9,000 heuristic rows in a week carried
|
|
164
|
+
// one, and 339 of them sat a tier higher on this +0.04 alone). Capability —
|
|
165
|
+
// the served model must still accept image input — is enforced separately
|
|
166
|
+
// on `req.hasImages` in candidate selection, exactly as `classifyTask` does.
|
|
167
|
+
if (f.hasNewImage) add(W_IMAGES, "new image input");
|
|
141
168
|
if (f.toolCount > 0) add(W_TOOLS_OFFERED, `${f.toolCount} tools offered`);
|
|
142
169
|
|
|
143
170
|
score = Math.min(1, Math.max(0, score));
|
package/src/router/features.ts
CHANGED
|
@@ -170,9 +170,21 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// The tool run to judge for failure: the trailing run on a continuation, or
|
|
174
|
+
// the run that sits immediately behind the newest user turn — the failure
|
|
175
|
+
// the human just saw and is now responding to. The classifier weights the
|
|
176
|
+
// two differently (a mechanical retry is damped, a user-visible failure is
|
|
177
|
+
// not), so the second case has to be detectable here or that branch is dead.
|
|
173
178
|
let lastToolFailed = false;
|
|
179
|
+
let failureScanFrom = -1;
|
|
174
180
|
if (isToolResultContinuation) {
|
|
175
|
-
|
|
181
|
+
failureScanFrom = messages.length - 1;
|
|
182
|
+
} else if (tail?.role === "user") {
|
|
183
|
+
const start = trailingRunStart(messages, (m) => m.role === "user");
|
|
184
|
+
if (messages[start - 1]?.role === "tool") failureScanFrom = start - 1;
|
|
185
|
+
}
|
|
186
|
+
if (failureScanFrom >= 0) {
|
|
187
|
+
scanResults: for (let i = failureScanFrom; i >= 0; i--) {
|
|
176
188
|
const m = messages[i];
|
|
177
189
|
if (m === undefined || m.role !== "tool") break;
|
|
178
190
|
for (const re of TOOL_FAILURE_MARKERS) {
|