auto-model-router 0.7.1 → 0.7.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.
@@ -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.7.1",
10
+ "version": "0.7.2",
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.7.1",
17
+ "version": "0.7.2",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -907,6 +907,7 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
907
907
  | `minTrustSamples` | `12` | Attempts before trust is enforced. |
908
908
  | `trustScopedByHarness` | `false` | `true` = each harness reads only its own trust rows. |
909
909
  | `contextHeadroom` | `1.25` | Fraction of context kept free (a model must fit prompt × this). |
910
+ | `reasoningCompletionFloor` | `512` | Smallest completion budget a **reasoning** model is dispatched with. A reasoning model spends the budget thinking before it answers, so a caller's tight cap (omp asks ~12 tokens for a conversation title) returns nothing: measured on `ollama/gpt-oss:20b`, which hit the cap having produced no content and cost a dead dispatch plus a failover. Raised only for models that reason, never above the model's own ceiling, and a cap is an upper bound so a direct answer still stops early. 0 disables. |
910
911
  | `latencyWeight` | `0` | How hard to penalise slow models in scoring (soft multiplier on effective cost). `0` disables it. |
911
912
  | `latencyMinSamples` | `20` | Streamed samples before latency is judged against a model. |
912
913
  | `cacheReliabilityMinSamples` | `10` | Warm-expected samples before a model's observed cache hit rate discounts its "stay warm" price in the stay/switch comparison. A model whose cache misses when it should be warm (measured: 5-6% on glm/gemini, 11% on ling, 50% on nex) is kept less eagerly. `0` assumes every cache is reliable. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -189,6 +189,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
189
189
  { path: "filters.trustScopedByHarness", label: "Scope trust per harness", kind: "boolean" },
190
190
  { path: "filters.trustWindowDays", label: "Trust window", kind: "number", min: 0, hint: "days, 0=all time" },
191
191
  { path: "filters.contextHeadroom", label: "Context headroom", kind: "number", min: 1 },
192
+ { path: "filters.reasoningCompletionFloor", label: "Reasoning completion floor", kind: "number", min: 0 },
192
193
  { path: "filters.latencyWeight", label: "Latency weight", kind: "number", min: 0, hint: "0=ignore speed" },
193
194
  { path: "filters.latencyReferenceMs", label: "Latency reference TTFT", kind: "number", min: 1, hint: "ms" },
194
195
  { path: "filters.latencyReferenceTokensPerSec", label: "Latency reference speed", kind: "number", min: 1, hint: "tok/s" },
@@ -114,6 +114,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
114
114
  // opt-in after a replay run prices it.
115
115
  trustWindowDays: 0,
116
116
  contextHeadroom: 1.25,
117
+ // Enough for a short chain of thought plus a brief answer. Measured against
118
+ // the failure it fixes: gpt-oss:20b and nemotron-3-nano returned empty at a
119
+ // 12-token cap, then failed over.
120
+ reasoningCompletionFloor: 512,
117
121
  // Latency scoring is off by default (weight 0): opt in after establishing a
118
122
  // baseline. Expected total wait (TTFT + expected completion / throughput)
119
123
  // above the references inflates a model's effective cost.
