auto-model-router 0.4.0 → 0.4.2

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.
@@ -6,6 +6,7 @@ import { createFeedbackStore } from "../src/cost/feedback.ts";
6
6
  import { createLedger } from "../src/cost/ledger.ts";
7
7
  import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
8
8
  import { startServer, type StartedServer } from "../src/server/http.ts";
9
+ import { resolveProfile } from "../src/router/index.ts";
9
10
  import { ollamaRunway } from "../src/server/http.ts";
10
11
  import { createSessionOverrides, OVERRIDE_TTL_MS } from "../src/server/overrides.ts";
11
12
  import { openDb } from "../src/util/sqlite.ts";
@@ -121,7 +122,7 @@ describe("override and feedback endpoints", () => {
121
122
  beforeAll(() => {
122
123
  const cfg: RouterConfig = {
123
124
  ...structuredClone(DEFAULT_CONFIG),
124
- server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24 },
125
+ server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
125
126
  ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
126
127
  logLevel: "silent",
127
128
  };
@@ -221,3 +222,51 @@ describe("ollamaRunway", () => {
221
222
  expect(ollamaRunway(null, 7, 1)).toBeNull();
222
223
  });
223
224
  });
225
+
226
+ describe("subagent profile", () => {
227
+ test("a subagent asking for the default profile is routed under server.subagentProfile; explicit profiles are honoured", () => {
228
+ const cfg = structuredClone(DEFAULT_CONFIG);
229
+ expect(resolveProfile(cfg, "auto", true).id).toBe("auto-sub");
230
+ expect(resolveProfile(cfg, "auto", false).id).toBe("auto");
231
+ expect(resolveProfile(cfg, "auto-max", true).id).toBe("auto-max");
232
+ expect(resolveProfile(cfg, "unknown", true).id).toBe("auto-sub"); // unknown ids fall back to the default, which a subagent remaps
233
+ cfg.server.subagentProfile = "";
234
+ expect(resolveProfile(cfg, "auto", true).id).toBe("auto");
235
+ cfg.server.subagentProfile = "nope";
236
+ expect(resolveProfile(cfg, "auto", true).id).toBe("auto");
237
+ });
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,207 @@
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 pinned digest model is used as-is", async () => {
166
+ const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
167
+ const cfg = cfgWith({ model: pinned });
168
+ const db = openDb(":memory:");
169
+ const ledger = createLedger(db, cfg);
170
+ seedSession(ledger, "hard");
171
+ const { upstream, calls } = fakeUpstream(() => "digest");
172
+ await createDigester({ cfg, catalog, ledger, upstream, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "grep", input: { pattern: "x" }, content: BIG, query: "" });
173
+ expect(calls[0]?.model as string).toBe(pinned);
174
+ db.close();
175
+ });
176
+ });
177
+
178
+ describe("digest marker and extension logic", () => {
179
+ test("the marker names the tool, sizes, model and how to get the full output", () => {
180
+ expect(digestMarker("grep", { pattern: "retry" }, "z-ai/glm-5.3-flash", 48_000, 3_000)).toBe(
181
+ '[digest: grep output 48,000 bytes → 3,000 chars by z-ai/glm-5.3-flash. Full output: re-run grep {"pattern":"retry"}]',
182
+ );
183
+ });
184
+
185
+ test("textOf joins text parts and flags images", () => {
186
+ expect(textOf([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toEqual({ text: "a\nb", hasImage: false });
187
+ expect(textOf([{ type: "image" }, { type: "text", text: "a" }])).toEqual({ text: "a", hasImage: true });
188
+ });
189
+
190
+ test("shouldSend applies the client-side gate; parsePolicy is defensive", () => {
191
+ const p = parsePolicy({ enabled: true, minBytes: 10, maxBytes: 100, tools: ["Read", "grep"], fromTier: "hard" });
192
+ expect(p.tools).toEqual(["read", "grep"]);
193
+ expect(shouldSend(p, "read", false, "x".repeat(50), false)).toBe(true);
194
+ expect(shouldSend(p, "read", true, "x".repeat(50), false)).toBe(false);
195
+ expect(shouldSend(p, "read", false, "x".repeat(50), true)).toBe(false);
196
+ expect(shouldSend(p, "edit", false, "x".repeat(50), false)).toBe(false);
197
+ expect(shouldSend(p, "read", false, "x".repeat(5), false)).toBe(false);
198
+ expect(shouldSend(p, "read", false, "x".repeat(500), false)).toBe(false);
199
+ expect(parsePolicy({ enabled: false }).enabled).toBe(false);
200
+ expect(parsePolicy("nope").enabled).toBe(false);
201
+ expect(parsePolicy({ enabled: true }).minBytes).toBe(12_000);
202
+ });
203
+
204
+ test("digestToast is one readable line", () => {
205
+ 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)");
206
+ });
207
+ });
@@ -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) => void | Promise<void>)[]>();
32
+ const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => unknown)[]>();
33
33
 
