auto-model-router 0.24.0 → 0.26.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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.24.0",
10
+ "version": "0.26.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.24.0",
17
+ "version": "0.26.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -182,6 +182,8 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
182
182
  { path: "filters.deny", label: "Deny globs", kind: "stringArray", hint: "comma-separated" },
183
183
  { path: "filters.includeFree", label: "Include free models", kind: "boolean" },
184
184
  { path: "filters.requireToolSupport", label: "Require tool support", kind: "boolean" },
185
+ { path: "filters.agenticAxisForToolTurns", label: "Rank tool turns on the agentic axis", kind: "boolean", hint: "a tool loop is won on tool-driving ability, not the task's own axis" },
186
+ { path: "filters.minAgenticForToolTurns", label: "Min agentic score for tool turns", kind: "number", min: 0, max: 100, hint: "its own scale, not a tier floor; 0 disables. Models with no agentic score are not filtered" },
185
187
  { path: "filters.minTrust", label: "Min trust", kind: "number", min: 0, max: 1 },
186
188
  { path: "filters.feedbackWeight", label: "Feedback weight in trust", kind: "number", min: 0, hint: "0=record only; a bad verdict = this many failures" },
187
189
  { path: "filters.feedbackByTask", label: "Scope verdicts to the task type", kind: "boolean" },
@@ -103,6 +103,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
103
103
  // Free models are rate-limited hard enough that retries cost more than they save.
104
104
  includeFree: false,
105
105
  requireToolSupport: true,
106
+ agenticAxisForToolTurns: true,
107
+ minAgenticForToolTurns: 25,
106
108
  minTrust: 0.7,
107
109
  // Verdicts are recorded and reported first; weigh them once there are some.
108
110
  feedbackWeight: 0,
