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/test/skills.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ const NL = String.fromCharCode(10);
|
|
|
9
9
|
const bundle = (version: string, skills: Record<string, Record<string, string>>): SkillsBundle => ({ version, skills: Object.entries(skills).map(([name, files]) => ({ name, files })) });
|
|
10
10
|
|
|
11
11
|
describe("skills served by the remote, installed by connect", () => {
|
|
12
|
-
test("a bundle is parsed defensively", () => {
|
|
12
|
+
test("a bundle is parsed defensively", async () => {
|
|
13
13
|
expect(parseSkillsBundle({ version: "v1", skills: [{ name: "team-context", files: { "SKILL.md": "# x" } }] })?.skills[0]?.name).toBe("team-context");
|
|
14
14
|
expect(parseSkillsBundle({ version: "v1", skills: [{ name: "Bad Name", files: { "SKILL.md": "# x" } }] })).toBeNull();
|
|
15
15
|
expect(parseSkillsBundle({ version: "v1", skills: [{ name: "ok", files: { "notes.md": "x" } }] })).toBeNull(); // no SKILL.md
|
|
@@ -18,7 +18,7 @@ describe("skills served by the remote, installed by connect", () => {
|
|
|
18
18
|
expect(parseSkillsBundle("nope")).toBeNull();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("install writes each skill into each target, updates in place, removes what is gone, and leaves a member's own skill alone", () => {
|
|
21
|
+
test("install writes each skill into each target, updates in place, removes what is gone, and leaves a member's own skill alone", async () => {
|
|
22
22
|
const home = mkdtempSync(join(tmpdir(), "amr-skills-"));
|
|
23
23
|
const rh = join(home, ".auto-model-router");
|
|
24
24
|
const claude = join(home, ".claude", "skills");
|
|
@@ -83,7 +83,7 @@ describe("skills served by the remote, installed by connect", () => {
|
|
|
83
83
|
expect((await fetchSkills("https://t", "k", odd)).note).toContain("not understood");
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("connect installs the bundle only into the harnesses it configured", () => {
|
|
86
|
+
test("connect installs the bundle only into the harnesses it configured", async () => {
|
|
87
87
|
const home = mkdtempSync(join(tmpdir(), "amr-skills-connect-"));
|
|
88
88
|
mkdirSync(join(home, ".claude"), { recursive: true });
|
|
89
89
|
const agent = join(home, ".omp", "agent");
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dialect shim. Every case here is a way the two engines differ that cost
|
|
3
|
+
* a real bug while the ledger was ported: a JSON boolean read as the wrong
|
|
4
|
+
* type silently counted estimated cache hits as measured ones, a NULL
|
|
5
|
+
* parameter without a cast made Postgres refuse to plan the statement, and a
|
|
6
|
+
* repeated named bind mapped to the wrong slot reads the wrong column.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { bindNamed, jsonParam, jsonValue, num, numOrNull, openSqlDb } from "../src/util/sql.ts";
|
|
13
|
+
|
|
14
|
+
describe("bindNamed", () => {
|
|
15
|
+
test("numbers placeholders in order of first appearance, reusing a slot on postgres", async () => {
|
|
16
|
+
const text = "SELECT * FROM t WHERE a >= $since AND (b < $until OR c = $since)";
|
|
17
|
+
const pg = bindNamed(text, { since: 10, until: 20 }, true);
|
|
18
|
+
expect(pg.text).toBe("SELECT * FROM t WHERE a >= $1 AND (b < $2 OR c = $1)");
|
|
19
|
+
expect(pg.values).toEqual([10, 20]);
|
|
20
|
+
|
|
21
|
+
// SQLite's `?` cannot reuse a slot, so a repeat binds again, in order.
|
|
22
|
+
const lite = bindNamed(text, { since: 10, until: 20 }, false);
|
|
23
|
+
expect(lite.text).toBe("SELECT * FROM t WHERE a >= ? AND (b < ? OR c = ?)");
|
|
24
|
+
expect(lite.values).toEqual([10, 20, 10]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("leaves JSON paths alone and refuses an unbound name", async () => {
|
|
28
|
+
// `'$.isSubagent'` is a JSON path, not a placeholder.
|
|
29
|
+
const out = bindNamed("SELECT json_extract(features, '$.isSubagent') FROM t WHERE a = $a", { a: 1 }, true);
|
|
30
|
+
expect(out.text).toContain("'$.isSubagent'");
|
|
31
|
+
expect(out.values).toEqual([1]);
|
|
32
|
+
// A missing bind is a programming error, not an implicit NULL.
|
|
33
|
+
expect(() => bindNamed("SELECT $missing", {}, true)).toThrow();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("numeric coercion", () => {
|
|
38
|
+
test("postgres' string aggregates become numbers, and absent stays absent", async () => {
|
|
39
|
+
// Postgres returns COUNT(*) and BIGINT sums as strings; the ledger's
|
|
40
|
+
// helpers do arithmetic on them.
|
|
41
|
+
expect(num("42")).toBe(42);
|
|
42
|
+
expect(num(42)).toBe(42);
|
|
43
|
+
// A nullable aggregate must not silently become 0.
|
|
44
|
+
expect(num(null)).toBe(0);
|
|
45
|
+
expect(numOrNull(null)).toBeNull();
|
|
46
|
+
expect(numOrNull(undefined)).toBeNull();
|
|
47
|
+
expect(numOrNull("2.5")).toBe(2.5);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const engines = [
|
|
52
|
+
{ name: "sqlite", url: `sqlite://${join(tmpdir(), `shim-${process.pid}-${Date.now()}.db`)}` },
|
|
53
|
+
...(process.env.AMR_ROUTER_TEST_PG === undefined ? [] : [{ name: "postgres", url: process.env.AMR_ROUTER_TEST_PG }]),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
for (const engine of engines) {
|
|
57
|
+
describe(`shim on ${engine.name}`, () => {
|
|
58
|
+
test("json members read back as number, text and boolean", async () => {
|
|
59
|
+
const db = openSqlDb(engine.url);
|
|
60
|
+
try {
|
|
61
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS shim_probe");
|
|
62
|
+
await db.sql.unsafe(`CREATE TABLE shim_probe (id TEXT, u ${db.type("json")})`);
|
|
63
|
+
await db.sql`INSERT INTO shim_probe (id, u) VALUES (${"a"}, ${jsonParam(db, { promptTokens: 42, kind: "chat", estimated: true })})`;
|
|
64
|
+
await db.sql`INSERT INTO shim_probe (id, u) VALUES (${"b"}, ${jsonParam(db, { promptTokens: 1, kind: "chat", estimated: false })})`;
|
|
65
|
+
|
|
66
|
+
const rows = await db.query<{ id: string; tok: unknown; kind: unknown; est: unknown }>(
|
|
67
|
+
`SELECT id, ${db.jsonNum("u", "promptTokens")} AS tok, ${db.jsonText("u", "kind")} AS kind,
|
|
68
|
+
${db.jsonBool("u", "estimated")} AS est
|
|
69
|
+
FROM shim_probe WHERE id = $id`,
|
|
70
|
+
{ id: "a" },
|
|
71
|
+
);
|
|
72
|
+
expect(num(rows[0]?.tok)).toBe(42);
|
|
73
|
+
expect(rows[0]?.kind).toBe("chat");
|
|
74
|
+
// The boolean is the case that differed: json_extract yields the
|
|
75
|
+
// INTEGER 1, `->>` yields the TEXT 'true', and sqlite's IN does not
|
|
76
|
+
// coerce between them.
|
|
77
|
+
expect(num(rows[0]?.est)).toBe(1);
|
|
78
|
+
|
|
79
|
+
const off = await db.one<{ est: unknown }>(
|
|
80
|
+
`SELECT ${db.jsonBool("u", "estimated")} AS est FROM shim_probe WHERE id = $id`,
|
|
81
|
+
{ id: "b" },
|
|
82
|
+
);
|
|
83
|
+
expect(num(off?.est)).toBe(0);
|
|
84
|
+
|
|
85
|
+
// An aggregate over a JSON member: the reason jsonNum casts.
|
|
86
|
+
const total = await db.one<{ n: unknown }>(`SELECT SUM(${db.jsonNum("u", "promptTokens")}) AS n FROM shim_probe`);
|
|
87
|
+
expect(num(total?.n)).toBe(43);
|
|
88
|
+
} finally {
|
|
89
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS shim_probe");
|
|
90
|
+
await db.close();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an optional filter works with a NULL parameter, and substring search matches", async () => {
|
|
95
|
+
const db = openSqlDb(engine.url);
|
|
96
|
+
try {
|
|
97
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS shim_filter");
|
|
98
|
+
await db.sql.unsafe("CREATE TABLE shim_filter (h TEXT, reasons TEXT)");
|
|
99
|
+
await db.sql`INSERT INTO shim_filter (h, reasons) VALUES (${"h1"}, ${"failover: upstream died"})`;
|
|
100
|
+
await db.sql`INSERT INTO shim_filter (h, reasons) VALUES (${"h2"}, ${"cheapest"})`;
|
|
101
|
+
|
|
102
|
+
// The `(${x} IS NULL OR col = ${x})` idiom: Postgres cannot infer a
|
|
103
|
+
// type for a bare NULL placeholder without the cast.
|
|
104
|
+
const all = await db.query<{ h: string }>(
|
|
105
|
+
`SELECT h FROM shim_filter WHERE ($h${db.nullableText} IS NULL OR h = $h) ORDER BY h`,
|
|
106
|
+
{ h: null },
|
|
107
|
+
);
|
|
108
|
+
expect(all.map((r) => r.h)).toEqual(["h1", "h2"]);
|
|
109
|
+
|
|
110
|
+
const one = await db.query<{ h: string }>(
|
|
111
|
+
`SELECT h FROM shim_filter WHERE ($h${db.nullableText} IS NULL OR h = $h)`,
|
|
112
|
+
{ h: "h2" },
|
|
113
|
+
);
|
|
114
|
+
expect(one.map((r) => r.h)).toEqual(["h2"]);
|
|
115
|
+
|
|
116
|
+
const failovers = await db.query<{ h: string }>(
|
|
117
|
+
`SELECT h FROM shim_filter WHERE ${db.contains("reasons", "'failover:'")}`,
|
|
118
|
+
);
|
|
119
|
+
expect(failovers.map((r) => r.h)).toEqual(["h1"]);
|
|
120
|
+
} finally {
|
|
121
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS shim_filter");
|
|
122
|
+
await db.close();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("scalar minimum takes two arguments on both engines", async () => {
|
|
127
|
+
const db = openSqlDb(engine.url);
|
|
128
|
+
try {
|
|
129
|
+
// Postgres reserves MIN for the aggregate; LEAST is the scalar.
|
|
130
|
+
const row = await db.one<{ v: unknown }>(`SELECT ${db.least("3", "7")} AS v`);
|
|
131
|
+
expect(num(row?.v)).toBe(3);
|
|
132
|
+
} finally {
|
|
133
|
+
await db.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a json column round-trips whichever way it is stored", async () => {
|
|
138
|
+
const db = openSqlDb(engine.url);
|
|
139
|
+
try {
|
|
140
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS shim_rt");
|
|
141
|
+
await db.sql.unsafe(`CREATE TABLE shim_rt (u ${db.type("json")})`);
|
|
142
|
+
const value = { a: 1, nested: { b: [1, 2, 3] } };
|
|
143
|
+
await db.sql`INSERT INTO shim_rt (u) VALUES (${jsonParam(db, value)})`;
|
|
144
|
+
const row = await db.one<{ u: unknown }>("SELECT u FROM shim_rt");
|
|
145
|
+
// Stringifying into a JSONB column stores a JSON *string*, and every
|
|
146
|
+
// `->>` on it then reads NULL; jsonParam is what prevents that.
|
|
147
|
+
expect(jsonValue<typeof value>(row?.u)).toEqual(value);
|
|
148
|
+
} finally {
|
|
149
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS shim_rt");
|
|
150
|
+
await db.close();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
package/test/state.test.ts
CHANGED
|
@@ -1,90 +1,97 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
3
6
|
import { createConversationStore } from "../src/router/state.ts";
|
|
4
|
-
import {
|
|
7
|
+
import { migrateStore } from "../src/util/schema.ts";
|
|
8
|
+
import { openSqlDb } from "../src/util/sql.ts";
|
|
5
9
|
|
|
6
|
-
function mkStore() {
|
|
7
|
-
|
|
10
|
+
async function mkStore() {
|
|
11
|
+
// A file per store: the shim cannot share `:memory:` between handles, and a
|
|
12
|
+
// real deployment is a file (or a database) anyway.
|
|
13
|
+
const db = openSqlDb(join(tmpdir(), `state-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
14
|
+
await migrateStore(db);
|
|
8
15
|
return { db, store: createConversationStore(db) };
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
describe("conversation spend accounting", () => {
|
|
12
|
-
test("accrue accumulates instead of overwriting", () => {
|
|
13
|
-
const { db, store } = mkStore();
|
|
19
|
+
test("accrue accumulates instead of overwriting", async () => {
|
|
20
|
+
const { db, store } = await mkStore();
|
|
14
21
|
try {
|
|
15
|
-
store.load("k1");
|
|
16
|
-
store.accrue("k1", { spentUsd: 0.25 });
|
|
17
|
-
store.accrue("k1", { spentUsd: 0.5 });
|
|
18
|
-
store.accrue("k1", { escalations: 1 });
|
|
19
|
-
store.accrue("k1", { escalations: 2 });
|
|
22
|
+
await store.load("k1");
|
|
23
|
+
await store.accrue("k1", { spentUsd: 0.25 });
|
|
24
|
+
await store.accrue("k1", { spentUsd: 0.5 });
|
|
25
|
+
await store.accrue("k1", { escalations: 1 });
|
|
26
|
+
await store.accrue("k1", { escalations: 2 });
|
|
20
27
|
|
|
21
|
-
const state = store.get("k1");
|
|
28
|
+
const state = await store.get("k1");
|
|
22
29
|
expect(state?.spentUsd).toBeCloseTo(0.75, 10);
|
|
23
30
|
expect(state?.escalations).toBe(3);
|
|
24
31
|
} finally {
|
|
25
|
-
db.close();
|
|
32
|
+
await db.close();
|
|
26
33
|
}
|
|
27
34
|
});
|
|
28
35
|
|
|
29
|
-
test("save cannot clobber spend booked by a dispatch that never committed", () => {
|
|
36
|
+
test("save cannot clobber spend booked by a dispatch that never committed", async () => {
|
|
30
37
|
// The live bug: a dispatch is billed by the upstream, aborts mid-stream,
|
|
31
38
|
// and returns before the commit path. The NEXT dispatch had already loaded
|
|
32
39
|
// a turn-start snapshot, and `save` wrote that snapshot's stale total back
|
|
33
40
|
// over the aborted dispatch's cost. 30% of real spend vanished this way.
|
|
34
|
-
const { db, store } = mkStore();
|
|
41
|
+
const { db, store } = await mkStore();
|
|
35
42
|
try {
|
|
36
|
-
const snapshot = store.load("k1");
|
|
43
|
+
const snapshot = await store.load("k1");
|
|
37
44
|
expect(snapshot.spentUsd).toBe(0);
|
|
38
45
|
|
|
39
46
|
// An aborted dispatch books its cost while `snapshot` is still in hand.
|
|
40
|
-
store.accrue("k1", { spentUsd: 0.4, escalations: 1 });
|
|
47
|
+
await store.accrue("k1", { spentUsd: 0.4, escalations: 1 });
|
|
41
48
|
|
|
42
49
|
// The in-flight turn now commits using the state it loaded earlier.
|
|
43
50
|
snapshot.turn = 1;
|
|
44
51
|
snapshot.currentSlug = "cheap/model";
|
|
45
|
-
store.save(snapshot);
|
|
52
|
+
await store.save(snapshot);
|
|
46
53
|
|
|
47
|
-
const after = store.get("k1");
|
|
54
|
+
const after = await store.get("k1");
|
|
48
55
|
expect(after?.spentUsd).toBeCloseTo(0.4, 10);
|
|
49
56
|
expect(after?.escalations).toBe(1);
|
|
50
57
|
// The latest-wins fields still persist normally.
|
|
51
58
|
expect(after?.turn).toBe(1);
|
|
52
59
|
expect(after?.currentSlug).toBe("cheap/model");
|
|
53
60
|
} finally {
|
|
54
|
-
db.close();
|
|
61
|
+
await db.close();
|
|
55
62
|
}
|
|
56
63
|
});
|
|
57
64
|
|
|
58
|
-
test("interleaved dispatches both keep their money", () => {
|
|
59
|
-
const { db, store } = mkStore();
|
|
65
|
+
test("interleaved dispatches both keep their money", async () => {
|
|
66
|
+
const { db, store } = await mkStore();
|
|
60
67
|
try {
|
|
61
|
-
const a = store.load("k1");
|
|
62
|
-
const b = store.get("k1");
|
|
68
|
+
const a = await store.load("k1");
|
|
69
|
+
const b = await store.get("k1");
|
|
63
70
|
expect(b).not.toBeNull();
|
|
64
71
|
|
|
65
|
-
store.accrue("k1", { spentUsd: 0.1 });
|
|
66
|
-
store.save(a);
|
|
67
|
-
store.accrue("k1", { spentUsd: 0.2 });
|
|
68
|
-
if (b !== null) store.save(b);
|
|
72
|
+
await store.accrue("k1", { spentUsd: 0.1 });
|
|
73
|
+
await store.save(a);
|
|
74
|
+
await store.accrue("k1", { spentUsd: 0.2 });
|
|
75
|
+
if (b !== null) await store.save(b);
|
|
69
76
|
|
|
70
|
-
expect(store.get("k1")?.spentUsd).toBeCloseTo(0.3, 10);
|
|
77
|
+
expect((await store.get("k1"))?.spentUsd).toBeCloseTo(0.3, 10);
|
|
71
78
|
} finally {
|
|
72
|
-
db.close();
|
|
79
|
+
await db.close();
|
|
73
80
|
}
|
|
74
81
|
});
|
|
75
82
|
|
|
76
|
-
test("a zero delta does not touch the row", () => {
|
|
83
|
+
test("a zero delta does not touch the row", async () => {
|
|
77
84
|
// Bumping updated_at_ms for a no-op write would keep a dead conversation
|
|
78
85
|
// alive against `prune`, which reaps on that timestamp.
|
|
79
|
-
const { db, store } = mkStore();
|
|
86
|
+
const { db, store } = await mkStore();
|
|
80
87
|
try {
|
|
81
|
-
store.load("k1");
|
|
82
|
-
const before = store.get("k1")?.updatedAtMs ?? 0;
|
|
88
|
+
await store.load("k1");
|
|
89
|
+
const before = (await store.get("k1"))?.updatedAtMs ?? 0;
|
|
83
90
|
expect(before).toBeGreaterThan(0);
|
|
84
|
-
store.accrue("k1", { spentUsd: 0, escalations: 0 });
|
|
85
|
-
expect(store.get("k1")?.updatedAtMs).toBe(before);
|
|
91
|
+
await store.accrue("k1", { spentUsd: 0, escalations: 0 });
|
|
92
|
+
expect((await store.get("k1"))?.updatedAtMs).toBe(before);
|
|
86
93
|
} finally {
|
|
87
|
-
db.close();
|
|
94
|
+
await db.close();
|
|
88
95
|
}
|
|
89
96
|
});
|
|
90
97
|
});
|
package/test/summary.test.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
2
5
|
|
|
3
6
|
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
4
|
-
import {
|
|
7
|
+
import { createSqlLedger } from "../src/cost/ledger-sql.ts";
|
|
8
|
+
import { migrateStore } from "../src/util/schema.ts";
|
|
9
|
+
import type { AsyncLedger } from "../src/cost/types.ts";
|
|
5
10
|
import { buildDailySummary, countTierChanges, createKv, DAILY_SUMMARY_INTERVAL_MS, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews } from "../src/cost/summary.ts";
|
|
6
11
|
import type { LedgerEntry } from "../src/cost/types.ts";
|
|
12
|
+
import { openSqlDb, type SqlDb } from "../src/util/sql.ts";
|
|
7
13
|
import { openDb } from "../src/util/sqlite.ts";
|
|
8
14
|
import { fetchSummary } from "../omp-extension/report-logic.ts";
|
|
9
15
|
|
|
@@ -53,27 +59,30 @@ function entry(over: Partial<LedgerEntry>): LedgerEntry {
|
|
|
53
59
|
} as LedgerEntry;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
function seeded() {
|
|
62
|
+
async function seeded(): Promise<{ db: SqlDb; ledger: AsyncLedger }> {
|
|
57
63
|
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
64
|
+
// A file, not `:memory:`: the summary reads the report through its own
|
|
65
|
+
// handle on the store, which an in-memory database cannot share.
|
|
66
|
+
const path = join(tmpdir(), `summary-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
67
|
+
cfg.ledger.path = path;
|
|
68
|
+
const db = openSqlDb(path);
|
|
69
|
+
await migrateStore(db);
|
|
70
|
+
return { db, ledger: createSqlLedger(db, cfg, { findModel: () => null }) };
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
describe("buildDailySummary", () => {
|
|
65
|
-
test("compares the last 24h with the day before and names the top models and tier moves", () => {
|
|
66
|
-
const { db, ledger } = seeded();
|
|
74
|
+
test("compares the last 24h with the day before and names the top models and tier moves", async () => {
|
|
75
|
+
const { db, ledger } = await seeded();
|
|
67
76
|
try {
|
|
68
77
|
// Yesterday: one conversation climbing simple → hard, then dropping to moderate; a second model.
|
|
69
|
-
ledger.record(entry({ createdAtMs: NOW - 5 * HOUR, tier: "simple", turn: 1 }));
|
|
70
|
-
ledger.record(entry({ createdAtMs: NOW - 4 * HOUR, tier: "hard", turn: 2, slug: "vendor/big", servedSlug: "vendor/big", reportedUsd: 0.5 }));
|
|
71
|
-
ledger.record(entry({ createdAtMs: NOW - 3 * HOUR, tier: "moderate", turn: 3, escalationSignal: "empty_completion", wasted: true }));
|
|
72
|
-
ledger.record(entry({ createdAtMs: NOW - 3 * HOUR + 1, tier: "moderate", turn: 3, attempt: 1 }));
|
|
73
|
-
ledger.record(entry({ createdAtMs: NOW - 2 * HOUR, requestedModel: "digest", slug: "vendor/tiny", servedSlug: "vendor/tiny", conversationKey: "d", reportedUsd: 0.001 }));
|
|
78
|
+
await ledger.record(entry({ createdAtMs: NOW - 5 * HOUR, tier: "simple", turn: 1 }));
|
|
79
|
+
await ledger.record(entry({ createdAtMs: NOW - 4 * HOUR, tier: "hard", turn: 2, slug: "vendor/big", servedSlug: "vendor/big", reportedUsd: 0.5 }));
|
|
80
|
+
await ledger.record(entry({ createdAtMs: NOW - 3 * HOUR, tier: "moderate", turn: 3, escalationSignal: "empty_completion", wasted: true }));
|
|
81
|
+
await ledger.record(entry({ createdAtMs: NOW - 3 * HOUR + 1, tier: "moderate", turn: 3, attempt: 1 }));
|
|
82
|
+
await ledger.record(entry({ createdAtMs: NOW - 2 * HOUR, requestedModel: "digest", slug: "vendor/tiny", servedSlug: "vendor/tiny", conversationKey: "d", reportedUsd: 0.001 }));
|
|
74
83
|
// The day before: pricier.
|
|
75
|
-
for (let i = 0; i < 4; i++) ledger.record(entry({ createdAtMs: NOW - 30 * HOUR - i, conversationKey: "old", reportedUsd: 0.4 }));
|
|
76
|
-
const s = buildDailySummary(db, { nowMs: NOW, baselines: [{ slug: "anthropic/claude-opus-5", prompt: 15e-6, completion: 75e-6, cacheRead: 1.5e-6 }] });
|
|
84
|
+
for (let i = 0; i < 4; i++) await ledger.record(entry({ createdAtMs: NOW - 30 * HOUR - i, conversationKey: "old", reportedUsd: 0.4 }));
|
|
85
|
+
const s = await buildDailySummary(db, { nowMs: NOW, baselines: [{ slug: "anthropic/claude-opus-5", prompt: 15e-6, completion: 75e-6, cacheRead: 1.5e-6 }] });
|
|
77
86
|
expect(s.current.dispatches).toBe(5);
|
|
78
87
|
expect(s.current.spendUsd).toBeCloseTo(0.531, 6);
|
|
79
88
|
expect(s.current.escalations).toBe(1);
|
|
@@ -103,29 +112,29 @@ describe("buildDailySummary", () => {
|
|
|
103
112
|
}
|
|
104
113
|
});
|
|
105
114
|
|
|
106
|
-
test("an empty day renders as such and carries no news unless a model is spiking", () => {
|
|
107
|
-
const { db } = seeded();
|
|
115
|
+
test("an empty day renders as such and carries no news unless a model is spiking", async () => {
|
|
116
|
+
const { db } = await seeded();
|
|
108
117
|
try {
|
|
109
|
-
const s = buildDailySummary(db, { nowMs: NOW });
|
|
118
|
+
const s = await buildDailySummary(db, { nowMs: NOW });
|
|
110
119
|
expect(summaryHasNews(s)).toBe(false);
|
|
111
120
|
const text = renderDailySummary(s);
|
|
112
121
|
expect(text).toContain("no routed turns in the last 24h");
|
|
113
122
|
expect(text).toContain("soft failures: no model spiking in the last hour");
|
|
114
123
|
expect(text).not.toContain("ollama:");
|
|
115
124
|
expect(summaryHasNews({ ...s, spikes: [{ slug: "a/b", recentDispatches: 5, recentFailures: 3, recentRate: 0.6, baselineDispatches: 0, baselineFailures: 0, baselineRate: 0 }] })).toBe(true);
|
|
116
|
-
expect(countTierChanges(db, 0, "")).toEqual({ up: 0, down: 0 });
|
|
125
|
+
expect(await countTierChanges(db, 0, "")).toEqual({ up: 0, down: 0 });
|
|
117
126
|
} finally {
|
|
118
127
|
db.close();
|
|
119
128
|
}
|
|
120
129
|
});
|
|
121
130
|
|
|
122
|
-
test("harness scope narrows both windows", () => {
|
|
123
|
-
const { db, ledger } = seeded();
|
|
131
|
+
test("harness scope narrows both windows", async () => {
|
|
132
|
+
const { db, ledger } = await seeded();
|
|
124
133
|
try {
|
|
125
|
-
ledger.record(entry({ harnessId: "a" }));
|
|
126
|
-
ledger.record(entry({ harnessId: "b", reportedUsd: 5 }));
|
|
127
|
-
expect(buildDailySummary(db, { nowMs: NOW, harnessId: "a" }).current).toMatchObject({ dispatches: 1, spendUsd: 0.01 });
|
|
128
|
-
expect(buildDailySummary(db, { nowMs: NOW }).current.dispatches).toBe(2);
|
|
134
|
+
await ledger.record(entry({ harnessId: "a" }));
|
|
135
|
+
await ledger.record(entry({ harnessId: "b", reportedUsd: 5 }));
|
|
136
|
+
expect((await buildDailySummary(db, { nowMs: NOW, harnessId: "a" })).current).toMatchObject({ dispatches: 1, spendUsd: 0.01 });
|
|
137
|
+
expect((await buildDailySummary(db, { nowMs: NOW })).current.dispatches).toBe(2);
|
|
129
138
|
} finally {
|
|
130
139
|
db.close();
|
|
131
140
|
}
|
|
@@ -133,8 +142,10 @@ describe("buildDailySummary", () => {
|
|
|
133
142
|
});
|
|
134
143
|
|
|
135
144
|
describe("once-a-day gate", () => {
|
|
136
|
-
test("router_kv marks the summary shown per harness for 20h, surviving a reopen", () => {
|
|
137
|
-
|
|
145
|
+
test("router_kv marks the summary shown per harness for 20h, surviving a reopen", async () => {
|
|
146
|
+
// `createKv` owns `router_kv` on a bun:sqlite handle — not the ledger's
|
|
147
|
+
// table, and not part of the shim port.
|
|
148
|
+
const db = openDb(join(tmpdir(), `t-summary.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
138
149
|
try {
|
|
139
150
|
const kv = createKv(db);
|
|
140
151
|
expect(summaryDue(kv, "", NOW)).toBe(true);
|