@@ -95,6 +95,7 @@ const filters = z.strictObject({
95
95
  trustScopedByHarness: z.boolean().optional(),
96
96
  trustWindowDays: z.number().nonnegative().optional(),
97
97
  contextHeadroom: z.number().positive().optional(),
98
+ reasoningCompletionFloor: z.number().int().nonnegative().optional(),
98
99
  latencyWeight: z.number().nonnegative().optional(),
99
100
  latencyReferenceMs: z.number().positive().optional(),
100
101
  latencyReferenceTokensPerSec: z.number().positive().optional(),
@@ -221,6 +221,21 @@ 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
+ * Smallest completion budget a REASONING model is dispatched with, tokens.
226
+ *
227
+ * A reasoning model spends the budget thinking before it answers, so a
228
+ * caller's tight cap returns nothing at all: omp asks for ~12 tokens for a
229
+ * conversation title, and `ollama/gpt-oss:20b` hit the cap having produced
230
+ * no content, which cost a dead dispatch and a failover to another model.
231
+ * When the chosen model reasons and the caller asked for less than this, the
232
+ * dispatch is raised to this floor (never above the model's own completion
233
+ * ceiling). Models that answer directly keep the caller's cap.
234
+ *
235
+ * A cap is an upper bound, not a target — a model that answers in ten tokens
236
+ * still stops at ten. 0 disables the floor.
237
+ */
238
+ reasoningCompletionFloor: number;
224
239
  /** Drop models whose ledger success rate is below this, once `minTrustSamples` is met. */
225
240
  minTrust: number;
226
241
  /**
@@ -608,6 +608,18 @@ export function select(args: SelectArgs): Decision {
608
608
  // The ceiling is a hard limit anyway; passing it explicitly also caps runaway completions.
609
609
  maxTokens = maxTokens === undefined ? ceiling : Math.min(maxTokens, ceiling);
610
610
  }
611
+ // A reasoning model spends the budget thinking before it answers, so a caller's
612
+ // tight cap returns nothing and the turn fails over having paid for the dispatch.
613
+ // Raise it to the floor for those models only; the ceiling still wins.
614
+ const floor = cfg.filters.reasoningCompletionFloor;
615
+ const thinksBeforeAnswering = chosen.model.reasoningMandatory || (chosen.model.supportsReasoning && reasoning !== "off");
616
+ if (floor > 0 && thinksBeforeAnswering && maxTokens !== undefined && maxTokens < floor) {
617
+ const raised = ceiling === undefined ? floor : Math.min(floor, ceiling);
618
+ if (raised > maxTokens) {
619
+ reasons.push(`completion budget raised ${maxTokens} → ${raised}: ${chosen.model.slug} reasons before it answers`);
620
+ maxTokens = raised;
621
+ }
622
+ }
611
623
  const stripAssistantReasoning = !(chosen.model.supportsReasoning && REASONING_REPLAY_AUTHORS[chosen.model.author] === true);
612
624
 
613
625
  // The recorded forecast is the EXPECTED price of this dispatch, not the
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
46
46
  data: { axis: "intelligence", minQuality: 0 },
47
47
  chat: { axis: "intelligence", minQuality: 0 },
48
48
  },
49
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, 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: "",
@@ -82,9 +82,11 @@ function run(opts: {
82
82
  tier?: Tier;
83
83
  ledger?: Ledger | null;
84
84
  harnessId?: string;
85
+ maxTokens?: number;
85
86
  }) {
86
87
  const cfg = opts.cfg ?? BASE;
87
- const req = request(opts.userText ?? "tidy the retry helper");
88
+ const base = request(opts.userText ?? "tidy the retry helper");
89
+ const req = opts.maxTokens === undefined ? base : { ...base, maxTokens: opts.maxTokens };
88
90
  const features = extractFeatures(req, opts.promptTokens ?? 4000);
89
91
  const heuristic = scoreHeuristic(features, cfg);
90
92
  const classification = opts.tier === undefined ? heuristic : { ...heuristic, tier: opts.tier };
@@ -353,6 +355,31 @@ describe("decision shape", () => {
353
355
  }
354
356
  });
355
357
 
358
+ test("a reasoning model gets the completion floor; a direct one keeps the caller's cap", () => {
359
+ const usable = (m: (typeof MODELS)[number]): boolean => m.supportsTools && m.contextLength >= 32_000 && (m.maxCompletionTokens ?? 100_000) >= 4096;
360
+ const thinker = MODELS.find((m) => usable(m) && m.supportsReasoning);
361
+ const direct = MODELS.find((m) => usable(m) && !m.supportsReasoning && !m.reasoningMandatory);
362
+ expect(thinker).toBeDefined();
363
+ expect(direct).toBeDefined();
364
+ const withFloor = (slug: string, floor: number): RouterConfig => ({ ...BASE, filters: { ...BASE.filters, allow: [slug], reasoningCompletionFloor: floor } });
365
+
366
+ // omp asks for a dozen tokens for a title; a reasoning model would spend them thinking
367
+ // and return nothing, so the dispatch is raised.
368
+ const raised = run({ tier: "trivial", cfg: withFloor(thinker!.slug, 512), maxTokens: 12 });
369
+ expect(raised.slug).toBe(thinker!.slug);
370
+ expect(raised.maxTokens).toBe(512);
371
+ expect(raised.reasons.some((r) => r.includes("reasons before it answers"))).toBe(true);
372
+
373
+ // A model that answers directly is untouched: its cap is the caller's.
374
+ const kept = run({ tier: "trivial", cfg: withFloor(direct!.slug, 512), maxTokens: 12 });
375
+ expect(kept.slug).toBe(direct!.slug);
376
+ expect(kept.maxTokens).toBe(12);
377
+
378
+ // The floor never raises past what the caller already asked for, and 0 disables it.
379
+ expect(run({ tier: "trivial", cfg: withFloor(thinker!.slug, 512), maxTokens: 4000 }).maxTokens).toBe(4000);
380
+ expect(run({ tier: "trivial", cfg: withFloor(thinker!.slug, 0), maxTokens: 12 }).maxTokens).toBe(12);
381
+ });
382
+
356
383
  test("plans a probe for cheap tiers and leaves the top tier unprobed", () => {
357
384
  expect(run({ tier: "trivial" }).probe.enabled).toBe(true);
358
385
  // Nothing above `hard` to escalate into, so probing it would only add latency.
package/test/turn.test.ts CHANGED
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
46
46
  data: { axis: "intelligence", minQuality: 0 },
47
47
  chat: { axis: "intelligence", minQuality: 0 },
48
48
  },
49
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, 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: "",