auto-model-router 0.4.1 → 0.4.3
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 +48 -2
- package/omp-extension/digest-logic.ts +53 -0
- package/omp-extension/pi-coding-agent.d.ts +14 -1
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/omp-extension/router-digest.ts +93 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +22 -1
- package/src/config/defaults.ts +20 -0
- package/src/config/schema.ts +18 -1
- package/src/config/types.ts +54 -0
- package/src/cost/ledger.ts +77 -9
- package/src/cost/report.ts +24 -3
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +31 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +1 -0
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +5 -1
- package/src/server/compaction-digest.ts +127 -0
- package/src/server/digest.ts +243 -0
- package/src/server/http.ts +51 -1
- package/src/server/turn.ts +27 -1
- package/src/util/sqlite.ts +7 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +7 -0
- package/test/compaction.test.ts +40 -3
- package/test/controls.test.ts +34 -0
- package/test/digest.test.ts +229 -0
- package/test/embed-lifecycle.test.ts +1 -1
- package/test/failover.test.ts +4 -3
- package/test/learned.test.ts +21 -1
- package/test/report-hub.test.ts +3 -0
- package/test/report-logic.test.ts +11 -1
- package/test/report.test.ts +3 -0
- package/test/select.test.ts +2 -2
- package/test/summary.test.ts +171 -0
- package/test/tokens.test.ts +44 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +69 -3
- package/tools/train-classifier.ts +75 -20
package/test/controls.test.ts
CHANGED
|
@@ -236,3 +236,37 @@ describe("subagent profile", () => {
|
|
|
236
236
|
expect(resolveProfile(cfg, "auto", true).id).toBe("auto");
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
|
+
|
|
240
|
+
describe("digest endpoints", () => {
|
|
241
|
+
let handle: StartedServer;
|
|
242
|
+
let baseUrl = "";
|
|
243
|
+
beforeAll(() => {
|
|
244
|
+
const cfg: RouterConfig = {
|
|
245
|
+
...structuredClone(DEFAULT_CONFIG),
|
|
246
|
+
server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
|
|
247
|
+
ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
|
|
248
|
+
logLevel: "silent",
|
|
249
|
+
};
|
|
250
|
+
cfg.digest = { ...cfg.digest, enabled: true, minBytes: 10 };
|
|
251
|
+
handle = startServer(cfg);
|
|
252
|
+
baseUrl = `http://127.0.0.1:${handle.server.port}`;
|
|
253
|
+
});
|
|
254
|
+
afterAll(async () => {
|
|
255
|
+
await handle.stop();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("policy reflects the config; a digest for a session with no turns is declined, a bad body rejected", async () => {
|
|
259
|
+
const policy = (await (await fetch(`${baseUrl}/v1/router/digest/policy`)).json()) as { enabled: boolean; minBytes: number; tools: string[] };
|
|
260
|
+
expect(policy.enabled).toBe(true);
|
|
261
|
+
expect(policy.minBytes).toBe(10);
|
|
262
|
+
expect(policy.tools).toContain("read");
|
|
263
|
+
const bad = await fetch(`${baseUrl}/v1/router/digest`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ toolName: "read" }) });
|
|
264
|
+
expect(bad.status).toBe(400);
|
|
265
|
+
const res = await fetch(`${baseUrl}/v1/router/digest`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "content-type": "application/json" },
|
|
268
|
+
body: JSON.stringify({ ompSessionId: "never", toolName: "read", input: {}, content: "x".repeat(100), query: "q" }),
|
|
269
|
+
});
|
|
270
|
+
expect((await res.json()) as unknown).toMatchObject({ digested: false, reason: "no routed turn in this session yet" });
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
4
|
+
import type { CatalogModel, CatalogSnapshot, CatalogSource } from "../src/catalog/types.ts";
|
|
5
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
6
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
7
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
8
|
+
import type { LedgerEntry } from "../src/cost/types.ts";
|
|
9
|
+
import { createDigester, digestApplies, digestMarker } from "../src/server/digest.ts";
|
|
10
|
+
import type { UpstreamClient } from "../src/upstream/types.ts";
|
|
11
|
+
import { createLogger } from "../src/util/log.ts";
|
|
12
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
13
|
+
import { digestToast, parsePolicy, shouldSend, textOf } from "../omp-extension/digest-logic.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The tool-result digest: gates (tool, size, error, session tier, cost),
|
|
17
|
+
* model choice from the cheap tier, the marker that keeps the full output
|
|
18
|
+
* reachable, the ledger row every digest leaves, and the extension's
|
|
19
|
+
* client-side checks.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
|
|
23
|
+
const MODELS: CatalogModel[] = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
|
|
24
|
+
const SNAPSHOT: CatalogSnapshot = { models: MODELS, fetchedAtMs: Date.now() };
|
|
25
|
+
const catalog: CatalogSource = {
|
|
26
|
+
get: async () => SNAPSHOT,
|
|
27
|
+
refresh: async () => SNAPSHOT,
|
|
28
|
+
peek: () => SNAPSHOT,
|
|
29
|
+
find: (slug) => MODELS.find((m) => m.slug === slug),
|
|
30
|
+
};
|
|
31
|
+
const log = createLogger("silent");
|
|
32
|
+
|
|
33
|
+
function cfgWith(over: Partial<RouterConfig["digest"]> = {}): RouterConfig {
|
|
34
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
35
|
+
cfg.ledger.path = ":memory:";
|
|
36
|
+
cfg.digest = { ...cfg.digest, enabled: true, minBytes: 100, ...over };
|
|
37
|
+
return cfg;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function seedSession(ledger: ReturnType<typeof createLedger>, tier: string): void {
|
|
41
|
+
const e: LedgerEntry = {
|
|
42
|
+
id: crypto.randomUUID(),
|
|
43
|
+
createdAtMs: Date.now(),
|
|
44
|
+
conversationKey: "k",
|
|
45
|
+
sessionId: "s",
|
|
46
|
+
turn: 3,
|
|
47
|
+
requestedModel: "auto",
|
|
48
|
+
harnessId: "",
|
|
49
|
+
ompSessionId: "omp-1",
|
|
50
|
+
slug: "x/y",
|
|
51
|
+
servedSlug: "x/y",
|
|
52
|
+
tier,
|
|
53
|
+
classificationSource: "heuristic",
|
|
54
|
+
reasons: [],
|
|
55
|
+
features: null,
|
|
56
|
+
score: null,
|
|
57
|
+
confidence: null,
|
|
58
|
+
task: null,
|
|
59
|
+
classifierReasons: null,
|
|
60
|
+
exploredFrom: null,
|
|
61
|
+
holdArm: null,
|
|
62
|
+
predictedUsd: 0.01,
|
|
63
|
+
reportedUsd: 0.01,
|
|
64
|
+
usage: { promptTokens: 100, cachedTokens: 0, cacheWriteTokens: 0, completionTokens: 10, reasoningTokens: 0, images: 0 },
|
|
65
|
+
attempt: 0,
|
|
66
|
+
escalationSignal: null,
|
|
67
|
+
latencyMs: 100,
|
|
68
|
+
ttftMs: 50,
|
|
69
|
+
finishReason: "stop",
|
|
70
|
+
wasted: false,
|
|
71
|
+
upstreamGenerationId: null,
|
|
72
|
+
error: null,
|
|
73
|
+
promptTokensSaved: 0,
|
|
74
|
+
};
|
|
75
|
+
ledger.record(e);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fakeUpstream(reply: (body: Record<string, unknown>) => string, costUsd: number | null = 0.0004): { upstream: UpstreamClient; calls: Record<string, unknown>[] } {
|
|
79
|
+
const calls: Record<string, unknown>[] = [];
|
|
80
|
+
return {
|
|
81
|
+
calls,
|
|
82
|
+
upstream: {
|
|
83
|
+
dispatch: () => Promise.reject(new Error("not used")),
|
|
84
|
+
complete: async (body) => {
|
|
85
|
+
calls.push(body);
|
|
86
|
+
return { text: reply(body), costUsd };
|
|
87
|
+
},
|
|
88
|
+
fetchModels: () => Promise.resolve([]),
|
|
89
|
+
fetchModelsForUser: () => Promise.resolve([]),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const BIG = Array.from({ length: 400 }, (_, i) => `${i + 1}: export const value${i} = ${i};`).join("\n");
|
|
95
|
+
|
|
96
|
+
describe("digestApplies", () => {
|
|
97
|
+
const d = { ...DEFAULT_CONFIG.digest, enabled: true, minBytes: 100, maxBytes: 1000 };
|
|
98
|
+
test("gates on switch, error, tool, size and session tier", () => {
|
|
99
|
+
expect(digestApplies({ ...d, enabled: false }, "read", 500, false, "hard").ok).toBe(false);
|
|
100
|
+
expect(digestApplies(d, "read", 500, true, "hard").ok).toBe(false);
|
|
101
|
+
expect(digestApplies(d, "edit", 500, false, "hard").ok).toBe(false);
|
|
102
|
+
expect(digestApplies(d, "read", 50, false, "hard").ok).toBe(false);
|
|
103
|
+
expect(digestApplies(d, "read", 5000, false, "hard").ok).toBe(false);
|
|
104
|
+
expect(digestApplies(d, "read", 500, false, null).ok).toBe(false);
|
|
105
|
+
expect(digestApplies(d, "read", 500, false, "simple").ok).toBe(false); // below fromTier moderate
|
|
106
|
+
expect(digestApplies(d, "read", 500, false, "moderate").ok).toBe(true);
|
|
107
|
+
expect(digestApplies(d, "READ", 500, false, "hard").ok).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("createDigester", () => {
|
|
112
|
+
test("condenses a large read for a hard-tier session on a cheap model and records a ledger row", async () => {
|
|
113
|
+
const cfg = cfgWith();
|
|
114
|
+
const db = openDb(":memory:");
|
|
115
|
+
const ledger = createLedger(db, cfg);
|
|
116
|
+
seedSession(ledger, "hard");
|
|
117
|
+
const { upstream, calls } = fakeUpstream(() => "Omitted 380 trivial constants.\n1: export const value0 = 0;\n...");
|
|
118
|
+
const d = createDigester({ cfg, catalog, ledger, upstream, log });
|
|
119
|
+
const r = await d.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/values.ts" }, content: BIG, query: "find value0" });
|
|
120
|
+
expect(r.digested).toBe(true);
|
|
121
|
+
if (!r.digested) return;
|
|
122
|
+
expect(r.text.startsWith("[digest: read output")).toBe(true);
|
|
123
|
+
expect(r.text).toContain("re-run read {\"path\":\"src/values.ts\"} (offset/limit for a range)");
|
|
124
|
+
expect(r.text).toContain("Omitted 380 trivial constants.");
|
|
125
|
+
expect(r.usd).toBeCloseTo(0.0004, 6);
|
|
126
|
+
// The cheap tier picked the model; the call carried the task and the raw output.
|
|
127
|
+
const call = calls[0]!;
|
|
128
|
+
expect(typeof call.model).toBe("string");
|
|
129
|
+
expect(catalog.find(call.model as string)?.price.prompt).toBeLessThanOrEqual(cfg.tiers.simple.maxInputPerMtok! / 1e6);
|
|
130
|
+
expect(JSON.stringify(call.messages)).toContain("Task: find value0");
|
|
131
|
+
// A ledger row under requestedModel "digest" with the served model and its cost.
|
|
132
|
+
const rows = ledger.recentEntries(10).filter((e) => e.requestedModel === "digest");
|
|
133
|
+
expect(rows).toHaveLength(1);
|
|
134
|
+
expect(rows[0]!.slug).toBe(call.model as string);
|
|
135
|
+
expect(rows[0]!.reportedUsd).toBeCloseTo(0.0004, 6);
|
|
136
|
+
expect(rows[0]!.ompSessionId).toBe("omp-1");
|
|
137
|
+
db.close();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("declines below the session tier, over the cost guard, when the model fails, or when nothing shrinks", async () => {
|
|
141
|
+
const db = openDb(":memory:");
|
|
142
|
+
const cfg = cfgWith();
|
|
143
|
+
const ledger = createLedger(db, cfg);
|
|
144
|
+
seedSession(ledger, "simple");
|
|
145
|
+
const cheap = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "short").upstream, log });
|
|
146
|
+
expect(await cheap.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: expect.stringContaining("below digest.fromTier") });
|
|
147
|
+
|
|
148
|
+
const db2 = openDb(":memory:");
|
|
149
|
+
const strict = cfgWith({ maxCostUsd: 0 });
|
|
150
|
+
const ledger2 = createLedger(db2, strict);
|
|
151
|
+
seedSession(ledger2, "hard");
|
|
152
|
+
expect(await createDigester({ cfg: strict, catalog, ledger: ledger2, upstream: fakeUpstream(() => "x").upstream, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: expect.stringContaining("exceeds digest.maxCostUsd") });
|
|
153
|
+
|
|
154
|
+
const failing: UpstreamClient = { ...fakeUpstream(() => "x").upstream, complete: () => Promise.reject(new Error("boom")) };
|
|
155
|
+
expect(await createDigester({ cfg, catalog, ledger: ledger2, upstream: failing, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: "digest model failed: boom" });
|
|
156
|
+
// The failed attempt is still a ledger row, with the error.
|
|
157
|
+
expect(ledger2.recentEntries(5).find((e) => e.requestedModel === "digest")?.error).toBe("boom");
|
|
158
|
+
|
|
159
|
+
const same = createDigester({ cfg, catalog, ledger: ledger2, upstream: fakeUpstream(() => BIG).upstream, log });
|
|
160
|
+
expect(await same.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: "digest did not shrink the output" });
|
|
161
|
+
db.close();
|
|
162
|
+
db2.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a compaction-sourced digest is gated on compaction.digestToolResults and judges the given tier", async () => {
|
|
166
|
+
const cfg = cfgWith({ enabled: false });
|
|
167
|
+
cfg.compaction.digestToolResults = true;
|
|
168
|
+
const db = openDb(":memory:");
|
|
169
|
+
const ledger = createLedger(db, cfg);
|
|
170
|
+
// No session rows at all: the tier comes from the request.
|
|
171
|
+
const { upstream, calls } = fakeUpstream(() => "Condensed.");
|
|
172
|
+
const d = createDigester({ cfg, catalog, ledger, upstream, log });
|
|
173
|
+
const base = { ompSessionId: "omp-9", harnessId: "", toolName: "read", input: { path: "x.ts" }, content: BIG, query: "q" };
|
|
174
|
+
// digest.enabled is off, so the tool_result path declines...
|
|
175
|
+
expect(await d.digest(base)).toMatchObject({ digested: false, reason: expect.stringContaining("disabled") });
|
|
176
|
+
// ...but compaction only needs compaction.digestToolResults, plus a tier at or above fromTier.
|
|
177
|
+
expect(await d.digest({ ...base, tier: "simple", source: "compaction" })).toMatchObject({ digested: false, reason: expect.stringContaining("below digest.fromTier") });
|
|
178
|
+
const r = await d.digest({ ...base, tier: "hard", source: "compaction" });
|
|
179
|
+
expect(r.digested).toBe(true);
|
|
180
|
+
expect(calls).toHaveLength(1);
|
|
181
|
+
expect(ledger.recentEntries(5).find((e) => e.requestedModel === "digest")?.reasons[0]).toContain("digest (compaction)");
|
|
182
|
+
cfg.compaction.digestToolResults = false;
|
|
183
|
+
expect(await d.digest({ ...base, tier: "hard", source: "compaction" })).toMatchObject({ digested: false });
|
|
184
|
+
db.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a pinned digest model is used as-is", async () => {
|
|
188
|
+
const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
|
|
189
|
+
const cfg = cfgWith({ model: pinned });
|
|
190
|
+
const db = openDb(":memory:");
|
|
191
|
+
const ledger = createLedger(db, cfg);
|
|
192
|
+
seedSession(ledger, "hard");
|
|
193
|
+
const { upstream, calls } = fakeUpstream(() => "digest");
|
|
194
|
+
await createDigester({ cfg, catalog, ledger, upstream, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "grep", input: { pattern: "x" }, content: BIG, query: "" });
|
|
195
|
+
expect(calls[0]?.model as string).toBe(pinned);
|
|
196
|
+
db.close();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("digest marker and extension logic", () => {
|
|
201
|
+
test("the marker names the tool, sizes, model and how to get the full output", () => {
|
|
202
|
+
expect(digestMarker("grep", { pattern: "retry" }, "z-ai/glm-5.3-flash", 48_000, 3_000)).toBe(
|
|
203
|
+
'[digest: grep output 48,000 bytes → 3,000 chars by z-ai/glm-5.3-flash. Full output: re-run grep {"pattern":"retry"}]',
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("textOf joins text parts and flags images", () => {
|
|
208
|
+
expect(textOf([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toEqual({ text: "a\nb", hasImage: false });
|
|
209
|
+
expect(textOf([{ type: "image" }, { type: "text", text: "a" }])).toEqual({ text: "a", hasImage: true });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("shouldSend applies the client-side gate; parsePolicy is defensive", () => {
|
|
213
|
+
const p = parsePolicy({ enabled: true, minBytes: 10, maxBytes: 100, tools: ["Read", "grep"], fromTier: "hard" });
|
|
214
|
+
expect(p.tools).toEqual(["read", "grep"]);
|
|
215
|
+
expect(shouldSend(p, "read", false, "x".repeat(50), false)).toBe(true);
|
|
216
|
+
expect(shouldSend(p, "read", true, "x".repeat(50), false)).toBe(false);
|
|
217
|
+
expect(shouldSend(p, "read", false, "x".repeat(50), true)).toBe(false);
|
|
218
|
+
expect(shouldSend(p, "edit", false, "x".repeat(50), false)).toBe(false);
|
|
219
|
+
expect(shouldSend(p, "read", false, "x".repeat(5), false)).toBe(false);
|
|
220
|
+
expect(shouldSend(p, "read", false, "x".repeat(500), false)).toBe(false);
|
|
221
|
+
expect(parsePolicy({ enabled: false }).enabled).toBe(false);
|
|
222
|
+
expect(parsePolicy("nope").enabled).toBe(false);
|
|
223
|
+
expect(parsePolicy({ enabled: true }).minBytes).toBe(12_000);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("digestToast is one readable line", () => {
|
|
227
|
+
expect(digestToast("read", 48 * 1024, 3 * 1024, "ollama/glm-5.3-flash", 0.00042)).toBe("digested read 48KB → 3KB via glm-5.3-flash ($0.0004)");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -29,7 +29,7 @@ import type { ExtensionAPI, ExtensionContext, ProviderRegistration } from "@oh-m
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
const registrations: { id: string; baseUrl: string }[] = [];
|
|
32
|
-
const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) =>
|
|
32
|
+
const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => unknown)[]>();
|
|
33
33
|
|
|
34
34
|
const pi: ExtensionAPI = {
|
|
35
35
|
setLabel: () => {},
|
package/test/failover.test.ts
CHANGED
|
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
46
46
|
data: { axis: "intelligence", minQuality: 0 },
|
|
47
47
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
48
48
|
},
|
|
49
|
-
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
49
|
+
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
50
50
|
classifier: {
|
|
51
51
|
ambiguityThreshold: 0,
|
|
52
52
|
model: "test/adjudicator", learnedModelPath: "",
|
|
@@ -74,9 +74,10 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
74
74
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
75
75
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
76
76
|
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
|
|
77
|
-
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
|
|
77
|
+
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
|
-
report: { baselines: [] },
|
|
79
|
+
report: { baselines: [], dailySummary: false },
|
|
80
|
+
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
|
|
80
81
|
profiles: [],
|
|
81
82
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
82
83
|
adaptiveTierFloors: true,
|
package/test/learned.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { auc, FEATURE_NAMES, learnedVector, predictRisk, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
|
|
3
|
+
import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, learnedRiskName, learnedVector, loadLearnedModel, predictRisk, resetLearnedModels, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The learned escalation-risk model: a deterministic logistic regression that
|
|
@@ -59,3 +59,23 @@ describe("trainLogistic", () => {
|
|
|
59
59
|
expect(() => trainLogistic([], [])).toThrow();
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
describe("learned label", () => {
|
|
64
|
+
test("a feedback-labelled model loads with its label and names its risk p(bad)", async () => {
|
|
65
|
+
const d = FEATURE_NAMES.length;
|
|
66
|
+
const base: LearnedModel = { version: LEARNED_MODEL_VERSION, trainedAtMs: 0, rows: 100, positives: 10, names: [...FEATURE_NAMES], means: new Array(d).fill(0), stds: new Array(d).fill(1), weights: new Array(d).fill(0), bias: 0, auc: 0.5 };
|
|
67
|
+
expect(learnedRiskName(base)).toBe("escalate");
|
|
68
|
+
expect(learnedRiskName({ ...base, label: "feedback" })).toBe("bad");
|
|
69
|
+
const path = `${import.meta.dir}/../.tmp-learned-feedback.json`;
|
|
70
|
+
await Bun.write(path, JSON.stringify({ ...base, label: "feedback" }));
|
|
71
|
+
try {
|
|
72
|
+
resetLearnedModels();
|
|
73
|
+
const loaded = await loadLearnedModel(path);
|
|
74
|
+
expect(loaded?.label).toBe("feedback");
|
|
75
|
+
expect(learnedRiskName(loaded!)).toBe("bad");
|
|
76
|
+
} finally {
|
|
77
|
+
resetLearnedModels();
|
|
78
|
+
await Bun.file(path).delete();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
package/test/report-hub.test.ts
CHANGED
|
@@ -78,6 +78,9 @@ function report(over: Partial<UsageReport> = {}): UsageReport {
|
|
|
78
78
|
cacheEstimated: false,
|
|
79
79
|
subagentDispatches: 0,
|
|
80
80
|
subagentSpendUsd: 0,
|
|
81
|
+
digests: 0,
|
|
82
|
+
digestSpendUsd: 0,
|
|
83
|
+
digestInputTokens: 0,
|
|
81
84
|
},
|
|
82
85
|
providers: [row("openrouter", 2), row("ollama", 1)],
|
|
83
86
|
models: [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { fetchReport, parseReportArgs, renderStatus, type HealthSnapshot } from "../omp-extension/report-logic.ts";
|
|
3
|
+
import { fetchReport, parseReportArgs, renderSoftFailureSpikes, renderStatus, type HealthSnapshot } from "../omp-extension/report-logic.ts";
|
|
4
4
|
|
|
5
5
|
describe("parseReportArgs", () => {
|
|
6
6
|
test("defaults to 7 days scoped to the harness", () => {
|
|
@@ -71,8 +71,11 @@ describe("renderStatus", () => {
|
|
|
71
71
|
costBias: { configured: 0.1, effective: 0.1, biasUntilUsage: 0.9 },
|
|
72
72
|
},
|
|
73
73
|
catalog: { models: 240, ageMs: 5 * 60_000, keyScoped: true, shrink: { fromModels: 300, toModels: 120, atMs: now } },
|
|
74
|
+
softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: [{ slug: "z-ai/glm-5.3", recentDispatches: 12, recentFailures: 5, recentRate: 5 / 12, baselineDispatches: 340, baselineRate: 0.08 }] },
|
|
74
75
|
};
|
|
75
76
|
const text = renderStatus("http://127.0.0.1:8788", h, now);
|
|
77
|
+
expect(text).toContain("soft failures SPIKING (1):");
|
|
78
|
+
expect(text).toContain(" z-ai/glm-5.3: 42% of 12 failed in the last 1h (7d baseline 8% of 340)");
|
|
76
79
|
expect(text).toContain("configured (omp)");
|
|
77
80
|
expect(text).toContain("240 models");
|
|
78
81
|
expect(text).toContain("refreshed 5m ago");
|
|
@@ -86,6 +89,13 @@ describe("renderStatus", () => {
|
|
|
86
89
|
expect(text).toContain("recording turns");
|
|
87
90
|
});
|
|
88
91
|
|
|
92
|
+
test("reports a quiet hour and omits the line for routers that predate the check", () => {
|
|
93
|
+
expect(renderStatus("http://h", { status: "ok", softFailures: { spikes: [] } })).toContain("soft failures: no model spiking in the last hour");
|
|
94
|
+
expect(renderStatus("http://h", { status: "ok" })).not.toContain("soft failures");
|
|
95
|
+
expect(renderSoftFailureSpikes(null)).toEqual([]);
|
|
96
|
+
expect(renderSoftFailureSpikes([{ slug: "a/b", recentRate: 0.5, recentDispatches: 6 }], 30 * 60_000, 7)).toEqual(["a/b: 50% of 6 failed in the last 30m (7d baseline 0% of 0)"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
89
99
|
test("degrades cleanly when sections are absent", () => {
|
|
90
100
|
const text = renderStatus("http://h", { status: "ok", apiKeyConfigured: false });
|
|
91
101
|
expect(text).toContain("key MISSING");
|
package/test/report.test.ts
CHANGED
|
@@ -227,6 +227,9 @@ describe("buildUsageReport", () => {
|
|
|
227
227
|
cacheEstimated: false,
|
|
228
228
|
subagentDispatches: 0,
|
|
229
229
|
subagentSpendUsd: 0,
|
|
230
|
+
digests: 0,
|
|
231
|
+
digestSpendUsd: 0,
|
|
232
|
+
digestInputTokens: 0,
|
|
230
233
|
});
|
|
231
234
|
expect(r.providers).toEqual([]);
|
|
232
235
|
expect(r.models).toEqual([]);
|
package/test/select.test.ts
CHANGED
|
@@ -637,7 +637,7 @@ describe("context compaction", () => {
|
|
|
637
637
|
keepHeadBytes: 20,
|
|
638
638
|
keepTailBytes: 20,
|
|
639
639
|
elideSupersededReads: true,
|
|
640
|
-
collapseDuplicateResults: true,
|
|
640
|
+
collapseDuplicateResults: true, digestToolResults: false, digestMaxPerTurn: 2,
|
|
641
641
|
},
|
|
642
642
|
};
|
|
643
643
|
|
|
@@ -954,7 +954,7 @@ describe("compaction.replanGrowthRatio (review 2026-09-05 §7)", () => {
|
|
|
954
954
|
keepHeadBytes: 20,
|
|
955
955
|
keepTailBytes: 20,
|
|
956
956
|
elideSupersededReads: false,
|
|
957
|
-
collapseDuplicateResults: false,
|
|
957
|
+
collapseDuplicateResults: false, digestToolResults: false, digestMaxPerTurn: 2,
|
|
958
958
|
},
|
|
959
959
|
};
|
|
960
960
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
4
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
5
|
+
import { buildDailySummary, countTierChanges, createKv, DAILY_SUMMARY_INTERVAL_MS, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews } from "../src/cost/summary.ts";
|
|
6
|
+
import type { LedgerEntry } from "../src/cost/types.ts";
|
|
7
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
8
|
+
import { fetchSummary } from "../omp-extension/report-logic.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The daily summary: the last 24h against the day before, top models, tier
|
|
12
|
+
* moves, the once-a-day gate in router_kv, and the text the transcript gets.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const HOUR = 3_600_000;
|
|
16
|
+
const NOW = Date.UTC(2026, 8, 7, 9, 0, 0);
|
|
17
|
+
|
|
18
|
+
function entry(over: Partial<LedgerEntry>): LedgerEntry {
|
|
19
|
+
return {
|
|
20
|
+
id: crypto.randomUUID(),
|
|
21
|
+
createdAtMs: NOW - HOUR,
|
|
22
|
+
conversationKey: "k",
|
|
23
|
+
sessionId: "s",
|
|
24
|
+
turn: 1,
|
|
25
|
+
requestedModel: "auto",
|
|
26
|
+
harnessId: "",
|
|
27
|
+
ompSessionId: "",
|
|
28
|
+
slug: "vendor/model",
|
|
29
|
+
servedSlug: "vendor/model",
|
|
30
|
+
tier: "simple",
|
|
31
|
+
classificationSource: "heuristic",
|
|
32
|
+
reasons: [],
|
|
33
|
+
features: null,
|
|
34
|
+
score: null,
|
|
35
|
+
confidence: null,
|
|
36
|
+
task: null,
|
|
37
|
+
classifierReasons: null,
|
|
38
|
+
exploredFrom: null,
|
|
39
|
+
holdArm: null,
|
|
40
|
+
predictedUsd: 0.01,
|
|
41
|
+
reportedUsd: 0.01,
|
|
42
|
+
usage: { promptTokens: 1000, cachedTokens: 500, cacheWriteTokens: 0, completionTokens: 100, reasoningTokens: 0, images: 0 },
|
|
43
|
+
attempt: 0,
|
|
44
|
+
escalationSignal: null,
|
|
45
|
+
latencyMs: 1_100,
|
|
46
|
+
ttftMs: 100,
|
|
47
|
+
finishReason: "stop",
|
|
48
|
+
wasted: false,
|
|
49
|
+
upstreamGenerationId: null,
|
|
50
|
+
error: null,
|
|
51
|
+
promptTokensSaved: null,
|
|
52
|
+
...over,
|
|
53
|
+
} as LedgerEntry;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function seeded() {
|
|
57
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
58
|
+
cfg.ledger.path = ":memory:";
|
|
59
|
+
const db = openDb(":memory:");
|
|
60
|
+
const ledger = createLedger(db, cfg);
|
|
61
|
+
return { db, ledger };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
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();
|
|
67
|
+
try {
|
|
68
|
+
// 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 }));
|
|
74
|
+
// 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 }] });
|
|
77
|
+
expect(s.current.dispatches).toBe(5);
|
|
78
|
+
expect(s.current.spendUsd).toBeCloseTo(0.531, 6);
|
|
79
|
+
expect(s.current.escalations).toBe(1);
|
|
80
|
+
expect(s.current.digests).toBe(1);
|
|
81
|
+
expect(s.current.modelSwitches).toBe(2);
|
|
82
|
+
expect(s.previous.dispatches).toBe(4);
|
|
83
|
+
expect(s.previous.spendUsd).toBeCloseTo(1.6, 6);
|
|
84
|
+
expect(s.topModels[0]).toMatchObject({ slug: "vendor/big", dispatches: 1 });
|
|
85
|
+
expect(s.topModels[0]!.share).toBeCloseTo(0.5 / 0.531, 3);
|
|
86
|
+
expect(s.tierChanges).toEqual({ up: 1, down: 1 });
|
|
87
|
+
expect(s.baseline?.slug).toBe("anthropic/claude-opus-5");
|
|
88
|
+
expect(summaryHasNews(s)).toBe(true);
|
|
89
|
+
|
|
90
|
+
const text = renderDailySummary({ ...s, spikes: [{ slug: "vendor/big", recentDispatches: 8, recentFailures: 4, recentRate: 0.5, baselineDispatches: 90, baselineFailures: 3, baselineRate: 3 / 90 }], ollama: { plan: "pro", usedUsd: 25.2, creditsUsd: 60, runwayDays: 23.4 } });
|
|
91
|
+
expect(text).toContain("auto-model-router daily summary — last 24h (all harnesses)");
|
|
92
|
+
expect(text).toContain("spend $0.531 (prev 24h $1.60, −67%) · 5 turns · 2 conversations · $0.106/turn");
|
|
93
|
+
expect(text).toContain("cache hit 50% · 1 escalations · 0 errors · 2 model switches (1 tier up, 1 down)");
|
|
94
|
+
expect(text).toContain("top models: vendor/big $0.500 (94%, 1 turns)");
|
|
95
|
+
// Tiny seeded turns cost more than Opus would have at list price: the honest branch renders.
|
|
96
|
+
expect(text).toContain("cost 574% MORE than anthropic/claude-opus-5 ($0.079 at list)");
|
|
97
|
+
expect(renderDailySummary({ ...s, baseline: { slug: "anthropic/claude-opus-5", usd: 2.5, savedShare: 0.7876 } })).toContain("saved 79% vs anthropic/claude-opus-5 ($2.50 at list)");
|
|
98
|
+
expect(text).toContain("1 digests for $0.001");
|
|
99
|
+
expect(text).toContain("soft failures SPIKING (1):\n vendor/big: 50% of 8 failed in the last 1h (7d baseline 3% of 90)");
|
|
100
|
+
expect(text).toContain("ollama: pro plan $25.20 of $60 · ~23 days of credits left");
|
|
101
|
+
} finally {
|
|
102
|
+
db.close();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("an empty day renders as such and carries no news unless a model is spiking", () => {
|
|
107
|
+
const { db } = seeded();
|
|
108
|
+
try {
|
|
109
|
+
const s = buildDailySummary(db, { nowMs: NOW });
|
|
110
|
+
expect(summaryHasNews(s)).toBe(false);
|
|
111
|
+
const text = renderDailySummary(s);
|
|
112
|
+
expect(text).toContain("no routed turns in the last 24h");
|
|
113
|
+
expect(text).toContain("soft failures: no model spiking in the last hour");
|
|
114
|
+
expect(text).not.toContain("ollama:");
|
|
115
|
+
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 });
|
|
117
|
+
} finally {
|
|
118
|
+
db.close();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("harness scope narrows both windows", () => {
|
|
123
|
+
const { db, ledger } = seeded();
|
|
124
|
+
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);
|
|
129
|
+
} finally {
|
|
130
|
+
db.close();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("once-a-day gate", () => {
|
|
136
|
+
test("router_kv marks the summary shown per harness for 20h, surviving a reopen", () => {
|
|
137
|
+
const db = openDb(":memory:");
|
|
138
|
+
try {
|
|
139
|
+
const kv = createKv(db);
|
|
140
|
+
expect(summaryDue(kv, "", NOW)).toBe(true);
|
|
141
|
+
markSummaryShown(kv, "", NOW);
|
|
142
|
+
expect(summaryDue(kv, "", NOW + HOUR)).toBe(false);
|
|
143
|
+
expect(summaryDue(kv, "other", NOW + HOUR)).toBe(true);
|
|
144
|
+
expect(summaryDue(kv, "", NOW + DAILY_SUMMARY_INTERVAL_MS)).toBe(true);
|
|
145
|
+
// Same table, fresh handle: the marker is durable.
|
|
146
|
+
expect(summaryDue(createKv(db), "", NOW + HOUR)).toBe(false);
|
|
147
|
+
kv.set("k", "v1");
|
|
148
|
+
kv.set("k", "v2");
|
|
149
|
+
expect(kv.get("k")).toBe("v2");
|
|
150
|
+
expect(kv.get("missing")).toBeNull();
|
|
151
|
+
} finally {
|
|
152
|
+
db.close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("fetchSummary", () => {
|
|
158
|
+
test("passes harness and auto through and surfaces the router's verdict", async () => {
|
|
159
|
+
const calls: string[] = [];
|
|
160
|
+
const fetchImpl = async (url: string): Promise<Response> => {
|
|
161
|
+
calls.push(url);
|
|
162
|
+
return new Response(JSON.stringify({ due: false, reason: "posted in the last 20h", summary: null }), { status: 200 });
|
|
163
|
+
};
|
|
164
|
+
const r = await fetchSummary("http://h", "omp", true, {}, fetchImpl);
|
|
165
|
+
expect(r).toEqual({ due: false, reason: "posted in the last 20h", summary: null });
|
|
166
|
+
expect(calls).toEqual(["http://h/v1/router/summary?harness=omp&auto=1"]);
|
|
167
|
+
await fetchSummary("http://h", "", false, {}, fetchImpl);
|
|
168
|
+
expect(calls[1]).toBe("http://h/v1/router/summary");
|
|
169
|
+
await expect(fetchSummary("http://h", "", false, {}, async () => new Response("no", { status: 503 }))).rejects.toThrow("router returned 503");
|
|
170
|
+
});
|
|
171
|
+
});
|
package/test/tokens.test.ts
CHANGED
|
@@ -237,3 +237,47 @@ describe("ledger.escalationCost", () => {
|
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
+
|
|
241
|
+
describe("ledger.softFailureSpikes", () => {
|
|
242
|
+
test("flags a model whose last-hour failure rate is a spike against its own 7-day baseline", () => {
|
|
243
|
+
const db = openDb(":memory:");
|
|
244
|
+
try {
|
|
245
|
+
const ledger = createLedger(db, cfg);
|
|
246
|
+
const now = 1_800_000_000_000;
|
|
247
|
+
const H = 3_600_000;
|
|
248
|
+
// Baseline: 100 dispatches over the prior week at 5% soft failures.
|
|
249
|
+
for (let i = 0; i < 100; i++) {
|
|
250
|
+
ledger.record(entry({ createdAtMs: now - 2 * H - i * 60 * 60_000, escalationSignal: i % 20 === 0 ? "empty_completion" : null, wasted: i % 20 === 0 }));
|
|
251
|
+
}
|
|
252
|
+
// Last hour: 10 dispatches, 4 soft failures (40%): a spike.
|
|
253
|
+
for (let i = 0; i < 10; i++) {
|
|
254
|
+
ledger.record(entry({ createdAtMs: now - 5 * 60_000 - i * 60_000, escalationSignal: i < 4 ? "repeat_tool_call" : null, wasted: i < 4 }));
|
|
255
|
+
}
|
|
256
|
+
// A second model with plenty of failures but a matching baseline is not spiking.
|
|
257
|
+
for (let i = 0; i < 100; i++) {
|
|
258
|
+
ledger.record(entry({ slug: "x/steady", servedSlug: "x/steady", createdAtMs: now - 2 * H - i * 60 * 60_000, error: i % 2 === 0 ? "upstream_error: 502" : null }));
|
|
259
|
+
}
|
|
260
|
+
for (let i = 0; i < 10; i++) {
|
|
261
|
+
ledger.record(entry({ slug: "x/steady", servedSlug: "x/steady", createdAtMs: now - 5 * 60_000 - i * 60_000, error: i % 2 === 0 ? "upstream_error: 502" : null }));
|
|
262
|
+
}
|
|
263
|
+
// Aborted and quota errors are not attributable; digest rows are side calls.
|
|
264
|
+
for (let i = 0; i < 10; i++) {
|
|
265
|
+
ledger.record(entry({ slug: "x/aborted", servedSlug: "x/aborted", createdAtMs: now - 5 * 60_000 - i * 60_000, error: "aborted: client closed" }));
|
|
266
|
+
ledger.record(entry({ slug: "x/digest", servedSlug: "x/digest", requestedModel: "digest", createdAtMs: now - 5 * 60_000 - i * 60_000, error: "upstream_error: 500" }));
|
|
267
|
+
}
|
|
268
|
+
const spikes = ledger.softFailureSpikes?.(now) ?? [];
|
|
269
|
+
expect(spikes.map((s) => s.slug)).toEqual(["openai/gpt-5-mini"]);
|
|
270
|
+
const s = spikes[0]!;
|
|
271
|
+
expect(s.recentDispatches).toBe(10);
|
|
272
|
+
expect(s.recentFailures).toBe(4);
|
|
273
|
+
expect(s.recentRate).toBeCloseTo(0.4, 6);
|
|
274
|
+
expect(s.baselineDispatches).toBe(100);
|
|
275
|
+
expect(s.baselineFailures).toBe(5);
|
|
276
|
+
expect(s.baselineRate).toBeCloseTo(0.05, 6);
|
|
277
|
+
// Too few recent dispatches: nothing spikes, however high the rate.
|
|
278
|
+
expect(ledger.softFailureSpikes?.(now, 3 * 60_000)).toEqual([]);
|
|
279
|
+
} finally {
|
|
280
|
+
db.close();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|