auto-model-router 0.4.3 → 0.4.5
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/.gitattributes +2 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +47 -1
- package/bunfig.toml +2 -0
- package/omp-extension/pi-coding-agent.d.ts +6 -0
- package/omp-extension/router-switch.ts +100 -0
- package/omp-extension/switch-logic.ts +81 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +4 -1
- package/src/cli/config-wizard.ts +9 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/hot-reload.ts +58 -9
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +27 -0
- package/src/cost/ledger.ts +9 -0
- package/src/cost/report.ts +26 -1
- package/src/cost/types.ts +4 -0
- package/src/router/compaction.ts +1 -1
- package/src/router/select.ts +22 -2
- package/src/server/advise.ts +82 -0
- package/src/server/compaction-digest.ts +2 -0
- package/src/server/digest.ts +55 -1
- package/src/server/http.ts +41 -6
- package/src/server/providers.ts +1 -0
- package/src/server/turn.ts +11 -0
- package/test/cache-control.test.ts +1 -1
- package/test/config-wizard.test.ts +1 -0
- package/test/digest.test.ts +22 -0
- package/test/failover.test.ts +2 -1
- package/test/harness-switch.test.ts +59 -0
- package/test/hot-reload.test.ts +37 -1
- package/test/migrations.test.ts +84 -0
- package/test/report-hub.test.ts +1 -1
- package/test/report.test.ts +29 -1
- package/test/select.test.ts +24 -0
- package/test/support/preload.ts +19 -0
- package/test/tokens.test.ts +24 -0
- package/test/turn.test.ts +2 -1
- package/tools/gen-migration-fixtures.ts +69 -0
package/src/router/select.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CatalogSnapshot } from "../catalog/types.ts";
|
|
10
10
|
import type { ProfileConfig, RouterConfig } from "../config/types.ts";
|
|
11
|
-
import { priceAt } from "../cost/forecast.ts";
|
|
11
|
+
import { forecast, priceAt } from "../cost/forecast.ts";
|
|
12
12
|
import type { Ledger } from "../cost/types.ts";
|
|
13
13
|
import { explorationDraw } from "./explore.ts";
|
|
14
14
|
import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
@@ -610,13 +610,33 @@ export function select(args: SelectArgs): Decision {
|
|
|
610
610
|
}
|
|
611
611
|
const stripAssistantReasoning = !(chosen.model.supportsReasoning && REASONING_REPLAY_AUTHORS[chosen.model.author] === true);
|
|
612
612
|
|
|
613
|
+
// The recorded forecast is the EXPECTED price of this dispatch, not the
|
|
614
|
+
// cold worst case candidates are ranked on. When the chosen model's cache
|
|
615
|
+
// is warm, the previous prompt's tokens are priced as cache reads at the
|
|
616
|
+
// model's measured hit rate; coldUsd stays the cold figure the budget
|
|
617
|
+
// guards used. Before this every recorded forecast was cold while nine
|
|
618
|
+
// turns in ten were warm: 89% over-predicted, median error 220%.
|
|
619
|
+
let expectedForecast = chosen.forecast;
|
|
620
|
+
if (warmSlug !== null && chosen.model.slug === warmSlug && effFeatures.promptTokens > 0 && state.lastPromptTokens > 0) {
|
|
621
|
+
const cachedShare = Math.min(1, state.lastPromptTokens / effFeatures.promptTokens);
|
|
622
|
+
let images = 0;
|
|
623
|
+
if (req.hasImages) for (const m of req.messages) images += m.images;
|
|
624
|
+
const warm = forecast(chosen.model, {
|
|
625
|
+
promptTokens: effFeatures.promptTokens,
|
|
626
|
+
completionTokens: EXPECTED_COMPLETION_TOKENS,
|
|
627
|
+
cacheHitRate: cacheHitExpectation(chosen.model.slug).rate * cachedShare,
|
|
628
|
+
images,
|
|
629
|
+
});
|
|
630
|
+
expectedForecast = { ...warm, coldUsd: chosen.forecast.coldUsd };
|
|
631
|
+
}
|
|
632
|
+
|
|
613
633
|
return {
|
|
614
634
|
slug: chosen.model.slug,
|
|
615
635
|
fallbacks,
|
|
616
636
|
tier: chosenTier,
|
|
617
637
|
classification: cls,
|
|
618
638
|
features,
|
|
619
|
-
forecast:
|
|
639
|
+
forecast: expectedForecast,
|
|
620
640
|
sessionId: state.sessionId,
|
|
621
641
|
sticky,
|
|
622
642
|
cacheBreakpointMessageIndices,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-side model switch: the router advises a tier for a prompt BEFORE
|
|
3
|
+
* the harness dispatches it, so the harness can move its active model to one
|
|
4
|
+
* the router cannot proxy (a Claude subscription model, say) for hard work
|
|
5
|
+
* and back to the router for the rest.
|
|
6
|
+
*
|
|
7
|
+
* Why this shape: proxying a subscription upstream would mean translating
|
|
8
|
+
* the OpenAI wire to that provider's and carrying its OAuth token through a
|
|
9
|
+
* third-party process. Advising is cheaper and keeps the token where it
|
|
10
|
+
* belongs. The cost of a native turn lands on the subscription, not the
|
|
11
|
+
* ledger; the router only sees the turns it serves.
|
|
12
|
+
*
|
|
13
|
+
* The advice is the heuristic classifier over the user's prompt text alone
|
|
14
|
+
* (the harness has not built the request yet), plus the session's last
|
|
15
|
+
* routed tier for context. No adjudicator call, no dispatch, nothing recorded.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
19
|
+
import type { Ledger } from "../cost/types.ts";
|
|
20
|
+
import { scoreHeuristic } from "../router/classify.ts";
|
|
21
|
+
import { extractFeatures } from "../router/features.ts";
|
|
22
|
+
import { estimateTokens } from "../tokens/estimate.ts";
|
|
23
|
+
import type { TaskType, Tier } from "../router/types.ts";
|
|
24
|
+
import type { NormRequest } from "../wire/types.ts";
|
|
25
|
+
|
|
26
|
+
export interface AdviseRequest {
|
|
27
|
+
ompSessionId: string;
|
|
28
|
+
harnessId: string;
|
|
29
|
+
/** The user's prompt as submitted. */
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Advice {
|
|
34
|
+
tier: Tier;
|
|
35
|
+
task: TaskType;
|
|
36
|
+
confidence: number;
|
|
37
|
+
score: number;
|
|
38
|
+
reasons: string[];
|
|
39
|
+
/** The tier this session's last routed turn ran at, when the router served one. */
|
|
40
|
+
lastTier: Tier | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SYSTEM_STUB = "You are a coding agent.";
|
|
44
|
+
|
|
45
|
+
function requestOf(req: AdviseRequest): NormRequest {
|
|
46
|
+
const text = req.text;
|
|
47
|
+
return {
|
|
48
|
+
protocol: "openai-chat",
|
|
49
|
+
conversationKey: `advise:${req.ompSessionId}`,
|
|
50
|
+
harnessId: req.harnessId,
|
|
51
|
+
ompSessionId: req.ompSessionId,
|
|
52
|
+
agentdoxScope: "",
|
|
53
|
+
isSubagent: false,
|
|
54
|
+
requestedModel: "auto",
|
|
55
|
+
messages: [
|
|
56
|
+
{ role: "system", text: SYSTEM_STUB, images: 0, textBytes: Buffer.byteLength(SYSTEM_STUB), toolCalls: [] },
|
|
57
|
+
{ role: "user", text, images: 0, textBytes: Buffer.byteLength(text), toolCalls: [] },
|
|
58
|
+
],
|
|
59
|
+
tools: [],
|
|
60
|
+
forcedToolChoice: false,
|
|
61
|
+
stream: false,
|
|
62
|
+
hasImages: false,
|
|
63
|
+
promptBytes: Buffer.byteLength(SYSTEM_STUB) + Buffer.byteLength(text),
|
|
64
|
+
renderUpstreamBody: () => ({}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Classifies a prompt the way the first turn of a conversation would be, without dispatching anything. */
|
|
69
|
+
export function advise(cfg: RouterConfig, ledger: Ledger | null, req: AdviseRequest): Advice {
|
|
70
|
+
const norm = requestOf(req);
|
|
71
|
+
const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", ledger));
|
|
72
|
+
const cls = scoreHeuristic(features, cfg);
|
|
73
|
+
const last = req.ompSessionId === "" ? null : (ledger?.latestForSession?.(req.ompSessionId)?.tier ?? null);
|
|
74
|
+
return {
|
|
75
|
+
tier: cls.tier,
|
|
76
|
+
task: cls.task,
|
|
77
|
+
confidence: cls.confidence,
|
|
78
|
+
score: cls.score,
|
|
79
|
+
reasons: cls.reasons,
|
|
80
|
+
lastTier: last === null ? null : (last as Tier),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -23,6 +23,8 @@ import type { DigestRequest, DigestResult } from "./digest.ts";
|
|
|
23
23
|
|
|
24
24
|
export interface CompactionDigester {
|
|
25
25
|
digest(req: DigestRequest): Promise<DigestResult>;
|
|
26
|
+
/** See Digester.noteToolCalls; optional so a fake need not implement it. */
|
|
27
|
+
noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface DigestCompactionArgs {
|
package/src/server/digest.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { DigestConfig, RouterConfig } from "../config/types.ts";
|
|
|
23
23
|
import { computeCost, forecast } from "../cost/forecast.ts";
|
|
24
24
|
import type { Ledger, LedgerEntry } from "../cost/types.ts";
|
|
25
25
|
import { buildCandidates } from "../router/candidates.ts";
|
|
26
|
+
import { primaryArg } from "../router/compaction.ts";
|
|
26
27
|
import { extractFeatures } from "../router/features.ts";
|
|
27
28
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
28
29
|
import { estimateTokens } from "../tokens/estimate.ts";
|
|
@@ -107,8 +108,31 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
|
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
/** A digest the agent may still go back on: same tool, same primary argument, within RERUN_WINDOW_MS. */
|
|
112
|
+
interface RecentDigest {
|
|
113
|
+
tool: string;
|
|
114
|
+
arg: string | null;
|
|
115
|
+
atMs: number;
|
|
116
|
+
ledgerId: string;
|
|
117
|
+
rerun: boolean;
|
|
118
|
+
}
|
|
119
|
+
const RERUN_WINDOW_MS = 2 * 3_600_000;
|
|
120
|
+
const RECENT_PER_SESSION = 50;
|
|
121
|
+
|
|
122
|
+
export interface Digester {
|
|
123
|
+
digest(req: DigestRequest): Promise<DigestResult>;
|
|
124
|
+
/**
|
|
125
|
+
* Quality signal: the tool calls a session just made. One that repeats a
|
|
126
|
+
* recent digest (same tool, same primary argument) means the agent went
|
|
127
|
+
* back for the full output; that digest's ledger row is marked wasted and
|
|
128
|
+
* the report shows the re-run rate. Returns how many were marked.
|
|
129
|
+
*/
|
|
130
|
+
noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createDigester(deps: DigesterDeps): Digester {
|
|
111
134
|
const { cfg, catalog, ledger, upstream, log } = deps;
|
|
135
|
+
const recent = new Map<string, RecentDigest[]>();
|
|
112
136
|
|
|
113
137
|
/** Cheapest simple-tier model that fits the prompt, or the configured one. */
|
|
114
138
|
async function pickModel(req: NormRequest, promptTokens: number): Promise<CatalogModel | null> {
|
|
@@ -229,6 +253,11 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
229
253
|
}
|
|
230
254
|
if (error !== null) return { digested: false, reason: `digest model failed: ${error}` };
|
|
231
255
|
if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
|
|
256
|
+
if (req.ompSessionId !== "") {
|
|
257
|
+
const list = recent.get(req.ompSessionId) ?? [];
|
|
258
|
+
list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false });
|
|
259
|
+
recent.set(req.ompSessionId, list.slice(-RECENT_PER_SESSION));
|
|
260
|
+
}
|
|
232
261
|
return {
|
|
233
262
|
digested: true,
|
|
234
263
|
text: `${digestMarker(req.toolName, req.input, model.slug, inputBytes, text.length)}\n${text}`,
|
|
@@ -239,5 +268,30 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
239
268
|
ms,
|
|
240
269
|
};
|
|
241
270
|
},
|
|
271
|
+
noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
|
|
272
|
+
const list = recent.get(ompSessionId);
|
|
273
|
+
if (list === undefined || list.length === 0) return 0;
|
|
274
|
+
let marked = 0;
|
|
275
|
+
for (const c of calls) {
|
|
276
|
+
const tool = c.name.toLowerCase();
|
|
277
|
+
const arg = primaryArg(c.argsJson);
|
|
278
|
+
if (arg === null) continue;
|
|
279
|
+
for (const d of list) {
|
|
280
|
+
if (d.rerun || d.tool !== tool || d.arg !== arg || nowMs - d.atMs > RERUN_WINDOW_MS) continue;
|
|
281
|
+
d.rerun = true;
|
|
282
|
+
marked++;
|
|
283
|
+
try {
|
|
284
|
+
ledger.markWasted?.(d.ledgerId);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
|
|
287
|
+
}
|
|
288
|
+
log.info("digest re-run: the agent fetched the full output after all", { tool, arg: arg.slice(0, 80) });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const kept = list.filter((d) => nowMs - d.atMs <= RERUN_WINDOW_MS);
|
|
292
|
+
if (kept.length === 0) recent.delete(ompSessionId);
|
|
293
|
+
else recent.set(ompSessionId, kept);
|
|
294
|
+
return marked;
|
|
295
|
+
},
|
|
242
296
|
};
|
|
243
297
|
}
|
package/src/server/http.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
|
|
|
7
7
|
import { createLedger } from "../cost/ledger.ts";
|
|
8
8
|
import { createSessionOverrides } from "./overrides.ts";
|
|
9
9
|
import { createDigester } from "./digest.ts";
|
|
10
|
+
import { advise } from "./advise.ts";
|
|
10
11
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
11
12
|
import { baselinePrices, buildUsageReport } from "../cost/report.ts";
|
|
12
13
|
import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
|
|
@@ -17,7 +18,7 @@ import { UpstreamError } from "../upstream/types.ts";
|
|
|
17
18
|
import { apiKeySource, ollamaKeySource } from "../config/load.ts";
|
|
18
19
|
import { ollamaMeter } from "../upstream/ollama-usage.ts";
|
|
19
20
|
import { routerConfigPath } from "../cli/config-cmd.ts";
|
|
20
|
-
import { watchConfig } from "../config/hot-reload.ts";
|
|
21
|
+
import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
|
|
21
22
|
import type { RouterConfig } from "../config/types.ts";
|
|
22
23
|
import { createLogger } from "../util/log.ts";
|
|
23
24
|
import { openDb } from "../util/sqlite.ts";
|
|
@@ -202,15 +203,16 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
202
203
|
|
|
203
204
|
// Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
|
|
204
205
|
// effect on the next turn without a restart, because every consumer reads
|
|
205
|
-
// the shared config object at call time. Construction-captured
|
|
206
|
-
// (server socket,
|
|
207
|
-
//
|
|
208
|
-
|
|
206
|
+
// the shared config object at call time. Construction-captured settings
|
|
207
|
+
// (server socket, upstream clients, agentdox bridge, ledger file) are
|
|
208
|
+
// pinned by path (PINNED_CONFIG_PATHS); editing those still requires a
|
|
209
|
+
// restart. The blocks are deep-copied so a reload cannot mutate the pin.
|
|
210
|
+
const pinned = structuredClone(cfg);
|
|
209
211
|
const configWatcher = watchConfig(
|
|
210
212
|
routerConfigPath(),
|
|
211
213
|
cfg,
|
|
212
214
|
pinned,
|
|
213
|
-
|
|
215
|
+
PINNED_CONFIG_PATHS,
|
|
214
216
|
{
|
|
215
217
|
onReload: ({ changed }) => {
|
|
216
218
|
log.info("config reloaded", { changed: changed.join(", ") });
|
|
@@ -269,6 +271,20 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
269
271
|
}, 60_000);
|
|
270
272
|
pruneTimer.unref();
|
|
271
273
|
|
|
274
|
+
// Ledger retention: hourly, and once at boot so a lowered setting takes
|
|
275
|
+
// effect without waiting. Reads the live config, so it hot-reloads.
|
|
276
|
+
const retain = (): void => {
|
|
277
|
+
try {
|
|
278
|
+
const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
|
|
279
|
+
if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const retentionTimer = setInterval(retain, 3_600_000);
|
|
285
|
+
retentionTimer.unref();
|
|
286
|
+
setTimeout(retain, 5_000).unref();
|
|
287
|
+
|
|
272
288
|
// Periodically refetch the (key-scoped) catalog in the background so
|
|
273
289
|
// guardrail/preference changes are picked up without needing traffic and a
|
|
274
290
|
// TTL expiry. catalogRefreshMs === 0 disables this.
|
|
@@ -400,6 +416,25 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
400
416
|
const harnessId = url.searchParams.get("harness") ?? "";
|
|
401
417
|
return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
|
|
402
418
|
}
|
|
419
|
+
if (req.method === "GET" && url.pathname === "/v1/router/advise/policy") {
|
|
420
|
+
const h = cfg.harnessSwitch;
|
|
421
|
+
return json({ enabled: h.enabled, models: h.models, minConfidence: h.minConfidence });
|
|
422
|
+
}
|
|
423
|
+
if (req.method === "POST" && url.pathname === "/v1/router/advise") {
|
|
424
|
+
// Harness-side switch: classify a prompt before the harness builds
|
|
425
|
+
// the request. Heuristic only; nothing is dispatched or recorded.
|
|
426
|
+
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
427
|
+
if (body === null || typeof body.text !== "string") {
|
|
428
|
+
return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "text required" });
|
|
429
|
+
}
|
|
430
|
+
return json(
|
|
431
|
+
advise(cfg, ledger, {
|
|
432
|
+
ompSessionId: typeof body.ompSessionId === "string" ? body.ompSessionId : "",
|
|
433
|
+
harnessId: typeof body.harnessId === "string" ? body.harnessId : "",
|
|
434
|
+
text: body.text.slice(0, 16_000),
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
403
438
|
if (req.method === "GET" && url.pathname === "/v1/router/summary") {
|
|
404
439
|
// The last 24 hours in a few lines. `auto=1` is the session-start
|
|
405
440
|
// caller: it gets `due: false` unless report.dailySummary is on, no
|
package/src/server/providers.ts
CHANGED
|
@@ -54,6 +54,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
54
54
|
costBias: cfg.ollama.costBias,
|
|
55
55
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
56
56
|
usage: ollamaUsage,
|
|
57
|
+
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
57
58
|
}),
|
|
58
59
|
ollama,
|
|
59
60
|
ollamaUsage,
|
package/src/server/turn.ts
CHANGED
|
@@ -114,6 +114,17 @@ export async function runTurn(
|
|
|
114
114
|
const log = createLogger(config.logLevel);
|
|
115
115
|
const state = conversations.load(req.conversationKey);
|
|
116
116
|
const turnNumber = state.turn + 1;
|
|
117
|
+
// Digest quality signal: the calls the agent just made, matched against
|
|
118
|
+
// recent digests of this session (a re-run of a digested read means the
|
|
119
|
+
// digest was not enough). The last assistant message holds this turn's calls.
|
|
120
|
+
if (deps.digester?.noteToolCalls !== undefined && req.ompSessionId !== "") {
|
|
121
|
+
for (let i = req.messages.length - 1; i >= 0; i--) {
|
|
122
|
+
const m = req.messages[i];
|
|
123
|
+
if (m === undefined || m.role !== "assistant") continue;
|
|
124
|
+
if (m.toolCalls.length > 0) deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
117
128
|
// Request header wins; the configured default covers harnesses that send none.
|
|
118
129
|
const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
|
|
119
130
|
const doxActive = bridge.enabled && doxScope !== "";
|
|
@@ -93,7 +93,7 @@ describe("planCacheBreakpoints", () => {
|
|
|
93
93
|
test("milestones follow post-compaction sizes", () => {
|
|
94
94
|
const req = loop(30);
|
|
95
95
|
const tail = req.messages.length - 1;
|
|
96
|
-
const plan = planCompaction(req.messages, BASE.compaction, req.promptBytes * 0.3, req.promptBytes);
|
|
96
|
+
const plan = planCompaction(req.messages, { ...BASE.compaction, enabled: true }, req.promptBytes * 0.3, req.promptBytes);
|
|
97
97
|
expect(plan.edits.length).toBeGreaterThan(0);
|
|
98
98
|
const options = cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 });
|
|
99
99
|
const raw = planCacheBreakpoints(req, MODEL, options).filter((i) => i !== 0 && i !== tail);
|
|
@@ -172,6 +172,7 @@ describe("WIZARD_SECTIONS coverage", () => {
|
|
|
172
172
|
]),
|
|
173
173
|
...["coding", "vision", "documentation", "data", "chat"].flatMap((t) => [`tasks.${t}.minQuality`, `tasks.${t}.requireImage`, `tasks.${t}.prefer`]),
|
|
174
174
|
...["trivial", "simple", "moderate", "hard"].map((t) => `exploration.rates.${t}`),
|
|
175
|
+
...["trivial", "simple", "moderate", "hard"].map((t) => `harnessSwitch.models.${t}`),
|
|
175
176
|
]);
|
|
176
177
|
const known = new Set(leaves(DEFAULT_CONFIG));
|
|
177
178
|
const unknown = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path)).filter((p) => !known.has(p) && !KNOWN_OPTIONAL.has(p));
|
package/test/digest.test.ts
CHANGED
|
@@ -184,6 +184,28 @@ describe("createDigester", () => {
|
|
|
184
184
|
db.close();
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
test("a later call of the same tool with the same primary argument marks the digest wasted", async () => {
|
|
188
|
+
const cfg = cfgWith();
|
|
189
|
+
const db = openDb(":memory:");
|
|
190
|
+
const ledger = createLedger(db, cfg);
|
|
191
|
+
seedSession(ledger, "hard");
|
|
192
|
+
const dg = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "Condensed.").upstream, log });
|
|
193
|
+
const r = await dg.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/a.ts", offset: 1 }, content: BIG, query: "" });
|
|
194
|
+
expect(r.digested).toBe(true);
|
|
195
|
+
const row = () => ledger.recentEntries(10).find((e) => e.requestedModel === "digest")!;
|
|
196
|
+
expect(row().wasted).toBe(false);
|
|
197
|
+
// A different file, a different tool, another session: no match.
|
|
198
|
+
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/b.ts"}' }, { name: "grep", argsJson: '{"pattern":"src/a.ts"}' }])).toBe(0);
|
|
199
|
+
expect(dg.noteToolCalls("omp-2", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
200
|
+
expect(row().wasted).toBe(false);
|
|
201
|
+
// The same read again (case-insensitive tool name, any other args): the agent wanted the full output.
|
|
202
|
+
expect(dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
|
|
203
|
+
expect(row().wasted).toBe(true);
|
|
204
|
+
// Marked once; a third read does not count again.
|
|
205
|
+
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
206
|
+
db.close();
|
|
207
|
+
});
|
|
208
|
+
|
|
187
209
|
test("a pinned digest model is used as-is", async () => {
|
|
188
210
|
const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
|
|
189
211
|
const cfg = cfgWith({ model: pinned });
|
package/test/failover.test.ts
CHANGED
|
@@ -77,9 +77,10 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
77
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
79
|
report: { baselines: [], dailySummary: false },
|
|
80
|
+
harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
|
|
80
81
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
|
|
81
82
|
profiles: [],
|
|
82
|
-
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
83
|
+
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
|
|
83
84
|
adaptiveTierFloors: true,
|
|
84
85
|
adaptivePriceCeilings: false,
|
|
85
86
|
logLevel: "silent",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
4
|
+
import { TIER_ORDER } from "../src/router/types.ts";
|
|
5
|
+
import { advise } from "../src/server/advise.ts";
|
|
6
|
+
import { decideSwitch, nativeModelFor, parseSwitchPolicy, type SwitchPolicy } from "../omp-extension/switch-logic.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Harness-side model switch: the router's prompt-only advice, and the
|
|
10
|
+
* extension's decision about moving omp's active model.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
describe("advise", () => {
|
|
14
|
+
test("classifies a prompt without a ledger or dispatch and reports the shape the extension reads", () => {
|
|
15
|
+
const a = advise(DEFAULT_CONFIG, null, { ompSessionId: "s", harnessId: "", text: "Redesign the routing pipeline so escalation and failover share one retry loop; consider cache costs and write the migration plan." });
|
|
16
|
+
expect(TIER_ORDER).toContain(a.tier);
|
|
17
|
+
expect(a.confidence).toBeGreaterThanOrEqual(0);
|
|
18
|
+
expect(a.confidence).toBeLessThanOrEqual(1);
|
|
19
|
+
expect(a.reasons.length).toBeGreaterThan(0);
|
|
20
|
+
expect(a.lastTier).toBeNull();
|
|
21
|
+
const terse = advise(DEFAULT_CONFIG, null, { ompSessionId: "", harnessId: "", text: "ok" });
|
|
22
|
+
expect(TIER_ORDER.indexOf(terse.tier)).toBeLessThanOrEqual(TIER_ORDER.indexOf(a.tier));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("switch policy", () => {
|
|
27
|
+
test("parses defensively and maps a tier to the nearest configured tier at or below it", () => {
|
|
28
|
+
expect(parseSwitchPolicy(null).enabled).toBe(false);
|
|
29
|
+
const p = parseSwitchPolicy({ enabled: true, models: { moderate: "anthropic/claude-sonnet-5", hard: "anthropic/claude-opus-4-8", simple: "no-slash" }, minConfidence: 0.5 });
|
|
30
|
+
expect(p.models).toEqual({ moderate: "anthropic/claude-sonnet-5", hard: "anthropic/claude-opus-4-8" });
|
|
31
|
+
expect(nativeModelFor(p, "hard")).toBe("anthropic/claude-opus-4-8");
|
|
32
|
+
expect(nativeModelFor(p, "moderate")).toBe("anthropic/claude-sonnet-5");
|
|
33
|
+
expect(nativeModelFor(p, "simple")).toBeUndefined();
|
|
34
|
+
expect(nativeModelFor(p, "trivial")).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("decideSwitch", () => {
|
|
39
|
+
const policy: SwitchPolicy = { enabled: true, models: { hard: "anthropic/claude-opus-4-8" }, minConfidence: 0.6 };
|
|
40
|
+
const router = "auto-model-router/auto";
|
|
41
|
+
|
|
42
|
+
test("moves up from the router for confident hard work, back for lighter work, and never past a manual choice", () => {
|
|
43
|
+
const up = decideSwitch({ policy, advised: { tier: "hard", confidence: 0.8 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null });
|
|
44
|
+
expect(up).toMatchObject({ action: "up", model: "anthropic/claude-opus-4-8" });
|
|
45
|
+
// Low confidence: stay.
|
|
46
|
+
expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.3 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
|
|
47
|
+
// Unmapped tier while on the router: stay.
|
|
48
|
+
expect(decideSwitch({ policy, advised: { tier: "moderate", confidence: 0.9 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
|
|
49
|
+
// On our own switch, hard again: already there.
|
|
50
|
+
expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.9 }, active: "anthropic/claude-opus-4-8", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router }).action).toBe("none");
|
|
51
|
+
// On our own switch, lighter work: back to the router model we left.
|
|
52
|
+
expect(decideSwitch({ policy, advised: { tier: "simple", confidence: 0.9 }, active: "anthropic/claude-opus-4-8", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router })).toMatchObject({ action: "back", model: router });
|
|
53
|
+
// The user chose a model by hand: leave it alone whatever the advice.
|
|
54
|
+
expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.9 }, active: "openai/gpt-5", activeIsRouter: false, switchedTo: null, returnTo: null }).action).toBe("none");
|
|
55
|
+
expect(decideSwitch({ policy, advised: { tier: "trivial", confidence: 0.9 }, active: "openai/gpt-5", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router }).action).toBe("none");
|
|
56
|
+
// Disabled: nothing moves.
|
|
57
|
+
expect(decideSwitch({ policy: { ...policy, enabled: false }, advised: { tier: "hard", confidence: 0.9 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
|
|
58
|
+
});
|
|
59
|
+
});
|
package/test/hot-reload.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
5
5
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
6
|
-
import { readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
|
|
6
|
+
import { PINNED_CONFIG_PATHS, readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
|
|
7
7
|
|
|
8
8
|
const DIR = join(import.meta.dir, ".tmp-hot-reload");
|
|
9
9
|
const CFG = join(DIR, "config.yml");
|
|
@@ -129,3 +129,39 @@ describe("watchConfig", () => {
|
|
|
129
129
|
expect(live.filters.latencyWeight).not.toBe(9.9);
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("watchConfig pins by path", () => {
|
|
134
|
+
const CFG2 = join(DIR, "config-paths.yml");
|
|
135
|
+
const live: RouterConfig = structuredClone(DEFAULT_CONFIG);
|
|
136
|
+
let watcher: ConfigWatcher | null = null;
|
|
137
|
+
|
|
138
|
+
beforeAll(() => {
|
|
139
|
+
writeFileSync(CFG2, "");
|
|
140
|
+
const pinned = structuredClone(DEFAULT_CONFIG);
|
|
141
|
+
pinned.ollama.apiKey = "pinned-key";
|
|
142
|
+
watcher = watchConfig(CFG2, live, pinned, PINNED_CONFIG_PATHS);
|
|
143
|
+
});
|
|
144
|
+
afterAll(() => watcher?.close());
|
|
145
|
+
|
|
146
|
+
test("a pinned key inside a block keeps its construction value while its siblings hot-reload", async () => {
|
|
147
|
+
writeFileSync(CFG2, yamlOf({ ollama: { apiKey: "from-file", costBias: 0.25, biasUntilUsage: 0.5 }, server: { port: 1, subagentProfile: "auto" }, ledger: { retentionDays: 30 } }));
|
|
148
|
+
await settle();
|
|
149
|
+
expect(live.ollama.costBias).toBe(0.25);
|
|
150
|
+
expect(live.ollama.biasUntilUsage).toBe(0.5);
|
|
151
|
+
expect(live.ollama.apiKey).toBe("pinned-key");
|
|
152
|
+
expect(live.server.port).toBe(DEFAULT_CONFIG.server.port);
|
|
153
|
+
expect(live.server.subagentProfile).toBe("auto");
|
|
154
|
+
expect(live.ledger.retentionDays).toBe(30);
|
|
155
|
+
expect(live.ledger.path).toBe(DEFAULT_CONFIG.ledger.path);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("the pinned path list names only real config keys", () => {
|
|
159
|
+
const root = DEFAULT_CONFIG as unknown as Record<string, Record<string, unknown>>;
|
|
160
|
+
for (const p of PINNED_CONFIG_PATHS) {
|
|
161
|
+
const [block = "", key] = p.split(".");
|
|
162
|
+
expect(block in root).toBe(true);
|
|
163
|
+
// Optional keys (server.apiKey, server.harnessId) are absent from the defaults but real.
|
|
164
|
+
if (key !== undefined && !["apiKey", "harnessId"].includes(key)) expect(key in root[block]!).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
7
|
+
import { createFeedbackStore } from "../src/cost/feedback.ts";
|
|
8
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
9
|
+
import { buildUsageReport } from "../src/cost/report.ts";
|
|
10
|
+
import { buildDailySummary, createKv } from "../src/cost/summary.ts";
|
|
11
|
+
import { createConversationStore } from "../src/router/state.ts";
|
|
12
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every ledger a past release wrote must open under the current bootstrap:
|
|
16
|
+
* the version lands on the current number, every prepared statement the
|
|
17
|
+
* router uses compiles against the migrated schema, the carried rows survive
|
|
18
|
+
* with their backfills, and the aggregates run. The fixtures come from
|
|
19
|
+
* tools/gen-migration-fixtures.ts (each tag's own bootstrap plus one row per
|
|
20
|
+
* table), so a column added without a guard, or a statement that assumes a
|
|
21
|
+
* column older files lack, fails here rather than on a user's install.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const FIXTURES = join(import.meta.dir, "fixtures", "migrations");
|
|
25
|
+
const files = readdirSync(FIXTURES).filter((f) => /^router-v\d+\.db$/.test(f)).sort((a, b) => Number(/\d+/.exec(a)![0]) - Number(/\d+/.exec(b)![0]));
|
|
26
|
+
const CURRENT_VERSION = 17;
|
|
27
|
+
|
|
28
|
+
describe("schema migrations from every shipped version", () => {
|
|
29
|
+
test("fixtures exist for the versions that shipped", () => {
|
|
30
|
+
expect(files.map((f) => Number(/\d+/.exec(f)![0]))).toEqual([4, 5, 10, 12, 13, 14, 16]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const from = Number(/\d+/.exec(file)![0]);
|
|
35
|
+
test(`v${from} → v${CURRENT_VERSION}: opens, migrates, keeps its rows, and every consumer runs`, () => {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-migrate-"));
|
|
37
|
+
const path = join(dir, "router.db");
|
|
38
|
+
copyFileSync(join(FIXTURES, file), path);
|
|
39
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
40
|
+
cfg.ledger.path = path;
|
|
41
|
+
const db = openDb(path);
|
|
42
|
+
try {
|
|
43
|
+
expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
|
|
44
|
+
// Every column the current code writes exists after migration.
|
|
45
|
+
const ledgerCols = new Set((db.query("PRAGMA table_info(ledger)").all() as { name: string }[]).map((c) => c.name));
|
|
46
|
+
for (const c of ["harness_id", "error_kind", "omp_session_id", "features", "explored_from", "hold_arm", "prompt_tokens_saved"]) expect(ledgerCols.has(c)).toBe(true);
|
|
47
|
+
const convCols = new Set((db.query("PRAGMA table_info(conversations)").all() as { name: string }[]).map((c) => c.name));
|
|
48
|
+
for (const c of ["context_version", "compaction_plan", "compaction_plan_tokens", "upgrade_deferred_tier"]) expect(convCols.has(c)).toBe(true);
|
|
49
|
+
// The fixture's ledger row survived the ALTERs with its values.
|
|
50
|
+
const row = db.query("SELECT id, error, slug FROM ledger").get() as { id: string; error: string | null; slug: string } | null;
|
|
51
|
+
expect(row).toEqual({ id: "fixture-id", error: "upstream_error: 502", slug: "fixture-slug" });
|
|
52
|
+
// Every prepared statement compiles and every consumer runs on the migrated file.
|
|
53
|
+
const ledger = createLedger(db, cfg);
|
|
54
|
+
const conversations = createConversationStore(db);
|
|
55
|
+
createFeedbackStore(db);
|
|
56
|
+
createKv(db);
|
|
57
|
+
expect(ledger.recentEntries(5)).toHaveLength(1);
|
|
58
|
+
expect(ledger.trust("fixture-slug")).not.toBeNull();
|
|
59
|
+
expect(ledger.softFailureSpikes?.()).toEqual([]);
|
|
60
|
+
expect(ledger.latestForSession?.("nope")).toBeNull();
|
|
61
|
+
expect(conversations.load("fixture-key").key).toBe("fixture-key");
|
|
62
|
+
expect(buildUsageReport(db, { windowDays: 3650 }).totals.dispatches).toBe(1);
|
|
63
|
+
expect(buildDailySummary(db, {}).current.dispatches).toBe(0);
|
|
64
|
+
expect(ledger.prune?.(0)).toBe(0);
|
|
65
|
+
} finally {
|
|
66
|
+
db.close();
|
|
67
|
+
try {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// Windows keeps the file locked until the statements are collected; the temp dir is disposable.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("a fresh database lands on the same version as a migrated one", () => {
|
|
77
|
+
const db = openDb(":memory:");
|
|
78
|
+
try {
|
|
79
|
+
expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
|
|
80
|
+
} finally {
|
|
81
|
+
db.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
package/test/report-hub.test.ts
CHANGED
|
@@ -80,7 +80,7 @@ function report(over: Partial<UsageReport> = {}): UsageReport {
|
|
|
80
80
|
subagentSpendUsd: 0,
|
|
81
81
|
digests: 0,
|
|
82
82
|
digestSpendUsd: 0,
|
|
83
|
-
digestInputTokens: 0,
|
|
83
|
+
digestInputTokens: 0, digestReruns: 0, forecastSamples: 0, forecastMeanError: 0, forecastOverShare: 0,
|
|
84
84
|
},
|
|
85
85
|
providers: [row("openrouter", 2), row("ollama", 1)],
|
|
86
86
|
models: [
|
package/test/report.test.ts
CHANGED
|
@@ -229,7 +229,7 @@ describe("buildUsageReport", () => {
|
|
|
229
229
|
subagentSpendUsd: 0,
|
|
230
230
|
digests: 0,
|
|
231
231
|
digestSpendUsd: 0,
|
|
232
|
-
digestInputTokens: 0,
|
|
232
|
+
digestInputTokens: 0, digestReruns: 0, forecastSamples: 0, forecastMeanError: 0, forecastOverShare: 0,
|
|
233
233
|
});
|
|
234
234
|
expect(r.providers).toEqual([]);
|
|
235
235
|
expect(r.models).toEqual([]);
|
|
@@ -291,3 +291,31 @@ describe("renderUsageReport", () => {
|
|
|
291
291
|
db.close();
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
describe("digest re-runs and forecast accuracy", () => {
|
|
296
|
+
test("wasted digest rows count as re-runs; forecast error is judged on clean kept rows only", () => {
|
|
297
|
+
const { db, ledger } = seeded();
|
|
298
|
+
try {
|
|
299
|
+
ledger.record(entry({ requestedModel: "digest", conversationKey: "d1", reportedUsd: 0.001, predictedUsd: 0.001 }));
|
|
300
|
+
ledger.record(entry({ requestedModel: "digest", conversationKey: "d2", reportedUsd: 0.001, predictedUsd: 0.001, wasted: true }));
|
|
301
|
+
// Two clean turns: one predicted double, one predicted half.
|
|
302
|
+
ledger.record(entry({ predictedUsd: 0.02, reportedUsd: 0.01 }));
|
|
303
|
+
ledger.record(entry({ predictedUsd: 0.005, reportedUsd: 0.01 }));
|
|
304
|
+
// Excluded from the forecast judgement: wasted, errored, no reported cost.
|
|
305
|
+
ledger.record(entry({ predictedUsd: 1, reportedUsd: 0.01, wasted: true }));
|
|
306
|
+
ledger.record(entry({ predictedUsd: 1, reportedUsd: 0.01, error: "upstream_error: 500" }));
|
|
307
|
+
ledger.record(entry({ predictedUsd: 1, reportedUsd: null }));
|
|
308
|
+
const t = buildUsageReport(db, { windowDays: 1, nowMs: NOW }).totals;
|
|
309
|
+
expect(t.digests).toBe(2);
|
|
310
|
+
expect(t.digestReruns).toBe(1);
|
|
311
|
+
expect(t.forecastSamples).toBe(2);
|
|
312
|
+
expect(t.forecastMeanError).toBeCloseTo((1 + 0.5) / 2, 6);
|
|
313
|
+
expect(t.forecastOverShare).toBeCloseTo(0.5, 6);
|
|
314
|
+
const text = renderUsageReport(buildUsageReport(db, { windowDays: 1, nowMs: NOW }));
|
|
315
|
+
expect(text).toContain("re-run rate 50% (1 fetched again in full)");
|
|
316
|
+
expect(text).toContain("forecast: mean error 75% of reported cost over 2 turns · 50% over-predicted");
|
|
317
|
+
} finally {
|
|
318
|
+
db.close();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|