auto-model-router 0.31.0 → 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/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 +101 -41
- 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 +13 -13
- 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/cost.test.ts
CHANGED
|
@@ -23,12 +23,12 @@ const SONNET = model("anthropic/claude-sonnet-4.5"); // publishes cache prices +
|
|
|
23
23
|
const ALL = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
|
|
24
24
|
|
|
25
25
|
describe("priceAt", () => {
|
|
26
|
-
test("returns the base price below every override threshold", () => {
|
|
26
|
+
test("returns the base price below every override threshold", async () => {
|
|
27
27
|
expect(priceAt(SONNET, 1000).prompt).toBe(SONNET.price.prompt);
|
|
28
28
|
expect(priceAt(SONNET, 199_999).prompt).toBe(SONNET.price.prompt);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test("crosses into the long-context tier at the threshold", () => {
|
|
31
|
+
test("crosses into the long-context tier at the threshold", async () => {
|
|
32
32
|
const tier = SONNET.priceTiers[0];
|
|
33
33
|
expect(tier).toBeDefined();
|
|
34
34
|
if (tier === undefined) return;
|
|
@@ -36,7 +36,7 @@ describe("priceAt", () => {
|
|
|
36
36
|
expect(priceAt(SONNET, tier.minPromptTokens + 1).prompt).toBeGreaterThan(SONNET.price.prompt);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
test("a long conversation is dearer per token than a short one", () => {
|
|
39
|
+
test("a long conversation is dearer per token than a short one", async () => {
|
|
40
40
|
// The whole reason override tiers are modelled: ignoring them
|
|
41
41
|
// underestimates long-session cost by roughly half.
|
|
42
42
|
const short = computeCost(SONNET, usage({ promptTokens: 50_000, completionTokens: 1000 }));
|
|
@@ -48,7 +48,7 @@ describe("priceAt", () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
describe("computeCost", () => {
|
|
51
|
-
test("components sum to the reported total", () => {
|
|
51
|
+
test("components sum to the reported total", async () => {
|
|
52
52
|
const b = computeCost(
|
|
53
53
|
SONNET,
|
|
54
54
|
usage({ promptTokens: 10_000, cachedTokens: 6000, cacheWriteTokens: 1000, completionTokens: 500, reasoningTokens: 200, images: 2 }),
|
|
@@ -57,7 +57,7 @@ describe("computeCost", () => {
|
|
|
57
57
|
expect(sum).toBeCloseTo(b.total, 12);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test("prompt_tokens already includes cached tokens, so they are not billed twice", () => {
|
|
60
|
+
test("prompt_tokens already includes cached tokens, so they are not billed twice", async () => {
|
|
61
61
|
// 10k prompt of which 10k cached must cost far less than 10k fresh,
|
|
62
62
|
// and must not be billed as 20k.
|
|
63
63
|
const allFresh = computeCost(SONNET, usage({ promptTokens: 10_000 }));
|
|
@@ -70,7 +70,7 @@ describe("computeCost", () => {
|
|
|
70
70
|
expect(allCached.cacheRead).toBeCloseTo(10_000 * cacheRead, 12);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
test("cache reads are cheaper than fresh prompt tokens wherever published", () => {
|
|
73
|
+
test("cache reads are cheaper than fresh prompt tokens wherever published", async () => {
|
|
74
74
|
let checked = 0;
|
|
75
75
|
for (const m of ALL) {
|
|
76
76
|
const read = m.price.cacheRead;
|
|
@@ -83,7 +83,7 @@ describe("computeCost", () => {
|
|
|
83
83
|
expect(checked).toBeGreaterThan(0);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("reasoning tokens are a subset of completion tokens and never double-billed", () => {
|
|
86
|
+
test("reasoning tokens are a subset of completion tokens and never double-billed", async () => {
|
|
87
87
|
const withReasoning = computeCost(SONNET, usage({ completionTokens: 1000, reasoningTokens: 400 }));
|
|
88
88
|
const withoutReasoning = computeCost(SONNET, usage({ completionTokens: 1000 }));
|
|
89
89
|
// Sonnet publishes no separate reasoning rate, so 1000 completion tokens
|
|
@@ -94,14 +94,14 @@ describe("computeCost", () => {
|
|
|
94
94
|
expect(withReasoning.total).toBeLessThan(inflated.total);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
test("zero usage costs nothing beyond any flat per-request fee", () => {
|
|
97
|
+
test("zero usage costs nothing beyond any flat per-request fee", async () => {
|
|
98
98
|
const b = computeCost(SONNET, EMPTY_USAGE);
|
|
99
99
|
expect(b.total).toBe(b.request);
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
describe("forecast", () => {
|
|
104
|
-
test("cold is never cheaper than expected, for every model in the catalog", () => {
|
|
104
|
+
test("cold is never cheaper than expected, for every model in the catalog", async () => {
|
|
105
105
|
// A budget guard checks the cold number, so this ordering is load-bearing:
|
|
106
106
|
// several models publish a cache-write rate BELOW their prompt rate, so
|
|
107
107
|
// the honest worst case is the max of "no cache" and "full cache write".
|
|
@@ -113,14 +113,14 @@ describe("forecast", () => {
|
|
|
113
113
|
}
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
test("a higher assumed cache hit rate lowers the expected cost", () => {
|
|
116
|
+
test("a higher assumed cache hit rate lowers the expected cost", async () => {
|
|
117
117
|
const cold = forecast(SONNET, { promptTokens: 50_000, completionTokens: 500, cacheHitRate: 0, images: 0 });
|
|
118
118
|
const warm = forecast(SONNET, { promptTokens: 50_000, completionTokens: 500, cacheHitRate: 0.9, images: 0 });
|
|
119
119
|
expect(warm.expectedUsd).toBeLessThan(cold.expectedUsd);
|
|
120
120
|
expect(warm.assumedCacheHitRate).toBeCloseTo(0.9, 12);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
test("records the assumptions it was given", () => {
|
|
123
|
+
test("records the assumptions it was given", async () => {
|
|
124
124
|
const f = forecast(SONNET, { promptTokens: 1234, completionTokens: 567, cacheHitRate: 0.25, images: 3 });
|
|
125
125
|
expect(f.slug).toBe(SONNET.slug);
|
|
126
126
|
expect(f.assumedPromptTokens).toBe(1234);
|
|
@@ -128,7 +128,7 @@ describe("forecast", () => {
|
|
|
128
128
|
expect(f.expectedUsd).toBeGreaterThan(0);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
test("a cheap model forecasts below an expensive one for identical work", () => {
|
|
131
|
+
test("a cheap model forecasts below an expensive one for identical work", async () => {
|
|
132
132
|
const cheap = model("openai/gpt-5-nano");
|
|
133
133
|
const dear = model("openai/gpt-5-pro");
|
|
134
134
|
const args = { promptTokens: 20_000, completionTokens: 1000, cacheHitRate: 0, images: 0 };
|
package/test/digest.test.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AsyncLedger } from "../src/cost/types.ts";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { migrateStore } from "../src/util/schema.ts";
|
|
7
|
+
import { openSqlDb } from "../src/util/sql.ts";
|
|
2
8
|
|
|
3
9
|
import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
4
10
|
import type { CatalogModel, CatalogSnapshot, CatalogSource } from "../src/catalog/types.ts";
|
|
5
11
|
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
6
12
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
7
|
-
import {
|
|
13
|
+
import { createSqlLedger } from "../src/cost/ledger-sql.ts";
|
|
8
14
|
import type { LedgerEntry } from "../src/cost/types.ts";
|
|
9
15
|
import { createDigester, digestApplies, digestMarker } from "../src/server/digest.ts";
|
|
10
16
|
import type { UpstreamClient } from "../src/upstream/types.ts";
|
|
11
17
|
import { createLogger } from "../src/util/log.ts";
|
|
12
|
-
import { openDb } from "../src/util/sqlite.ts";
|
|
13
18
|
import { digestToast, parsePolicy, shouldSend, textOf } from "../omp-extension/digest-logic.ts";
|
|
14
19
|
|
|
15
20
|
/**
|
|
@@ -37,7 +42,7 @@ function cfgWith(over: Partial<RouterConfig["digest"]> = {}): RouterConfig {
|
|
|
37
42
|
return cfg;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
function seedSession(ledger:
|
|
45
|
+
async function seedSession(ledger: AsyncLedger, tier: string): Promise<void> {
|
|
41
46
|
const e: LedgerEntry = {
|
|
42
47
|
id: crypto.randomUUID(),
|
|
43
48
|
createdAtMs: Date.now(),
|
|
@@ -72,7 +77,7 @@ function seedSession(ledger: ReturnType<typeof createLedger>, tier: string): voi
|
|
|
72
77
|
error: null,
|
|
73
78
|
promptTokensSaved: 0,
|
|
74
79
|
};
|
|
75
|
-
ledger.record(e);
|
|
80
|
+
await ledger.record(e);
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
function fakeUpstream(reply: (body: Record<string, unknown>) => string, costUsd: number | null = 0.0004): { upstream: UpstreamClient; calls: Record<string, unknown>[] } {
|
|
@@ -95,7 +100,7 @@ const BIG = Array.from({ length: 400 }, (_, i) => `${i + 1}: export const value$
|
|
|
95
100
|
|
|
96
101
|
describe("digestApplies", () => {
|
|
97
102
|
const d = { ...DEFAULT_CONFIG.digest, enabled: true, minBytes: 100, maxBytes: 1000 };
|
|
98
|
-
test("gates on switch, error, tool, size and session tier", () => {
|
|
103
|
+
test("gates on switch, error, tool, size and session tier", async () => {
|
|
99
104
|
expect(digestApplies({ ...d, enabled: false }, "read", 500, false, "hard").ok).toBe(false);
|
|
100
105
|
expect(digestApplies(d, "read", 500, true, "hard").ok).toBe(false);
|
|
101
106
|
expect(digestApplies(d, "edit", 500, false, "hard").ok).toBe(false);
|
|
@@ -111,9 +116,10 @@ describe("digestApplies", () => {
|
|
|
111
116
|
describe("createDigester", () => {
|
|
112
117
|
test("condenses a large read for a hard-tier session on a cheap model and records a ledger row", async () => {
|
|
113
118
|
const cfg = cfgWith();
|
|
114
|
-
const db =
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
120
|
+
await migrateStore(db);
|
|
121
|
+
const ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
122
|
+
await seedSession(ledger, "hard");
|
|
117
123
|
const { upstream, calls } = fakeUpstream(() => "Omitted 380 trivial constants.\n1: export const value0 = 0;\n...");
|
|
118
124
|
const d = createDigester({ cfg, catalog, ledger, upstream, log });
|
|
119
125
|
const r = await d.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/values.ts" }, content: BIG, query: "find value0" });
|
|
@@ -129,44 +135,47 @@ describe("createDigester", () => {
|
|
|
129
135
|
expect(catalog.find(call.model as string)?.price.prompt).toBeLessThanOrEqual(cfg.tiers.simple.maxInputPerMtok! / 1e6);
|
|
130
136
|
expect(JSON.stringify(call.messages)).toContain("Task: find value0");
|
|
131
137
|
// A ledger row under requestedModel "digest" with the served model and its cost.
|
|
132
|
-
const rows = ledger.recentEntries(10).filter((e) => e.requestedModel === "digest");
|
|
138
|
+
const rows = (await ledger.recentEntries(10)).filter((e) => e.requestedModel === "digest");
|
|
133
139
|
expect(rows).toHaveLength(1);
|
|
134
140
|
expect(rows[0]!.slug).toBe(call.model as string);
|
|
135
141
|
expect(rows[0]!.reportedUsd).toBeCloseTo(0.0004, 6);
|
|
136
142
|
expect(rows[0]!.ompSessionId).toBe("omp-1");
|
|
137
|
-
db.close();
|
|
143
|
+
await db.close();
|
|
138
144
|
});
|
|
139
145
|
|
|
140
146
|
test("declines below the session tier, over the cost guard, when the model fails, or when nothing shrinks", async () => {
|
|
141
|
-
const db =
|
|
147
|
+
const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
148
|
+
await migrateStore(db);
|
|
142
149
|
const cfg = cfgWith();
|
|
143
|
-
const ledger =
|
|
144
|
-
seedSession(ledger, "simple");
|
|
150
|
+
const ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
151
|
+
await seedSession(ledger, "simple");
|
|
145
152
|
const cheap = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "short").upstream, log });
|
|
146
153
|
expect(await cheap.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: expect.stringContaining("below digest.fromTier") });
|
|
147
154
|
|
|
148
|
-
const db2 =
|
|
155
|
+
const db2 = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
156
|
+
await migrateStore(db2);
|
|
149
157
|
const strict = cfgWith({ maxCostUsd: 0 });
|
|
150
|
-
const ledger2 =
|
|
151
|
-
seedSession(ledger2, "hard");
|
|
158
|
+
const ledger2 = createSqlLedger(db2, strict, { findModel: () => null });
|
|
159
|
+
await seedSession(ledger2, "hard");
|
|
152
160
|
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
161
|
|
|
154
162
|
const failing: UpstreamClient = { ...fakeUpstream(() => "x").upstream, complete: () => Promise.reject(new Error("boom")) };
|
|
155
163
|
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
164
|
// The failed attempt is still a ledger row, with the error.
|
|
157
|
-
expect(ledger2.recentEntries(5).find((e) => e.requestedModel === "digest")?.error).toBe("boom");
|
|
165
|
+
expect((await ledger2.recentEntries(5)).find((e) => e.requestedModel === "digest")?.error).toBe("boom");
|
|
158
166
|
|
|
159
167
|
const same = createDigester({ cfg, catalog, ledger: ledger2, upstream: fakeUpstream(() => BIG).upstream, log });
|
|
160
168
|
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();
|
|
169
|
+
await db.close();
|
|
162
170
|
db2.close();
|
|
163
171
|
});
|
|
164
172
|
|
|
165
173
|
test("a compaction-sourced digest is gated on compaction.digestToolResults and judges the given tier", async () => {
|
|
166
174
|
const cfg = cfgWith({ enabled: false });
|
|
167
175
|
cfg.compaction.digestToolResults = true;
|
|
168
|
-
const db =
|
|
169
|
-
|
|
176
|
+
const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
177
|
+
await migrateStore(db);
|
|
178
|
+
const ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
170
179
|
// No session rows at all: the tier comes from the request.
|
|
171
180
|
const { upstream, calls } = fakeUpstream(() => "Condensed.");
|
|
172
181
|
const d = createDigester({ cfg, catalog, ledger, upstream, log });
|
|
@@ -178,63 +187,65 @@ describe("createDigester", () => {
|
|
|
178
187
|
const r = await d.digest({ ...base, tier: "hard", source: "compaction" });
|
|
179
188
|
expect(r.digested).toBe(true);
|
|
180
189
|
expect(calls).toHaveLength(1);
|
|
181
|
-
expect(ledger.recentEntries(5).find((e) => e.requestedModel === "digest")?.reasons[0]).toContain("digest (compaction)");
|
|
190
|
+
expect((await ledger.recentEntries(5)).find((e) => e.requestedModel === "digest")?.reasons[0]).toContain("digest (compaction)");
|
|
182
191
|
cfg.compaction.digestToolResults = false;
|
|
183
192
|
expect(await d.digest({ ...base, tier: "hard", source: "compaction" })).toMatchObject({ digested: false });
|
|
184
|
-
db.close();
|
|
193
|
+
await db.close();
|
|
185
194
|
});
|
|
186
195
|
|
|
187
196
|
test("a later call of the same tool with the same primary argument marks the digest wasted", async () => {
|
|
188
197
|
const cfg = cfgWith();
|
|
189
|
-
const db =
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
199
|
+
await migrateStore(db);
|
|
200
|
+
const ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
201
|
+
await seedSession(ledger, "hard");
|
|
192
202
|
const dg = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "Condensed.").upstream, log });
|
|
193
203
|
const r = await dg.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/a.ts", offset: 1 }, content: BIG, query: "" });
|
|
194
204
|
expect(r.digested).toBe(true);
|
|
195
|
-
const row = () => ledger.recentEntries(10).find((e) => e.requestedModel === "digest")!;
|
|
196
|
-
expect(row().wasted).toBe(false);
|
|
205
|
+
const row = async (): Promise<LedgerEntry> => (await ledger.recentEntries(10)).find((e) => e.requestedModel === "digest")!;
|
|
206
|
+
expect((await row()).wasted).toBe(false);
|
|
197
207
|
// 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);
|
|
208
|
+
expect(await dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/b.ts"}' }, { name: "grep", argsJson: '{"pattern":"src/a.ts"}' }])).toBe(0);
|
|
209
|
+
expect(await dg.noteToolCalls("omp-2", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
210
|
+
expect((await row()).wasted).toBe(false);
|
|
201
211
|
// The next request carries the call that PRODUCED the digest in its last assistant message: not a re-run.
|
|
202
|
-
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts","offset":1}' }])).toBe(0);
|
|
203
|
-
expect(row().wasted).toBe(false);
|
|
212
|
+
expect(await dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts","offset":1}' }])).toBe(0);
|
|
213
|
+
expect((await row()).wasted).toBe(false);
|
|
204
214
|
// The same read again (case-insensitive tool name, any other args): the agent wanted the full output.
|
|
205
|
-
expect(dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
|
|
206
|
-
expect(row().wasted).toBe(true);
|
|
215
|
+
expect(await dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
|
|
216
|
+
expect((await row()).wasted).toBe(true);
|
|
207
217
|
// Marked once; a third read does not count again.
|
|
208
|
-
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
209
|
-
db.close();
|
|
218
|
+
expect(await dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
219
|
+
await db.close();
|
|
210
220
|
});
|
|
211
221
|
|
|
212
222
|
test("a pinned digest model is used as-is", async () => {
|
|
213
223
|
const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
|
|
214
224
|
const cfg = cfgWith({ model: pinned });
|
|
215
|
-
const db =
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
|
|
226
|
+
await migrateStore(db);
|
|
227
|
+
const ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
228
|
+
await seedSession(ledger, "hard");
|
|
218
229
|
const { upstream, calls } = fakeUpstream(() => "digest");
|
|
219
230
|
await createDigester({ cfg, catalog, ledger, upstream, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "grep", input: { pattern: "x" }, content: BIG, query: "" });
|
|
220
231
|
expect(calls[0]?.model as string).toBe(pinned);
|
|
221
|
-
db.close();
|
|
232
|
+
await db.close();
|
|
222
233
|
});
|
|
223
234
|
});
|
|
224
235
|
|
|
225
236
|
describe("digest marker and extension logic", () => {
|
|
226
|
-
test("the marker names the tool, sizes, model and how to get the full output", () => {
|
|
237
|
+
test("the marker names the tool, sizes, model and how to get the full output", async () => {
|
|
227
238
|
expect(digestMarker("grep", { pattern: "retry" }, "z-ai/glm-5.3-flash", 48_000, 3_000)).toBe(
|
|
228
239
|
'[digest: grep output 48,000 bytes → 3,000 chars by z-ai/glm-5.3-flash. Full output: re-run grep {"pattern":"retry"}]',
|
|
229
240
|
);
|
|
230
241
|
});
|
|
231
242
|
|
|
232
|
-
test("textOf joins text parts and flags images", () => {
|
|
243
|
+
test("textOf joins text parts and flags images", async () => {
|
|
233
244
|
expect(textOf([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toEqual({ text: "a\nb", hasImage: false });
|
|
234
245
|
expect(textOf([{ type: "image" }, { type: "text", text: "a" }])).toEqual({ text: "a", hasImage: true });
|
|
235
246
|
});
|
|
236
247
|
|
|
237
|
-
test("shouldSend applies the client-side gate; parsePolicy is defensive", () => {
|
|
248
|
+
test("shouldSend applies the client-side gate; parsePolicy is defensive", async () => {
|
|
238
249
|
const p = parsePolicy({ enabled: true, minBytes: 10, maxBytes: 100, tools: ["Read", "grep"], fromTier: "hard" });
|
|
239
250
|
expect(p.tools).toEqual(["read", "grep"]);
|
|
240
251
|
expect(shouldSend(p, "read", false, "x".repeat(50), false)).toBe(true);
|
|
@@ -248,7 +259,7 @@ describe("digest marker and extension logic", () => {
|
|
|
248
259
|
expect(parsePolicy({ enabled: true }).minBytes).toBe(12_000);
|
|
249
260
|
});
|
|
250
261
|
|
|
251
|
-
test("digestToast is one readable line", () => {
|
|
262
|
+
test("digestToast is one readable line", async () => {
|
|
252
263
|
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)");
|
|
253
264
|
});
|
|
254
265
|
});
|
|
@@ -124,7 +124,7 @@ afterAll(async () => {
|
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
describe("embedded router: port selection", () => {
|
|
127
|
-
test("adopts the port models.yml advertises, so omp's pre-resolved handle is valid", () => {
|
|
127
|
+
test("adopts the port models.yml advertises, so omp's pre-resolved handle is valid", async () => {
|
|
128
128
|
expect(portOfLatestRegistration()).toBe(advertised);
|
|
129
129
|
});
|
|
130
130
|
|
|
@@ -132,13 +132,13 @@ describe("embedded router: port selection", () => {
|
|
|
132
132
|
expect(await alive(advertised)).toBe(true);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test("does NOT write its port into models.yml", () => {
|
|
135
|
+
test("does NOT write its port into models.yml", async () => {
|
|
136
136
|
// Persisting an ephemeral port makes it authoritative for the NEXT
|
|
137
137
|
// session's startup resolution, which is where the dead handle came from.
|
|
138
138
|
expect(readFileSync(modelsYmlPath, "utf8")).toBe(modelsYmlBefore);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
test("publishes the port for subagents and the toast", () => {
|
|
141
|
+
test("publishes the port for subagents and the toast", async () => {
|
|
142
142
|
const portFile = join(home, "embed.port");
|
|
143
143
|
expect(existsSync(portFile)).toBe(true);
|
|
144
144
|
expect(readFileSync(portFile, "utf8").trim()).toBe(String(advertised));
|
|
@@ -146,7 +146,7 @@ describe("embedded router: port selection", () => {
|
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
describe("embedded router: lifetime", () => {
|
|
149
|
-
test("registers NO session_shutdown teardown", () => {
|
|
149
|
+
test("registers NO session_shutdown teardown", async () => {
|
|
150
150
|
// omp fires that from a throwaway host during provider refresh, so a
|
|
151
151
|
// teardown there kills a router the live session is still using.
|
|
152
152
|
expect(handlers.get("session_shutdown") ?? []).toHaveLength(0);
|
|
@@ -165,7 +165,7 @@ describe("embedded router: lifetime", () => {
|
|
|
165
165
|
expect(await alive(advertised)).toBe(true);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
test("each session still gets its own registration, so per-session tagging survives reuse", () => {
|
|
168
|
+
test("each session still gets its own registration, so per-session tagging survives reuse", async () => {
|
|
169
169
|
expect(registrations.length).toBeGreaterThanOrEqual(2);
|
|
170
170
|
});
|
|
171
171
|
});
|
package/test/embed-logic.test.ts
CHANGED
|
@@ -18,17 +18,17 @@ import {
|
|
|
18
18
|
} from "../omp-extension/embed-logic.ts";
|
|
19
19
|
|
|
20
20
|
describe("resolveEmbedPort", () => {
|
|
21
|
-
test("returns 0 (let the OS assign a free port) when nothing is configured", () => {
|
|
21
|
+
test("returns 0 (let the OS assign a free port) when nothing is configured", async () => {
|
|
22
22
|
expect(resolveEmbedPort(undefined)).toBe(0);
|
|
23
23
|
expect(resolveEmbedPort("")).toBe(0);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("uses an explicit valid env port verbatim", () => {
|
|
26
|
+
test("uses an explicit valid env port verbatim", async () => {
|
|
27
27
|
expect(resolveEmbedPort("8812")).toBe(8812);
|
|
28
28
|
expect(resolveEmbedPort("0")).toBe(0);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test("falls back to 0 on junk or out-of-range values", () => {
|
|
31
|
+
test("falls back to 0 on junk or out-of-range values", async () => {
|
|
32
32
|
expect(resolveEmbedPort("notaport")).toBe(0);
|
|
33
33
|
expect(resolveEmbedPort("-1")).toBe(0);
|
|
34
34
|
expect(resolveEmbedPort("70000")).toBe(0);
|
|
@@ -37,20 +37,20 @@ describe("resolveEmbedPort", () => {
|
|
|
37
37
|
// A stable port is what keeps omp's PRE-extension model resolution correct:
|
|
38
38
|
// it reads models.yml before this extension can bind and rewrite it, so an
|
|
39
39
|
// ephemeral port leaves that block naming the previous session's dead port.
|
|
40
|
-
test("uses the configured server.port when no env override is set", () => {
|
|
40
|
+
test("uses the configured server.port when no env override is set", async () => {
|
|
41
41
|
expect(resolveEmbedPort(undefined, 8788)).toBe(8788);
|
|
42
42
|
expect(resolveEmbedPort("", 8788)).toBe(8788);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
test("the env var wins over the configured port", () => {
|
|
45
|
+
test("the env var wins over the configured port", async () => {
|
|
46
46
|
expect(resolveEmbedPort("8812", 8788)).toBe(8812);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test("an explicit env 0 wins, so an ephemeral port stays requestable", () => {
|
|
49
|
+
test("an explicit env 0 wins, so an ephemeral port stays requestable", async () => {
|
|
50
50
|
expect(resolveEmbedPort("0", 8788)).toBe(0);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
test("ignores a nonsense configured port rather than binding it", () => {
|
|
53
|
+
test("ignores a nonsense configured port rather than binding it", async () => {
|
|
54
54
|
expect(resolveEmbedPort(undefined, 0)).toBe(0);
|
|
55
55
|
expect(resolveEmbedPort(undefined, -5)).toBe(0);
|
|
56
56
|
expect(resolveEmbedPort(undefined, 70_000)).toBe(0);
|
|
@@ -73,16 +73,16 @@ describe("modelsYmlPort", () => {
|
|
|
73
73
|
name: Auto (auto-model-router)
|
|
74
74
|
`;
|
|
75
75
|
|
|
76
|
-
test("reads the advertised port out of a real block", () => {
|
|
76
|
+
test("reads the advertised port out of a real block", async () => {
|
|
77
77
|
expect(modelsYmlPort(REAL)).toBe(58724);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
test("returns null when our provider block is absent", () => {
|
|
80
|
+
test("returns null when our provider block is absent", async () => {
|
|
81
81
|
expect(modelsYmlPort("providers:\n openrouter:\n baseUrl: https://openrouter.ai/api/v1\n")).toBeNull();
|
|
82
82
|
expect(modelsYmlPort("")).toBeNull();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
test("is not fooled by another provider's baseUrl appearing first", () => {
|
|
85
|
+
test("is not fooled by another provider's baseUrl appearing first", async () => {
|
|
86
86
|
const mixed = `providers:
|
|
87
87
|
llama.cpp:
|
|
88
88
|
baseUrl: http://127.0.0.1:8080/v1
|
|
@@ -92,7 +92,7 @@ describe("modelsYmlPort", () => {
|
|
|
92
92
|
expect(modelsYmlPort(mixed)).toBe(8788);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
test("returns null when the block carries no parseable url", () => {
|
|
95
|
+
test("returns null when the block carries no parseable url", async () => {
|
|
96
96
|
expect(modelsYmlPort("providers:\n auto-model-router:\n api: openai-completions\n")).toBeNull();
|
|
97
97
|
});
|
|
98
98
|
});
|
|
@@ -107,14 +107,14 @@ describe("embed port file", () => {
|
|
|
107
107
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
test("round-trips the bound port", () => {
|
|
110
|
+
test("round-trips the bound port", async () => {
|
|
111
111
|
const p = embedPortPath(dir);
|
|
112
112
|
expect(p).toBe(join(dir, EMBED_PORT_FILE));
|
|
113
113
|
writeEmbedPort(p, 45678);
|
|
114
114
|
expect(readEmbedPort(p)).toBe(45678);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
test("returns null for a missing or malformed file", () => {
|
|
117
|
+
test("returns null for a missing or malformed file", async () => {
|
|
118
118
|
expect(readEmbedPort(embedPortPath(join(dir, "absent")))).toBeNull();
|
|
119
119
|
writeEmbedPort(embedPortPath(dir), -5);
|
|
120
120
|
expect(readEmbedPort(embedPortPath(dir))).toBeNull();
|
|
@@ -135,7 +135,7 @@ describe("buildProviderConfig", () => {
|
|
|
135
135
|
ledger: { fallbackBlend: { inputPerMtok: 0.2, outputPerMtok: 0.8 } },
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
-
test("builds a provider config against the actual bound port", () => {
|
|
138
|
+
test("builds a provider config against the actual bound port", async () => {
|
|
139
139
|
const c: EmbedConfig = buildProviderConfig(45678, base);
|
|
140
140
|
expect(c.baseUrl).toBe("http://127.0.0.1:45678/v1");
|
|
141
141
|
expect(c.port).toBe(45678);
|
|
@@ -145,41 +145,41 @@ describe("buildProviderConfig", () => {
|
|
|
145
145
|
expect(c.models[0]).toMatchObject({ id: "auto", contextWindow: 400_000, maxTokens: 32_000 });
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
test("converts cost to USD-per-million-token and applies cache multipliers", () => {
|
|
148
|
+
test("converts cost to USD-per-million-token and applies cache multipliers", async () => {
|
|
149
149
|
const c: EmbedConfig = buildProviderConfig(45678, base);
|
|
150
150
|
// input 0.2, output 0.8, cacheRead = 0.2*0.1 = 0.02, cacheWrite = 0.2*1.25 = 0.25
|
|
151
151
|
expect(c.models[0]!.cost).toEqual({ input: 0.2, output: 0.8, cacheRead: 0.02, cacheWrite: 0.25 });
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test("normalizes a wildcard listen host to loopback", () => {
|
|
154
|
+
test("normalizes a wildcard listen host to loopback", async () => {
|
|
155
155
|
const c: EmbedConfig = buildProviderConfig(45678, { ...base, server: { host: "0.0.0.0" } });
|
|
156
156
|
expect(c.baseUrl).toBe("http://127.0.0.1:45678/v1");
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
test("carries the harness id through when configured", () => {
|
|
159
|
+
test("carries the harness id through when configured", async () => {
|
|
160
160
|
const c: EmbedConfig = buildProviderConfig(45678, { ...base, server: { host: "127.0.0.1", harnessId: "prod-a" } });
|
|
161
161
|
expect(c.harnessId).toBe("prod-a");
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
describe("embed constants", () => {
|
|
166
|
-
test("provider id and dummy key stay stable", () => {
|
|
166
|
+
test("provider id and dummy key stay stable", async () => {
|
|
167
167
|
expect(EMBED_PROVIDER_ID).toBe("auto-model-router");
|
|
168
168
|
});
|
|
169
|
-
test("port file name is stable", () => {
|
|
169
|
+
test("port file name is stable", async () => {
|
|
170
170
|
expect(EMBED_PORT_FILE).toBe("embed.port");
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
describe("agentdox scope", () => {
|
|
175
|
-
test("derives a slug from the workspace basename", () => {
|
|
175
|
+
test("derives a slug from the workspace basename", async () => {
|
|
176
176
|
expect(deriveAgentdoxScope("E:/projects/Ashlands/Ashlands")).toBe("ashlands");
|
|
177
177
|
expect(deriveAgentdoxScope("/home/drew/omp-router")).toBe("omp-router");
|
|
178
178
|
expect(deriveAgentdoxScope("E:\\projects\\My Game\\")).toBe("my-game");
|
|
179
179
|
expect(deriveAgentdoxScope("")).toBe("");
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
test("the workspace derivation wins over the scope-agnostic defaultScope", () => {
|
|
182
|
+
test("the workspace derivation wins over the scope-agnostic defaultScope", async () => {
|
|
183
183
|
// Regression: one router install serves every project on the machine, so a
|
|
184
184
|
// global `defaultScope` overriding the derivation made an ashlands session
|
|
185
185
|
// ship `X-Agentdox-Scope: omp-router` — wrong context injected, turns
|
|
@@ -198,7 +198,7 @@ describe("agentdox scope", () => {
|
|
|
198
198
|
expect(fallback.agentdoxScope).toBe("pinned");
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
-
test("no scope header when the bridge is off", () => {
|
|
201
|
+
test("no scope header when the bridge is off", async () => {
|
|
202
202
|
const cfg = {
|
|
203
203
|
server: { host: "127.0.0.1" },
|
|
204
204
|
profiles: [],
|
|
@@ -219,7 +219,7 @@ describe("workspace origin", () => {
|
|
|
219
219
|
rmSync(root, { recursive: true, force: true });
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
-
test("a plain repository: the remote from .git/config, also from a subdirectory", () => {
|
|
222
|
+
test("a plain repository: the remote from .git/config, also from a subdirectory", async () => {
|
|
223
223
|
const repo = join(root, "repo");
|
|
224
224
|
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
225
225
|
writeFileSync(join(repo, ".git", "config"), CONFIG);
|
|
@@ -230,7 +230,7 @@ describe("workspace origin", () => {
|
|
|
230
230
|
expect(deriveAgentdoxScope(repo)).toBe("repo");
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
-
test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", () => {
|
|
233
|
+
test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", async () => {
|
|
234
234
|
// The main checkout holds the config; the worktree's own dir only points at it.
|
|
235
235
|
const main = join(root, "main");
|
|
236
236
|
mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
|
|
@@ -249,7 +249,7 @@ describe("workspace origin", () => {
|
|
|
249
249
|
expect(deriveWorkspaceOrigin(join(sub, "lib"))).toBe("github.com/org/lib");
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
-
test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", () => {
|
|
252
|
+
test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", async () => {
|
|
253
253
|
const bare = join(root, "bare");
|
|
254
254
|
mkdirSync(join(bare, ".git"), { recursive: true });
|
|
255
255
|
writeFileSync(join(bare, ".git", "config"), "[core]\n\tbare = false\n");
|
|
@@ -278,7 +278,7 @@ describe("workspace origin", () => {
|
|
|
278
278
|
expect(deriveWorkspaceOrigin("/x/repo", () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n')).toBe("");
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
-
test("buildProviderConfig carries the origin beside the scope, only where the scope goes", () => {
|
|
281
|
+
test("buildProviderConfig carries the origin beside the scope, only where the scope goes", async () => {
|
|
282
282
|
const base = {
|
|
283
283
|
server: { host: "127.0.0.1" },
|
|
284
284
|
profiles: [],
|