@@ -128,6 +128,8 @@ const filters = z.strictObject({
128
128
  deny: z.array(z.string()).optional(),
129
129
  includeFree: z.boolean().optional(),
130
130
  requireToolSupport: z.boolean().optional(),
131
+ agenticAxisForToolTurns: z.boolean().optional(),
132
+ minAgenticForToolTurns: z.number().min(0).max(100).optional(),
131
133
  minTrust: z.number().min(0).max(1).optional(),
132
134
  feedbackWeight: z.number().nonnegative().optional(),
133
135
  feedbackByTask: z.boolean().optional(),
@@ -221,6 +221,26 @@ export interface FilterConfig {
221
221
  includeFree: boolean;
222
222
  /** Require `supported_parameters` to include `tools` whenever the request offers tools. */
223
223
  requireToolSupport: boolean;
224
+ /**
225
+ * Score a turn that carries tools on the `agentic` axis instead of its task's axis.
226
+ * On by default: a tool loop is won or lost on tool-driving ability, and `chat` and
227
+ * `documentation` score on `intelligence`, which does not measure it. Off restores
228
+ * the task's own axis for every turn.
229
+ */
230
+ agenticAxisForToolTurns: boolean;
231
+ /**
232
+ * Minimum `agentic` score a model needs to be offered a turn that carries tools, 0-100.
233
+ * The cheap tiers rank with `qualityExponent: 0` — cheapest above the floor — so on those
234
+ * tiers the ranking axis is inert and only a floor keeps a tool-incapable model out.
235
+ *
236
+ * Judged on its own scale, NOT against a tier's `minQuality`: agentic scores run far lower
237
+ * than coding and intelligence, so reusing a tier floor here empties the catalog. Measured:
238
+ * 25 drops gpt-oss-20b (1.4) and gemma-3-12b (0.1) while leaving 14 models under $0.30/Mtok.
239
+ *
240
+ * A model that publishes NO agentic score is not filtered — only 103 of 223 tool-capable
241
+ * models carry one, so rejecting the unscored would discard half the catalog. 0 disables.
242
+ */
243
+ minAgenticForToolTurns: number;
224
244
  /**
225
245
  * Smallest completion budget a REASONING model is dispatched with, tokens.
226
246
  *
@@ -169,7 +169,15 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
169
169
  const minContext = Math.ceil(features.promptTokens * filters.contextHeadroom) + expectedCompletionTokens;
170
170
  // Task selects the quality axis and capability filters; the tier still
171
171
  // bounds cost.
172
- const effectiveAxis = taskCfg.axis;
172
+ //
173
+ // A turn that carries tools is scored on `agentic`, whatever the task's own axis says.
174
+ // `chat` and `documentation` score on `intelligence`, which says nothing about whether a
175
+ // model can drive a tool loop: measured here, ollama/gpt-oss:20b (intelligence 9, agentic
176
+ // 1.4) won a tool-bearing trivial turn on price because every candidate sat under the
177
+ // tier floor and the adaptive band then relaxed it. The `agentic` score is already in the
178
+ // catalog for every model and was the one axis nothing routed on.
179
+ const toolTurn = req.tools.length > 0;
180
+ const effectiveAxis: QualityAxis = toolTurn && filters.agenticAxisForToolTurns ? "agentic" : taskCfg.axis;
173
181
  // Two floors with different meanings, and only one of them may be relaxed:
174
182
  // - the TIER floor is an economic envelope tuned against the full catalog,
175
183
  // so when a guardrail narrows availability below it, relaxing to the
@@ -180,7 +188,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
180
188
  const plan = cfg.adaptiveTierFloors || cfg.adaptivePriceCeilings ? tierPlanFor(snapshot, cfg) : null;
181
189
  const adaptiveTierFloor =
182
190
  cfg.adaptiveTierFloors && plan !== null
183
- ? effectiveQualityFloor(tierCfg.minQuality, tier, effectiveAxis, plan)
191
+ ? effectiveQualityFloor(tierCfg.minQuality, tier, taskCfg.axis, plan)
184
192
  : tierCfg.minQuality;
185
193
  const qualityFloor = Math.max(taskFloor, adaptiveTierFloor);
186
194
  // Input-price ceiling: catalog-derived band when adaptive, else the fixed config.
@@ -229,6 +237,20 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
229
237
  rejected.push({ slug, reason: "no_tool_support" });
230
238
  continue;
231
239
  }
240
+ // A tool loop needs a model that can drive one. The cheap tiers rank with
241
+ // `qualityExponent: 0` — cheapest above the floor — so there the ranking axis decides
242
+ // nothing and only this keeps a tool-incapable model out: ollama/gpt-oss:20b (agentic
243
+ // 1.4) was winning tool-bearing trivial turns purely on price. Judged on the agentic
244
+ // scale rather than a tier floor, and never applied to a model that publishes no
245
+ // agentic score, since most of the catalog does not. Relaxed with the rest under
246
+ // tier rescue, so a narrowed catalog still gets a turn.
247
+ if (toolTurn && !relaxQuality && filters.minAgenticForToolTurns > 0) {
248
+ const agentic = model.quality.agentic;
249
+ if (agentic !== undefined && agentic < filters.minAgenticForToolTurns) {
250
+ rejected.push({ slug, reason: "below_quality_floor", detail: `agentic ${agentic} < ${filters.minAgenticForToolTurns} for a tool turn` });
251
+ continue;
252
+ }
253
+ }
232
254
  if ((req.hasImages || taskCfg.requireImage === true) && !model.inputModalities.includes("image")) {
233
255
  rejected.push({ slug, reason: "no_image_support" });
234
256
  continue;
@@ -239,35 +261,59 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
239
261
  }
240
262
 
241
263
  const pinned = tierCfg.pin.includes(slug) || taskPins.includes(slug);
242
- const quality = resolveQuality(model, effectiveAxis);
264
+ // Floors are judged on the TASK's axis, always. The `agentic` scores sit on a lower
265
+ // scale than coding and intelligence (glm-5.3-flash: coding 71.5, agentic 51.2), so
266
+ // reusing one numeric floor across axes empties the set — measured here, a floor of
267
+ // 60 on `agentic` admitted nothing, tier rescue then dropped the floor entirely and
268
+ // the WEAKEST model won. The axis switch below therefore reorders candidates without
269
+ // touching who is eligible.
270
+ const quality = resolveQuality(model, taskCfg.axis);
243
271
  if (!pinned && !relaxQuality && qualityFloor > 0) {
244
272
  if (quality === null) {
245
273
  rejected.push({ slug, reason: "below_quality_floor", detail: "no published quality score" });
246
274
  continue;
247
275
  }
248
276
  if (quality.score < qualityFloor) {
249
- rejected.push({ slug, reason: "below_quality_floor", detail: `${quality.score} < floor ${qualityFloor}` });
277
+ rejected.push({ slug, reason: "below_quality_floor", detail: `${quality.score} < floor ${qualityFloor} on ${quality.axis}` });
250
278
  continue;
251
279
  }
252
280
  }
281
+ // What the candidate is RANKED on: a tool-bearing turn is won or lost on tool-driving
282
+ // ability, and `chat`/`documentation` score on `intelligence`, which does not measure
283
+ // it. Ranking is relative within the admitted set, so a lower-scaled axis is safe here
284
+ // in a way a floor is not.
285
+ const rankQuality = effectiveAxis === taskCfg.axis ? quality : resolveQuality(model, effectiveAxis);
253
286
 
254
287
  // Price ceilings at the ACTUAL prompt size: long-context overrides can
255
288
  // push a model over the ceiling exactly when conversations get long.
256
289
  // Catalog prices are per-token; ceilings are per million tokens.
290
+ //
291
+ // The ceiling is compared against the BIASED price, because that is what the turn
292
+ // actually costs this deployment: capacity already paid for — an Ollama plan's
293
+ // included credits, a Claude Pro/Max subscription — carries the list price of the
294
+ // twin it is priced from, and a subscription model at $2-5/Mtok list would be
295
+ // thrown out here on every cheap tier before `costBias` was ever consulted. That
296
+ // made the bias silently inert: setting it to 0.00001 changed no decision at all.
257
297
  const price = priceAt(model, Math.max(1, features.promptTokens));
258
- if (!relaxPrice && priceCeiling !== undefined && price.prompt * 1e6 > priceCeiling) {
298
+ const providerBias =
299
+ snapshot.providerBias?.[model.provider] ??
300
+ (model.provider === "ollama" ? cfg.ollama.costBias : (cfg.upstreams.find((u) => u.id === model.provider)?.costBias ?? 1));
301
+ const biasedPrompt = price.prompt * providerBias;
302
+ const biasedCompletion = price.completion * providerBias;
303
+ const biasNote = providerBias === 1 ? "" : ` (×${providerBias} bias on $${(price.prompt * 1e6).toFixed(2)} list)`;
304
+ if (!relaxPrice && priceCeiling !== undefined && biasedPrompt * 1e6 > priceCeiling) {
259
305
  rejected.push({
260
306
  slug,
261
307
  reason: "over_price_ceiling",
262
- detail: `input $${(price.prompt * 1e6).toFixed(2)}/Mtok > ceiling $${priceCeiling.toFixed(2)}`,
308
+ detail: `input $${(biasedPrompt * 1e6).toFixed(2)}/Mtok > ceiling $${priceCeiling.toFixed(2)}${biasNote}`,
263
309
  });
264
310
  continue;
265
311
  }
266
- if (!relaxPrice && tierCfg.maxOutputPerMtok !== undefined && price.completion * 1e6 > tierCfg.maxOutputPerMtok) {
312
+ if (!relaxPrice && tierCfg.maxOutputPerMtok !== undefined && biasedCompletion * 1e6 > tierCfg.maxOutputPerMtok) {
267
313
  rejected.push({
268
314
  slug,
269
315
  reason: "over_price_ceiling",
270
- detail: `output $${(price.completion * 1e6).toFixed(2)}/Mtok > ceiling $${tierCfg.maxOutputPerMtok}`,
316
+ detail: `output $${(biasedCompletion * 1e6).toFixed(2)}/Mtok > ceiling $${tierCfg.maxOutputPerMtok}${biasNote}`,
271
317
  });
272
318
  continue;
273
319
  }
@@ -339,11 +385,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
339
385
  images,
340
386
  });
341
387
  const trustScore = trust !== null && trust.attempts > 0 ? trust.successRate : UNMEASURED_TRUST;
342
- const qualityScore = quality?.score ?? 0;
343
388
  // Shared scoring: trust converts flakiness into money — a model failing
344
389
  // 20% of the time really costs ~25% more in retries. Latency does the same
345
390
  // for slowness (TTFT over the reference). qualityExponent 0 makes this
346
- // "cheapest above the floor"; the floor does the quality work.
391
+ const qualityScore = rankQuality?.score ?? 0;
347
392
  const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens, latencyWeightFor(filters, features.isToolResultContinuation));
348
393
  // Escalation-cost term: the trust divisor prices a failure as a retry of
349
394
  // THIS model, but a probe escalation re-dispatches the whole prompt on
@@ -357,14 +402,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
357
402
  filters.escalationCostWeight > 0 && args.escalationUsdPerPromptToken !== undefined
358
403
  ? filters.escalationCostWeight * escalationRate * args.escalationUsdPerPromptToken * features.promptTokens
359
404
  : 0;
360
- // Provider bias: capacity already paid for an Ollama plan's included credits, a
361
- // Claude Pro/Max subscription — is money already spent, so an operator may value it
362
- // below list price in ranking. The ledger still records list price.
363
- // The snapshot carries the LIVE bias (credit-aware, and each named upstream's own);
364
- // the static config value is the fallback for snapshots built without one.
365
- const providerBias =
366
- snapshot.providerBias?.[model.provider] ??
367
- (model.provider === "ollama" ? cfg.ollama.costBias : (cfg.upstreams.find((u) => u.id === model.provider)?.costBias ?? 1));
405
+ // `providerBias` is computed with the price ceilings above capacity already paid for
406
+ // is money already spent, so it is valued below list in ranking AND against the
407
+ // ceilings. The ledger still records list price either way.
368
408
  const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5) + escalationUsd) * latencyMult * providerBias;
369
409
  // Score is assigned in a SECOND PASS below: both qualityNormalization and
370
410
  // capabilityFloorUsd are properties of the candidate SET, not of one
@@ -372,9 +412,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
372
412
  const score = 0;
373
413
 
374
414
  const reasons: string[] = [
375
- quality === null
415
+ rankQuality === null
376
416
  ? "unscored on every quality axis"
377
- : `quality ${quality.score} on ${quality.axis}${quality.axis === effectiveAxis ? "" : ` (fallback from ${effectiveAxis})`}`,
417
+ : `quality ${rankQuality.score} on ${rankQuality.axis}${rankQuality.axis === effectiveAxis ? "" : ` (fallback from ${effectiveAxis})`}${effectiveAxis === taskCfg.axis ? "" : ` — ranked on ${effectiveAxis} because the turn carries tools`}`,
378
418
  trust === null || trust.attempts === 0
379
419
  ? `trust unmeasured: neutral prior ${UNMEASURED_TRUST}`
380
420
  : `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
@@ -47,7 +47,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
47
47
  data: { axis: "intelligence", minQuality: 0 },
48
48
  chat: { axis: "intelligence", minQuality: 0 },
49
49
  },
50
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
50
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
51
51
  classifier: {
52
52
  ambiguityThreshold: 0,
53
53
  model: "test/adjudicator", learnedModelPath: "",
@@ -389,7 +389,10 @@ describe("decision shape", () => {
389
389
  test("carries the session id, features, and a reasoning trail", () => {
390
390
  const d = run({ tier: "simple" });
391
391
  expect(d.sessionId.startsWith("omp-")).toBe(true);
392
- expect(d.reasons.length).toBeGreaterThan(0);
392
+ // `d.reasons` holds decision-level notes — a widening, a hysteresis hold — and is
393
+ // legitimately empty when a tier serves the turn without incident. The trail that is
394
+ // always present is the per-candidate one, so that is what a caller can rely on.
395
+ expect(d.considered[0]!.reasons.length).toBeGreaterThan(0);
393
396
  expect(d.features.toolCount).toBe(1);
394
397
  expect(d.considered.length).toBeGreaterThan(0);
395
398
  });
@@ -358,6 +358,55 @@ describe("adaptive price ceilings", () => {
358
358
  expect(on.candidates.map((c) => c.model.slug)).not.toContain("a/4");
359
359
  expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
360
360
  });
361
+
362
+ test("a price ceiling judges the BIASED price, so prepaid capacity is not thrown out on list", () => {
363
+ // A subscription upstream inherits its OpenRouter twin's list price ($4/Mtok here) and is
364
+ // discounted by `costBias` because the capacity is already paid for. Judging the ceiling on
365
+ // list threw it out before the bias was ever read, which made the bias entirely inert.
366
+ const snap = snapshot(priced);
367
+ const biased = { ...snap, providerBias: { [snap.models[0]!.provider]: 0.1 } };
368
+ const run = (s: typeof snap) =>
369
+ buildCandidates({ req, features, tier: "moderate", task: "coding", snapshot: s, ledger: null, cfg: { ...BASE, adaptivePriceCeilings: true }, expectedCompletionTokens: 512, warmSlug: null });
370
+ // Unbiased: the band tightens moderate to $3 and a/4 is over it.
371
+ expect(run(snap).rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
372
+ // Biased ×0.1: $4 list is $0.40 to this deployment, so it clears the same ceiling.
373
+ const on = run(biased);
374
+ expect(on.candidates.map((c) => c.model.slug)).toContain("a/4");
375
+ expect(on.rejected.some((r) => r.slug === "a/4")).toBe(false);
376
+ });
377
+
378
+ test("a tool turn excludes tool-incapable models on the cheap tiers and ranks on agentic above them", () => {
379
+ // Two Ollama-priced models: the cheap one cannot drive a tool loop (agentic 1.4, as
380
+ // ollama/gpt-oss:20b really scores), the dearer one can (agentic 51.2, glm-5.3-flash).
381
+ const mk = (slug: string, price: number, intelligence: number, agentic: number) => ({
382
+ slug, canonicalSlug: slug, name: slug, provider: "ollama", vendor: "ollama",
383
+ contextLength: 131072, maxCompletionTokens: 32000, supportsTools: true, supportsReasoning: false,
384
+ reasoningMandatory: false, supportsToolChoice: true, inputModalities: ["text" as const],
385
+ price: { prompt: price / 1e6, completion: price / 1e6 }, priceTiers: [],
386
+ quality: { intelligence, coding: intelligence, agentic }, tokenizer: "Other",
387
+ isFree: false, createdAtMs: 0, author: "ollama",
388
+ });
389
+ const weak = mk("ollama/weak", 0.07, 9, 1.4);
390
+ const capable = mk("ollama/capable", 0.15, 41.9, 51.2);
391
+ const snap = { models: [weak, capable], fetchedAtMs: Date.now(), keyScoped: false };
392
+ const run = (tier: "trivial" | "moderate", min: number) =>
393
+ buildCandidates({
394
+ req, features, tier, task: "chat", snapshot: snap, ledger: null,
395
+ cfg: { ...BASE, filters: { ...BASE.filters, minAgenticForToolTurns: min } },
396
+ expectedCompletionTokens: 512, warmSlug: null,
397
+ });
398
+ // `trivial` ranks with qualityExponent 0 — cheapest above the floor — so the axis
399
+ // decides nothing there and only the agentic floor keeps the weak model out.
400
+ expect(run("trivial", 0).candidates[0]!.model.slug).toBe("ollama/weak");
401
+ const floored = run("trivial", 25);
402
+ expect(floored.candidates.map((c) => c.model.slug)).toEqual(["ollama/capable"]);
403
+ expect(floored.rejected.some((x) => x.slug === "ollama/weak" && (x.detail ?? "").includes("agentic 1.4 < 25"))).toBe(true);
404
+ // Above the cheap tiers quality does carry weight, and there the turn is ranked on
405
+ // agentic: the capable model leads even though it costs more than twice as much.
406
+ const ranked = run("moderate", 0);
407
+ expect(ranked.candidates[0]!.model.slug).toBe("ollama/capable");
408
+ expect(ranked.candidates[0]!.reasons.join(" ")).toContain("ranked on agentic");
409
+ });
361
410
  });
362
411
 
363
412
  describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
package/test/turn.test.ts CHANGED
@@ -49,7 +49,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
49
49
  data: { axis: "intelligence", minQuality: 0 },
50
50
  chat: { axis: "intelligence", minQuality: 0 },
51
51
  },
52
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
52
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
53
53
  classifier: {
54
54
  ambiguityThreshold: 0,
55
55
  model: "test/adjudicator", learnedModelPath: "",