auto-model-router 0.2.29 → 0.2.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.29",
3
+ "version": "0.2.30",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -135,6 +135,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
135
135
  // OpenRouter sticky sessions expire in 5-10 minutes.
136
136
  cacheWarmTtlMs: 300_000,
137
137
  maxDowngradePerTurn: 1,
138
+ // Off: breaking a hold means a model switch, which costs a cache write.
139
+ // Enable where the held tier is expensive; see HysteresisConfig.
140
+ breakHoldOnMechanical: false,
138
141
  },
139
142
  exploration: {
140
143
  // Opt-in. Exploration knowingly routes some turns below the tier that
@@ -111,6 +111,7 @@ const hysteresis = z.strictObject({
111
111
  switchMargin: z.number().positive().optional(),
112
112
  cacheWarmTtlMs: z.number().nonnegative().optional(),
113
113
  maxDowngradePerTurn: z.number().int().nonnegative().optional(),
114
+ breakHoldOnMechanical: z.boolean().optional(),
114
115
  });
115
116
 
116
117
  const exploration = z.strictObject({
@@ -287,6 +287,25 @@ export interface HysteresisConfig {
287
287
  cacheWarmTtlMs: number;
288
288
  /** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
289
289
  maxDowngradePerTurn: number;
290
+ /**
291
+ * Let a mechanical tool-result continuation escape a hold that sits above its
292
+ * own classification.
293
+ *
294
+ * A hold bets that the next turn resembles the one that armed it, and it is
295
+ * usually right — flapping cold-starts prompt caches. But a continuation the
296
+ * classifier has already docked for being a mechanical next step, and whose
297
+ * score lands below the held tier, is evidence against that bet. Measured on
298
+ * 24h of live traffic: 37 of 44 sticky `hard` dispatches were exactly that,
299
+ * one scoring 0.154 (trivial) yet served by claude-opus-5 — $2.66 billed
300
+ * against $0.05 for the identical tokens on the moderate pick.
301
+ *
302
+ * Off by default: breaking a hold means a model switch, and switching costs a
303
+ * cache write. Worth it when the held tier is expensive, not obviously worth
304
+ * it when the tiers are close, so it is opt-in per deployment.
305
+ * `maxDowngradePerTurn` still applies, so quality steps down rather than
306
+ * falling off a cliff.
307
+ */
308
+ breakHoldOnMechanical: boolean;
290
309
  }
291
310
 
292
311
  /**
@@ -117,10 +117,23 @@ export function select(args: SelectArgs): Decision {
117
117
 
118
118
  // 2. Hysteresis: while the sticky window is open, never route below the
119
119
  // held tier — per-turn flapping would repeatedly cold-start prompt caches.
120
+ //
121
+ // Exception, when `breakHoldOnMechanical` is on: a hold is a bet that the
122
+ // NEXT turn resembles the one that armed it. A tool-result continuation
123
+ // whose own score lands below the held tier is direct evidence against
124
+ // that bet — the classifier already docks it for being a mechanical next
125
+ // step — so paying the held tier for it buys nothing. Measured on 24h of
126
+ // live traffic: 37 of 44 sticky `hard` dispatches were exactly this shape,
127
+ // one scoring 0.154 (trivial) yet served by claude-opus-5; $2.66 billed
128
+ // against $0.05 for the identical tokens on the moderate pick.
120
129
  let cls = classification;
130
+ const mechanicalOverride =
131
+ cfg.hysteresis.breakHoldOnMechanical && features.isToolResultContinuation && tierIdx(effective) < tierIdx(clampTier(state.currentTier ?? effective));
121
132
  if (state.stickyUntilTurn > state.turn && state.currentTier !== null && tierIdx(state.currentTier) >= tierIdx(effective)) {
122
133
  const held = clampTier(state.currentTier);
123
- if (held !== effective) {
134
+ if (mechanicalOverride) {
135
+ reasons.push(`hysteresis hold ${held} broken: mechanical tool-result continuation classified ${effective}`);
136
+ } else if (held !== effective) {
124
137
  reasons.push(`hysteresis: holding ${held} until turn ${state.stickyUntilTurn} (classified ${effective})`);
125
138
  cls = {
126
139
  ...classification,
@@ -67,7 +67,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
67
67
  escalateOnLengthStop: false,
68
68
  ...escalation,
69
69
  },
70
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
70
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
71
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
73
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
@@ -743,3 +743,81 @@ describe("context compaction", () => {
743
743
  expect(tight.promptTokensSaved).toBeGreaterThan(loose.promptTokensSaved);
744
744
  });
745
745
  });
746
+
747
+ describe("hysteresis.breakHoldOnMechanical", () => {
748
+ // A hold bets the next turn resembles the one that armed it. A tool-result
749
+ // continuation the classifier has already docked, scoring below the held
750
+ // tier, is evidence against that bet. Measured on 24h of live traffic: 37 of
751
+ // 44 sticky `hard` dispatches were exactly that shape — one scoring 0.154
752
+ // (trivial) yet served by claude-opus-5 — $2.66 billed against $0.05 for the
753
+ // same tokens on the moderate pick.
754
+ function continuation(): NormRequest {
755
+ return parseChatRequest(
756
+ {
757
+ model: "auto",
758
+ tools: TOOLS,
759
+ messages: [
760
+ { role: "system", content: "You are a coding agent." },
761
+ { role: "user", content: "read the file" },
762
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: '{"path":"a.ts"}' } }] },
763
+ { role: "tool", tool_call_id: "c1", content: "export const x = 1;" },
764
+ ],
765
+ },
766
+ new Headers(),
767
+ );
768
+ }
769
+
770
+ const held = state({ currentTier: "hard", currentSlug: "x-ai/grok-4.6", stickyUntilTurn: 9, turn: 1 });
771
+
772
+ function decide(req: NormRequest, breakHold: boolean) {
773
+ const cfg: RouterConfig = { ...BASE, hysteresis: { ...BASE.hysteresis, breakHoldOnMechanical: breakHold } };
774
+ const features = extractFeatures(req, 4_000);
775
+ return { d: select({ req, features, classification: scoreHeuristic(features, cfg), profile: PROFILE, state: held, snapshot: SNAPSHOT, ledger: null, cfg, nowMs: Date.now() }), features };
776
+ }
777
+
778
+ test("off by default, so a hold still pins the tier", () => {
779
+ expect(BASE.hysteresis.breakHoldOnMechanical).toBe(false);
780
+ const { d, features } = decide(continuation(), false);
781
+ expect(features.isToolResultContinuation).toBe(true);
782
+ expect(d.tier).toBe("hard");
783
+ expect(d.classification.source).toBe("sticky");
784
+ });
785
+
786
+ test("on, a mechanical continuation escapes the hold", () => {
787
+ const { d } = decide(continuation(), true);
788
+ expect(d.tier).not.toBe("hard");
789
+ expect(d.classification.source).not.toBe("sticky");
790
+ expect(d.reasons.some((r) => /hold hard broken/.test(r))).toBe(true);
791
+ });
792
+
793
+ test("a NON-mechanical turn still gets the hold, so flap protection survives", () => {
794
+ // This is the case hysteresis exists for: fresh user work mid-conversation
795
+ // must not bounce the model and cold-start its cache.
796
+ const { d, features } = decide(request("now refactor the retry helper"), true);
797
+ expect(features.isToolResultContinuation).toBe(false);
798
+ expect(d.tier).toBe("hard");
799
+ expect(d.classification.source).toBe("sticky");
800
+ });
801
+
802
+ test("the downgrade clamp still applies, so quality steps rather than falls", () => {
803
+ const cfg: RouterConfig = {
804
+ ...BASE,
805
+ hysteresis: { ...BASE.hysteresis, breakHoldOnMechanical: true, maxDowngradePerTurn: 1 },
806
+ };
807
+ const req = continuation();
808
+ const features = extractFeatures(req, 4_000);
809
+ // Force the fresh classification far below the hold to exercise the clamp.
810
+ const d = select({
811
+ req,
812
+ features,
813
+ classification: { ...scoreHeuristic(features, cfg), tier: "trivial" },
814
+ profile: PROFILE,
815
+ state: held,
816
+ snapshot: SNAPSHOT,
817
+ ledger: null,
818
+ cfg,
819
+ nowMs: Date.now(),
820
+ });
821
+ expect(d.tier).toBe("moderate");
822
+ });
823
+ });
package/test/turn.test.ts CHANGED
@@ -68,7 +68,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
68
68
  escalateOnLengthStop: false,
69
69
  ...escalation,
70
70
  },
71
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
71
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
72
72
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
73
73
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
74
74
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },