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 +1 -1
- package/src/config/defaults.ts +3 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +19 -0
- package/src/router/select.ts +14 -1
- package/test/failover.test.ts +1 -1
- package/test/select.test.ts +78 -0
- package/test/turn.test.ts +1 -1
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -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
|
package/src/config/schema.ts
CHANGED
|
@@ -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({
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
/**
|
package/src/router/select.ts
CHANGED
|
@@ -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 (
|
|
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,
|
package/test/failover.test.ts
CHANGED
|
@@ -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 },
|
package/test/select.test.ts
CHANGED
|
@@ -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 },
|