34
34
  const pi: ExtensionAPI = {
35
35
  setLabel: () => {},
@@ -24,6 +24,7 @@ function req(messages: NormMessage[] = [], over: Partial<NormRequest> = {}): Nor
24
24
  harnessId: "",
25
25
  ompSessionId: "",
26
26
  agentdoxScope: "",
27
+ isSubagent: false,
27
28
  requestedModel: "auto",
28
29
  messages,
29
30
  tools: [],
@@ -29,7 +29,7 @@ import type {
29
29
 
30
30
  function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
31
31
  return {
32
- server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24 },
32
+ server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
33
33
  openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
34
34
  ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
35
35
  benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
@@ -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, 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, 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: "",
@@ -57,7 +57,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
57
57
  toolAxis: "coding",
58
58
  chatAxis: "intelligence",
59
59
  agenticLoopDepth: 3,
60
- mechanicalRetryFactor: 0.2,
60
+ mechanicalRetryFactor: 0.2, readOnlyToolWeight: 0,
61
61
  reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
62
62
  },
63
63
  escalation: {
@@ -77,6 +77,7 @@ 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 },
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [] },
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,
@@ -92,6 +93,7 @@ function mkReq(): NormRequest {
92
93
  harnessId: "",
93
94
  ompSessionId: "",
94
95
  agentdoxScope: "",
96
+ isSubagent: false,
95
97
  requestedModel: "auto",
96
98
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
97
99
  tools: [],
@@ -337,3 +337,35 @@ describe("prompt anatomy", () => {
337
337
  expect(a.olderHalfBytes).toBe(0);
338
338
  });
339
339
  });
