auto-model-router 0.14.0 → 0.15.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 +6 -2
- package/package.json +1 -1
- package/src/cost/ledger.ts +2 -2
- package/src/cost/views.ts +64 -0
- package/src/lib.ts +1 -1
- package/src/server/http.ts +16 -5
- package/test/views.test.ts +31 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.15.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.15.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -710,8 +710,12 @@ escalation signal, error. Three views aggregate it, all from the same
|
|
|
710
710
|
and model (dispatches, tokens, spend, escalations, errors) as CSV. Also
|
|
711
711
|
`GET /v1/router/export?days=&harness=[&format=json]`; `GET /v1/router/spend?sinceMs=&harness=`
|
|
712
712
|
gives spend over a harness set since an instant, and `GET /v1/router/feedback?days=&harness=`
|
|
713
|
-
lists verdicts by model and the recent ones with the harness that gave them
|
|
714
|
-
|
|
713
|
+
lists verdicts by model and the recent ones with the harness that gave them, and
|
|
714
|
+
`GET /v1/router/decisions?harness=&days=|since=&slug=&tier=&limit=` is the decision trail
|
|
715
|
+
itself, newest first, each turn with its reasons, the classifier's view, forecast against
|
|
716
|
+
bill, escalation signal and verdicts (`?session=` narrows to one omp session, as `/router
|
|
717
|
+
why` does). These are what a front door such as the team edition reads instead of the
|
|
718
|
+
ledger file.
|
|
715
719
|
- `GET /v1/router/report?days=7&harness=<id>` for dashboards (`harness` may be
|
|
716
720
|
a comma-separated set of ids, for a group).
|
|
717
721
|
- `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
|
package/package.json
CHANGED
package/src/cost/ledger.ts
CHANGED
|
@@ -65,7 +65,7 @@ const CACHE_RELIABILITY_MEMO_MS = 60_000;
|
|
|
65
65
|
const DAY_MS = 86_400_000;
|
|
66
66
|
|
|
67
67
|
// Row shapes below are fixed by our own schema in util/sqlite.ts.
|
|
68
|
-
interface LedgerRow {
|
|
68
|
+
export interface LedgerRow {
|
|
69
69
|
id: string;
|
|
70
70
|
created_at_ms: number;
|
|
71
71
|
conversation_key: string;
|
|
@@ -240,7 +240,7 @@ function toLatency(slug: string, row: LatencyRow): ModelLatency | null {
|
|
|
240
240
|
return { slug, samples: row.samples, ttftMs: row.ttft_ms, tokensPerSec };
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
function toEntry(row: LedgerRow): LedgerEntry {
|
|
243
|
+
export function toEntry(row: LedgerRow): LedgerEntry {
|
|
244
244
|
return {
|
|
245
245
|
id: row.id,
|
|
246
246
|
createdAtMs: row.created_at_ms,
|
package/src/cost/views.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { providerOfSlug } from "./report.ts";
|
|
12
12
|
import type { Database } from "bun:sqlite";
|
|
13
|
+
import { toEntry, type LedgerRow } from "./ledger.ts";
|
|
13
14
|
import { harnessFilter } from "./report.ts";
|
|
15
|
+
import type { LedgerEntry } from "./types.ts";
|
|
14
16
|
|
|
15
17
|
/** `null` means every harness; an empty set matches nothing. */
|
|
16
18
|
export type HarnessScope = readonly string[] | null;
|
|
@@ -61,6 +63,68 @@ function scope(harness: HarnessScope, column: string): { sql: string[]; bind: Re
|
|
|
61
63
|
return { sql: f.sql.map((s) => s.replace(/^harness_id/, column)), bind: f.bind };
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** A ledger entry as the decision explorer shows it: the entry itself plus the verdicts given on it. */
|
|
67
|
+
export type DecisionEntry = LedgerEntry & { feedback: { verdict: "good" | "bad"; note: string; createdAtMs: number }[] };
|
|
68
|
+
|
|
69
|
+
export interface DecisionFilter {
|
|
70
|
+
/** Entries at or after this instant; 0 for everything the ledger still holds. */
|
|
71
|
+
sinceMs: number;
|
|
72
|
+
/** The harness set; null for every harness, an empty list for none. */
|
|
73
|
+
harness: HarnessScope;
|
|
74
|
+
/** At most this many, newest first; 1..1000. */
|
|
75
|
+
limit?: number;
|
|
76
|
+
/** Only turns dispatched to (or served by) this slug. */
|
|
77
|
+
slug?: string;
|
|
78
|
+
/** Only turns classified at this tier. */
|
|
79
|
+
tier?: string;
|
|
80
|
+
/** Only one omp session (`/router why`). */
|
|
81
|
+
ompSessionId?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Turns, newest first, over a harness set: what `GET /v1/router/decisions` serves and what a
|
|
86
|
+
* front door reads from the ledger file. Every field the decision trail needs is here — the
|
|
87
|
+
* reasons, the classifier's view, the cost forecast against the bill, the escalation signal —
|
|
88
|
+
* and the verdicts `/router good|bad` recorded against each turn ride along.
|
|
89
|
+
*/
|
|
90
|
+
export function decisionEntries(db: Database, filter: DecisionFilter): DecisionEntry[] {
|
|
91
|
+
const s = scope(filter.harness, "harness_id");
|
|
92
|
+
if (s === null) return [];
|
|
93
|
+
const where = ["created_at_ms >= $since", ...s.sql];
|
|
94
|
+
const bind: Record<string, string | number> = { $since: filter.sinceMs, ...s.bind };
|
|
95
|
+
if (filter.slug !== undefined && filter.slug !== "") {
|
|
96
|
+
where.push("(slug = $slug OR served_slug = $slug)");
|
|
97
|
+
bind.$slug = filter.slug;
|
|
98
|
+
}
|
|
99
|
+
if (filter.tier !== undefined && filter.tier !== "") {
|
|
100
|
+
where.push("tier = $tier");
|
|
101
|
+
bind.$tier = filter.tier;
|
|
102
|
+
}
|
|
103
|
+
if (filter.ompSessionId !== undefined && filter.ompSessionId !== "") {
|
|
104
|
+
where.push("omp_session_id = $session");
|
|
105
|
+
bind.$session = filter.ompSessionId;
|
|
106
|
+
}
|
|
107
|
+
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 1_000);
|
|
108
|
+
const rows = db.query(`SELECT * FROM ledger WHERE ${where.join(" AND ")} ORDER BY created_at_ms DESC LIMIT ${limit}`).all(bind) as LedgerRow[];
|
|
109
|
+
const entries = rows.map(toEntry);
|
|
110
|
+
if (entries.length === 0) return [];
|
|
111
|
+
// Verdicts, when the feedback table exists (it does not on a ledger no one has judged).
|
|
112
|
+
const hasFeedback = (db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'feedback'").get() as { name: string } | null) !== null;
|
|
113
|
+
const verdicts = new Map<string, DecisionEntry["feedback"]>();
|
|
114
|
+
if (hasFeedback) {
|
|
115
|
+
const ids = entries.map((e) => e.id);
|
|
116
|
+
const marks = ids.map((_, i) => `$f${i}`).join(", ");
|
|
117
|
+
const fb: Record<string, string> = {};
|
|
118
|
+
ids.forEach((id, i) => (fb[`$f${i}`] = id));
|
|
119
|
+
for (const r of db.query(`SELECT ledger_id, verdict, note, created_at_ms FROM feedback WHERE ledger_id IN (${marks}) ORDER BY created_at_ms ASC`).all(fb) as { ledger_id: string; verdict: string; note: string; created_at_ms: number }[]) {
|
|
120
|
+
const list = verdicts.get(r.ledger_id) ?? [];
|
|
121
|
+
list.push({ verdict: r.verdict === "good" ? "good" : "bad", note: r.note, createdAtMs: r.created_at_ms });
|
|
122
|
+
verdicts.set(r.ledger_id, list);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return entries.map((e) => ({ ...e, feedback: verdicts.get(e.id) ?? [] }));
|
|
126
|
+
}
|
|
127
|
+
|
|
64
128
|
/** Spend (reported where present, predicted otherwise) since `sinceMs`, digest calls included as the ledger counts them. */
|
|
65
129
|
export function spendUsdSince(db: Database, sinceMs: number, harness: HarnessScope): number {
|
|
66
130
|
const s = scope(harness, "harness_id");
|
package/src/lib.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type { DeepPartial } from "./config/load.ts";
|
|
|
22
22
|
export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
|
|
23
23
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
|
24
24
|
export { openDb } from "./util/sqlite.ts";
|
|
25
|
-
export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView } from "./cost/views.ts";
|
|
25
|
+
export { spendUsdSince, feedbackView, exportRows, exportCsv, decisionEntries, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView, type DecisionEntry, type DecisionFilter } from "./cost/views.ts";
|
|
26
26
|
export { createLedger } from "./cost/ledger.ts";
|
|
27
27
|
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
28
28
|
export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
|
package/src/server/http.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { createDigester } from "./digest.ts";
|
|
|
10
10
|
import { advise } from "./advise.ts";
|
|
11
11
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
12
12
|
import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
|
|
13
|
-
import { exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
|
|
13
|
+
import { decisionEntries, exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
|
|
14
14
|
import { anthropicErrorResponse, countAnthropicTokens, createMessagesWire } from "../wire/anthropic/messages.ts";
|
|
15
15
|
import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
|
|
16
16
|
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
@@ -555,13 +555,24 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
555
555
|
return json({ due: true, summary });
|
|
556
556
|
}
|
|
557
557
|
if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
|
|
558
|
+
// The decision trail, newest first. ?session=<omp session id> narrows to one
|
|
559
|
+
// session (/router why); ?harness=a,b to a harness set (a team's user or group),
|
|
560
|
+
// ?since=<ms> or ?days=N to a window, ?slug= and ?tier= to a model or a tier.
|
|
558
561
|
const rawLimit = url.searchParams.get("limit");
|
|
559
562
|
const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
|
|
560
563
|
const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
564
|
+
const sinceRaw = Number.parseInt(url.searchParams.get("since") ?? "", 10);
|
|
565
|
+
const daysRaw = url.searchParams.get("days");
|
|
566
|
+
const sinceMs = Number.isFinite(sinceRaw) ? sinceRaw : daysRaw === null ? 0 : Date.now() - clampDays(daysRaw, 30) * 86_400_000;
|
|
567
|
+
const entries = decisionEntries(db, {
|
|
568
|
+
sinceMs,
|
|
569
|
+
harness: harnessScopeParam(url.searchParams.get("harness")),
|
|
570
|
+
limit,
|
|
571
|
+
slug: url.searchParams.get("slug") ?? "",
|
|
572
|
+
tier: url.searchParams.get("tier") ?? "",
|
|
573
|
+
ompSessionId: url.searchParams.get("session") ?? "",
|
|
574
|
+
});
|
|
575
|
+
return json({ entries });
|
|
565
576
|
}
|
|
566
577
|
if (url.pathname === "/v1/router/override") {
|
|
567
578
|
// Per-session pin / tier overrides from omp. GET shows, POST sets or clears.
|
package/test/views.test.ts
CHANGED
|
@@ -148,6 +148,12 @@ describe("view routes", () => {
|
|
|
148
148
|
expect((await fetch(`http://127.0.0.1:${handle.server.port}/v1/router/spend?sinceMs=0`)).status).toBe(401);
|
|
149
149
|
expect((await get("/v1/router/spend")).status).toBe(400);
|
|
150
150
|
expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_x`)).json()) as { usd: number }).usd).toBeCloseTo(0.25, 6);
|
|
151
|
+
// The decision trail over a harness set: a team asks with its user or group ids and sees only theirs.
|
|
152
|
+
const mine = (await (await get("/v1/router/decisions?harness=u_x&days=1")).json()) as { entries: { id: string; feedback: unknown[] }[] };
|
|
153
|
+
expect(mine.entries.map((e) => e.id)).toEqual(["r1"]);
|
|
154
|
+
expect(mine.entries[0]?.feedback).toEqual([]);
|
|
155
|
+
expect((((await (await get("/v1/router/decisions?harness=u_other&days=1")).json()) as { entries: unknown[] }).entries)).toEqual([]);
|
|
156
|
+
expect((((await (await get("/v1/router/decisions?limit=1")).json()) as { entries: { id: string }[] }).entries.map((e) => e.id))).toEqual(["r1"]); // no filter: everything, as before
|
|
151
157
|
expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_other`)).json()) as { usd: number }).usd).toBe(0);
|
|
152
158
|
const fb = (await (await get("/v1/router/feedback?days=7")).json()) as { days: number; byModel: unknown[]; recent: unknown[] };
|
|
153
159
|
expect(fb).toEqual({ days: 7, byModel: [], recent: [] });
|
|
@@ -159,3 +165,28 @@ describe("view routes", () => {
|
|
|
159
165
|
expect(js.rows[0]?.harnessId).toBe("u_x");
|
|
160
166
|
});
|
|
161
167
|
});
|
|
168
|
+
|
|
169
|
+
describe("decision entries", () => {
|
|
170
|
+
const db = seeded();
|
|
171
|
+
const since = NOW - DAY;
|
|
172
|
+
|
|
173
|
+
test("newest first over a harness set, with the verdicts given on each turn", () => {
|
|
174
|
+
const { decisionEntries } = require("../src/cost/views.ts") as typeof import("../src/cost/views.ts");
|
|
175
|
+
const ada = decisionEntries(db, { sinceMs: since, harness: ["u_ada"] });
|
|
176
|
+
expect(ada.map((e) => e.id)).toEqual(["l1", "l2"]); // same instant in the fixture; insertion order within it is stable
|
|
177
|
+
expect(ada.find((e) => e.id === "l1")?.feedback).toEqual([{ verdict: "good", note: "", createdAtMs: NOW - 1000 }]);
|
|
178
|
+
expect(ada.find((e) => e.id === "l2")?.escalationSignal).toBe("circular");
|
|
179
|
+
// Everyone, within the window: the 40-day-old row stays out; the digest row is a turn like any other.
|
|
180
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null }).map((e) => e.id).sort()).toEqual(["l1", "l2", "l3", "l4"]);
|
|
181
|
+
expect(decisionEntries(db, { sinceMs: 0, harness: null }).length).toBe(5);
|
|
182
|
+
// A model, a tier, nobody, and a cap.
|
|
183
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null, slug: "ollama/glm-5.3-flash" }).map((e) => e.id).sort()).toEqual(["l3", "l4"]);
|
|
184
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null, tier: "hard" })).toEqual([]);
|
|
185
|
+
expect(decisionEntries(db, { sinceMs: since, harness: [] })).toEqual([]);
|
|
186
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null, limit: 1 }).length).toBe(1);
|
|
187
|
+
// The error on l3 and its note ride along, so an explorer can show why a turn went wrong.
|
|
188
|
+
const bob = decisionEntries(db, { sinceMs: since, harness: ["u_bob"], slug: "ollama/glm-5.3-flash" });
|
|
189
|
+
expect(bob.find((e) => e.id === "l3")?.error).toBe("boom");
|
|
190
|
+
expect(bob.find((e) => e.id === "l3")?.feedback[0]?.note).toBe("looped");
|
|
191
|
+
});
|
|
192
|
+
});
|