340
+
341
+ describe("subagent and read-only tool loop signals", () => {
342
+ test("a tool-result tail behind read-only calls is flagged; a write call clears it", () => {
343
+ const reads = req([
344
+ SYSTEM,
345
+ { role: "user", content: "find the retry helper" },
346
+ { role: "assistant", content: null, tool_calls: [
347
+ { id: "a", type: "function", function: { name: "grep", arguments: "{\"pattern\":\"retry\"}" } },
348
+ { id: "b", type: "function", function: { name: "read", arguments: "{\"path\":\"x.ts\"}" } },
349
+ ] },
350
+ { role: "tool", tool_call_id: "a", content: "x.ts:12" },
351
+ { role: "tool", tool_call_id: "b", content: "export function retry() {}" },
352
+ ]);
353
+ expect(extractFeatures(reads, 1000).readOnlyToolTail).toBe(true);
354
+ const write = req([
355
+ SYSTEM,
356
+ { role: "user", content: "fix it" },
357
+ toolCall("c", "edit", "{\"path\":\"x.ts\"}"),
358
+ { role: "tool", tool_call_id: "c", content: "ok" },
359
+ ]);
360
+ expect(extractFeatures(write, 1000).readOnlyToolTail).toBe(false);
361
+ // A fresh user turn is never a read-only tail, whatever came before.
362
+ expect(extractFeatures(req([SYSTEM, { role: "user", content: "now what?" }]), 100).readOnlyToolTail).toBe(false);
363
+ });
364
+
365
+ test("the subagent marker rides on the request", () => {
366
+ const r = parseChatRequest({ model: "auto", messages: [SYSTEM, { role: "user", content: "hi" }], tools: TOOLS }, new Headers({ "x-omp-subagent": "1" }));
367
+ expect(r.isSubagent).toBe(true);
368
+ expect(extractFeatures(r, 100).isSubagent).toBe(true);
369
+ expect(req([SYSTEM, { role: "user", content: "hi" }]).isSubagent).toBe(false);
370
+ });
371
+ });
@@ -10,7 +10,7 @@ describe("HTTP server resilience against dead streams", () => {
10
10
  beforeAll(() => {
11
11
  const cfg: RouterConfig = {
12
12
  ...DEFAULT_CONFIG,
13
- server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24 },
13
+ server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
14
14
  ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
15
15
  context: { ...DEFAULT_CONFIG.context, enabled: false },
16
16
  logLevel: "silent",
@@ -76,6 +76,11 @@ function report(over: Partial<UsageReport> = {}): UsageReport {
76
76
  aborted: 0,
77
77
  modelSwitches: 1,
78
78
  cacheEstimated: false,
79
+ subagentDispatches: 0,
80
+ subagentSpendUsd: 0,
81
+ digests: 0,
82
+ digestSpendUsd: 0,
83
+ digestInputTokens: 0,
79
84
  },
80
85
  providers: [row("openrouter", 2), row("ollama", 1)],
81
86
  models: [
@@ -197,6 +197,18 @@ describe("buildUsageReport", () => {
197
197
  db.close();
198
198
  });
199
199
 
200
+ test("subagent turns are counted with their spend", () => {
201
+ const { db, ledger } = seeded();
202
+ ledger.record(entry({ reportedUsd: 0.01, features: { isSubagent: true } }));
203
+ ledger.record(entry({ reportedUsd: 0.03, features: { isSubagent: false } }));
204
+ ledger.record(entry({ reportedUsd: 0.06 }));
205
+ const r = buildUsageReport(db, { windowDays: 7, nowMs: NOW });
206
+ expect(r.totals.subagentDispatches).toBe(1);
207
+ expect(r.totals.subagentSpendUsd).toBeCloseTo(0.01, 6);
208
+ expect(renderUsageReport(r)).toContain("subagents: 1 dispatches, $0.0100 (10% of spend)");
209
+ db.close();
210
+ });
211
+
200
212
  test("empty ledger yields zeroed totals and null speeds", () => {
201
213
  const { db } = seeded();
202
214
  const r = buildUsageReport(db, { windowDays: 7, nowMs: NOW });
@@ -213,6 +225,11 @@ describe("buildUsageReport", () => {
213
225
  aborted: 0,
214
226
  modelSwitches: 0,
215
227
  cacheEstimated: false,
228
+ subagentDispatches: 0,
229
+ subagentSpendUsd: 0,
230
+ digests: 0,
231
+ digestSpendUsd: 0,
232
+ digestInputTokens: 0,
216
233
  });
217
234
  expect(r.providers).toEqual([]);
218
235
  expect(r.models).toEqual([]);
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { createFeedbackStore } from "../src/cost/feedback.ts";
2
3
 
3
4
  import { loadConfig } from "../src/config/load.ts";
4
5
  import { createLedger, LATENCY_WINDOW_ROWS } from "../src/cost/ledger.ts";
@@ -410,3 +411,55 @@ describe("cache reliability signal", () => {
410
411
  db.close();
411
412
  });
412
413
  });
414
+
415
+ describe("feedback in trust", () => {
416
+ // A user verdict counts as filters.feedbackWeight attempts of that outcome.
417
+ function trustWith(weight: number, verdicts: Array<"good" | "bad">): { rate: number; good: number; bad: number } {
418
+ const db = openDb(":memory:");
419
+ try {
420
+ const c = structuredClone(cfg);
421
+ c.filters.feedbackWeight = weight;
422
+ const ledger = createLedger(db, c);
423
+ const fb = createFeedbackStore(db);
424
+ let last = "";
425
+ for (let i = 0; i < 10; i++) {
426
+ const e = entry({ error: null });
427
+ last = e.id;
428
+ ledger.record(e);
429
+ }
430
+ for (const v of verdicts) fb.record({ ledgerId: last, ompSessionId: "s", slug: "vendor/model", tier: "simple", verdict: v, note: "" });
431
+ const t = ledger.trust("vendor/model")!;
432
+ return { rate: t.successRate, good: t.feedbackGood ?? -1, bad: t.feedbackBad ?? -1 };
433
+ } finally {
434
+ db.close();
435
+ }
436
+ }
437
+
438
+ test("weight 0 records verdicts without moving the rate", () => {
439
+ const base = trustWith(0, []);
440
+ expect(base.rate).toBeCloseTo(11 / 12, 6); // (10 - 0 + 1) / (10 + 2)
441
+ expect(trustWith(0, ["bad", "bad"]).rate).toBeCloseTo(base.rate, 6);
442
+ });
443
+
444
+ test("a bad verdict counts as `weight` failures, a good one as `weight` successes", () => {
445
+ // 10 clean attempts + one bad verdict at weight 3: attempts 13, failures 3.
446
+ const bad = trustWith(3, ["bad"]);
447
+ expect(bad.rate).toBeCloseTo((13 - 3 + 1) / (13 + 2), 6);
448
+ expect(bad.bad).toBe(1);
449
+ const good = trustWith(3, ["good"]);
450
+ expect(good.rate).toBeCloseTo((13 - 0 + 1) / (13 + 2), 6);
451
+ expect(good.good).toBe(1);
452
+ // allTrust and signals agree with trust().
453
+ const db = openDb(":memory:");
454
+ const c = structuredClone(cfg);
455
+ c.filters.feedbackWeight = 3;
456
+ const ledger = createLedger(db, c);
457
+ const fb = createFeedbackStore(db);
458
+ const e = entry({ error: null });
459
+ ledger.record(e);
460
+ fb.record({ ledgerId: e.id, ompSessionId: "s", slug: "vendor/model", tier: "simple", verdict: "bad", note: "" });
461
+ expect(ledger.allTrust()[0]?.successRate).toBeCloseTo(ledger.trust("vendor/model")!.successRate, 9);
462
+ expect(ledger.signals?.(["vendor/model"]).get("vendor/model")?.trust?.successRate).toBeCloseTo(ledger.trust("vendor/model")!.successRate, 9);
463
+ db.close();
464
+ });
465
+ });
package/test/turn.test.ts CHANGED
@@ -29,7 +29,7 @@ import type {
29
29
 
30
30
  function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
31
31
  return {
32
- server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24 },
32
+ server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
33
33
  openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
34
34
  ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
35
35
  benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
@@ -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, 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, 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: "",
@@ -57,7 +57,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
57
57
  toolAxis: "coding",
58
58
  chatAxis: "intelligence",
59
59
  agenticLoopDepth: 3,
60
- mechanicalRetryFactor: 0.2,
60
+ mechanicalRetryFactor: 0.2, readOnlyToolWeight: 0,
61
61
  reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
62
62
  },
63
63
  escalation: {
@@ -77,6 +77,7 @@ 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 },
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [] },
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,
@@ -92,6 +93,7 @@ function mkReq(): NormRequest {
92
93
  harnessId: "",
93
94
  ompSessionId: "",
94
95
  agentdoxScope: "",
96
+ isSubagent: false,
95
97
  requestedModel: "auto",
96
98
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
97
99
  tools: [],
package/tools/replay.ts CHANGED
@@ -197,6 +197,7 @@ function requestOf(row: Row, f: Features): NormRequest {
197
197
  harnessId: row.harness_id,
198
198
  ompSessionId: "",
199
199
  agentdoxScope: "",
200
+ isSubagent: false,
200
201
  requestedModel: row.requested_model,
201
202
  messages,
202
203
  tools,