auto-model-router 0.2.28 → 0.2.29

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/CLAUDE.md CHANGED
@@ -119,8 +119,9 @@ read/write client is `src/context/agentdox.ts` (assemble / createSession / appen
119
119
  Beyond consuming agentdox as an agent, `src/context/` is the **router↔agentdox bridge**: it
120
120
  injects shared project context into every routed turn and records turns back, attributed to
121
121
  the model that served them. See `docs/AGENTDOX-BRIDGE.md` for the current state, how to run
122
- it, and the open issue. Design rationale lives in
123
- `E:/projects/agentdox/docs/architecture/router-context-bridge.md`.
122
+ it, and the open issue. Design rationale is the **decision log in the agentdox project brief**
123
+ for scope `omp-router` (read it with `context_brief`) — the bridge decisions and the evidence
124
+ behind them are recorded there as they are made.
124
125
 
125
126
  Turning the bridge on for the router itself (distinct from the MCP wiring above):
126
127
 
package/README.md CHANGED
@@ -677,7 +677,7 @@ has none of the project knowledge the last one built up. Because every harness
677
677
  routes through this one provider, the router is the single place that can fix
678
678
  that for all of them at once.
679
679
 
680
- Point it at an [agentdox](https://github.com/…/agentdox) server and every turn —
680
+ Point it at an [agentdox](https://github.com/drewappling/agentdox) server and every turn —
681
681
  whatever model wins the routing decision — carries the same project memory, docs,
682
682
  and brief:
683
683
 
@@ -744,8 +744,9 @@ not a dependency. If it is unreachable the turn routes and dispatches normally,
744
744
  and a pinned block keeps being served.
745
745
 
746
746
  `GET /health` reports the bridge's URL, default scope, and `recordTurns` — never
747
- the token. Design notes: `docs/architecture/router-context-bridge.md` in the
748
- agentdox repo. Live check: `bun tools/agentdox-e2e.ts`.
747
+ the token. Design notes: [`docs/AGENTDOX-BRIDGE.md`](docs/AGENTDOX-BRIDGE.md).
748
+ Server side: the [agentdox repo](https://github.com/drewappling/agentdox).
749
+ Live check: `bun tools/agentdox-e2e.ts`.
749
750
 
750
751
  ---
751
752
 
@@ -1,10 +1,11 @@
1
1
  # agentdox bridge — handoff
2
2
 
3
- **Status:** implemented, typechecks clean, 439 tests pass, injection verified end-to-end
3
+ **Status:** implemented, typechecks clean, 502 tests pass, injection verified end-to-end
4
4
  through omp. The write-back faults in §5 and §6 are **fixed**; `context.recordTurns` is on.
5
5
 
6
- Design rationale (why it is built this way):
7
- `E:/projects/agentdox/docs/architecture/router-context-bridge.md`.
6
+ Design rationale (why it is built this way) is the decision log in the agentdox project
7
+ brief for scope `omp-router` — read it with `context_brief`. The agentdox server itself:
8
+ [github.com/drewappling/agentdox](https://github.com/drewappling/agentdox).
8
9
 
9
10
  ---
10
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -98,6 +98,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
98
98
  toolAxis: "coding",
99
99
  chatAxis: "intelligence",
100
100
  agenticLoopDepth: 3,
101
+ // Shipped values, unchanged. See ClassifierConfig.reasoningWeights: a
102
+ // harness that pins the level for a whole session turns these into a
103
+ // constant tier offset, in which case `medium` belongs near 0.
104
+ reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
101
105
  },
102
106
  escalation: {
103
107
  enabled: true,
@@ -85,6 +85,14 @@ const classifier = z.strictObject({
85
85
  toolAxis: qualityAxis.optional(),
86
86
  chatAxis: qualityAxis.optional(),
87
87
  agenticLoopDepth: z.number().int().nonnegative().optional(),
88
+ reasoningWeights: z
89
+ .strictObject({
90
+ medium: z.number().nonnegative().optional(),
91
+ high: z.number().nonnegative().optional(),
92
+ xhigh: z.number().nonnegative().optional(),
93
+ max: z.number().nonnegative().optional(),
94
+ })
95
+ .optional(),
88
96
  });
89
97
 
90
98
  const escalation = z.strictObject({
@@ -229,6 +229,28 @@ export interface ClassifierConfig {
229
229
  chatAxis: QualityAxis;
230
230
  /** Tool-loop depth above which the agentic axis takes over. */
231
231
  agenticLoopDepth: number;
232
+ /**
233
+ * Score added when the CLIENT asks for a reasoning effort, per level. The
234
+ * premise is that asking for reasoning states expected difficulty directly.
235
+ *
236
+ * That premise fails when a harness sets the level once for a whole session:
237
+ * a constant cannot discriminate difficulty between turns, but it still
238
+ * shifts every turn's score. Measured on a live day: the requested level
239
+ * never changed within 111 of 115 conversations, `medium` (+0.14, over half
240
+ * of a 0.25-wide tier band) rode on 41.6% of dispatches, and 64 of 119
241
+ * `hard` dispatches reached that tier ONLY because of it — $6.66 billed
242
+ * against $0.16 for the same tokens on the moderate pick.
243
+ *
244
+ * Tune per deployment: a harness that raises the level deliberately for hard
245
+ * turns wants these weights, one that pins it session-wide wants `medium`
246
+ * near zero. Defaults preserve the shipped behaviour.
247
+ */
248
+ reasoningWeights: {
249
+ medium: number;
250
+ high: number;
251
+ xhigh: number;
252
+ max: number;
253
+ };
232
254
  }
233
255
 
234
256
  export interface EscalationConfig {
@@ -76,17 +76,24 @@ const W_TOOLS_OFFERED = 0.03;
76
76
  /** Score bucket boundaries: [trivial, simple, moderate, hard]. */
77
77
  const BOUNDARIES: readonly [number, number, number] = [0.25, 0.5, 0.75];
78
78
 
79
- /** A client that asks for reasoning is stating expected difficulty directly. */
80
- function reasoningWeight(level: ReasoningLevel | undefined): number {
79
+ /**
80
+ * Score for a client-stated reasoning effort. The premise is that asking for
81
+ * reasoning states expected difficulty — true when a harness raises the level
82
+ * for a hard turn, false when it pins one level for the whole session, where the
83
+ * "signal" is a constant that lifts every turn's score. Weights are therefore
84
+ * configurable per deployment; see ClassifierConfig.reasoningWeights.
85
+ */
86
+ function reasoningWeight(level: ReasoningLevel | undefined, cfg: RouterConfig): number {
87
+ const w = cfg.classifier.reasoningWeights;
81
88
  switch (level) {
82
89
  case "medium":
83
- return 0.14;
90
+ return w.medium;
84
91
  case "high":
85
- return 0.24;
92
+ return w.high;
86
93
  case "xhigh":
87
- return 0.3;
94
+ return w.xhigh;
88
95
  case "max":
89
- return 0.34;
96
+ return w.max;
90
97
  default:
91
98
  // off/minimal/low/undefined: no stated difficulty above the baseline.
92
99
  return 0;
@@ -113,7 +120,7 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
113
120
  );
114
121
  if (f.lastToolFailed) add(W_TOOL_FAILED, "last tool result failed");
115
122
  if (f.circularToolCall) add(W_CIRCULAR_LOOP, "circular tool call (re-issued a prior call; stuck)");
116
- const rw = reasoningWeight(f.requestedReasoning);
123
+ const rw = reasoningWeight(f.requestedReasoning, cfg);
117
124
  if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
118
125
  if (f.isTerseInstruction) add(W_TERSE, "terse instruction");
119
126
  add(Math.min(f.codeBlocks * W_CODE_BLOCK, CAP_CODE), `${f.codeBlocks} code block(s) in new content`);
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
3
4
  import { loadConfig } from "../src/config/load.ts";
4
5
  import type { RouterConfig } from "../src/config/types.ts";
5
6
  import { classify, classifyTask, pickQualityAxis, scoreHeuristic } from "../src/router/classify.ts";
@@ -178,6 +179,35 @@ describe("scoreHeuristic", () => {
178
179
  expect(thinking.score).toBeGreaterThan(plain.score);
179
180
  });
180
181
 
182
+ test("the reasoning weight is configurable, so a session-wide level can be discounted", () => {
183
+ // A harness that pins one reasoning level for a whole session turns this
184
+ // "signal" into a constant that lifts every turn's score. Measured live:
185
+ // the level never changed within 111 of 115 conversations, and 64 of 119
186
+ // hard dispatches reached that tier ONLY via the weight — $6.66 billed
187
+ // against $0.16 for the same tokens on the moderate pick.
188
+ //
189
+ // DEFAULT_CONFIG, not BASE: BASE is loadConfig({}), which reads this
190
+ // machine's real config.yml, and this assertion is about shipped values.
191
+ const features = extractFeatures(
192
+ parseChatRequest(
193
+ { model: "auto", tools: TOOLS, reasoning_effort: "medium", messages: [SYSTEM, { role: "user", content: "tidy this up" }] },
194
+ new Headers(),
195
+ ),
196
+ 5000,
197
+ );
198
+ const shipped = scoreHeuristic(features, DEFAULT_CONFIG);
199
+ const discounted = scoreHeuristic(features, {
200
+ ...DEFAULT_CONFIG,
201
+ classifier: { ...DEFAULT_CONFIG.classifier, reasoningWeights: { ...DEFAULT_CONFIG.classifier.reasoningWeights, medium: 0 } },
202
+ });
203
+ expect(shipped.score - discounted.score).toBeCloseTo(DEFAULT_CONFIG.classifier.reasoningWeights.medium, 5);
204
+ expect(discounted.reasons.some((r) => /requested reasoning/.test(r))).toBe(false);
205
+ });
206
+
207
+ test("ships with the historical weights, so enabling a discount is opt-in", () => {
208
+ expect(DEFAULT_CONFIG.classifier.reasoningWeights).toEqual({ medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 });
209
+ });
210
+
181
211
  test("always produces a bounded score, a real tier, and its reasoning", () => {
182
212
  const c = scoreHeuristic(featuresFor([SYSTEM, { role: "user", content: "hello" }]), BASE);
183
213
  expect(c.score).toBeGreaterThanOrEqual(0);
@@ -55,6 +55,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
55
55
  toolAxis: "coding",
56
56
  chatAxis: "intelligence",
57
57
  agenticLoopDepth: 3,
58
+ reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
58
59
  },
59
60
  escalation: {
60
61
  enabled: true,
package/test/turn.test.ts CHANGED
@@ -56,6 +56,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
56
56
  toolAxis: "coding",
57
57
  chatAxis: "intelligence",
58
58
  agenticLoopDepth: 3,
59
+ reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
59
60
  },
60
61
  escalation: {
61
62
  enabled: true,
package/tools/replay.ts CHANGED
@@ -37,8 +37,10 @@
37
37
  * size (`usage.promptTokens`), i.e. the prompt selection actually saw.
38
38
  * - `stickyUntilTurn` was never persisted per turn, so the hysteresis hold
39
39
  * window is absent. This is the main residual gap.
40
- * - `requestedReasoning` is the one `Features` field the ledger omits; it
41
- * replays as undefined.
40
+ * - `requestedReasoning` IS recorded and is now used. It was previously forced
41
+ * to undefined here on the belief the ledger omitted it, which under-scored
42
+ * ~42% of dispatches and reproduced 27 hard decisions against 120 served.
43
+ * Treat replay numbers produced before that fix as biased toward cheap tiers.
42
44
  * - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
43
45
  * be A/B'd via `--set` — only `RouterConfig` paths can.
44
46
  *
@@ -140,10 +142,20 @@ interface Row {
140
142
  created_at_ms: number;
141
143
  }
142
144
 
143
- /** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
145
+ /**
146
+ * Rebuilds the classifier input from the recorded blob.
147
+ *
148
+ * `requestedReasoning` IS recorded (JSON.stringify only drops it when the client
149
+ * sent no level), and it must be used: it is worth up to +0.34 of score, rides
150
+ * on ~42% of dispatches, and forcing it to undefined — as this did, on the
151
+ * assumption the ledger omitted it — under-scored every one of those rows.
152
+ * Measured effect of the bug: replay reproduced 27 hard-tier decisions against
153
+ * 120 actually served, i.e. it silently biased every comparison toward cheaper
154
+ * tiers and made reasoning-weight changes look like no-ops.
155
+ */
144
156
  function featuresOf(row: Row, promptTokens: number): Features {
145
157
  const f = JSON.parse(row.features) as Partial<Features>;
146
- return { ...(f as Features), promptTokens, requestedReasoning: undefined };
158
+ return { ...(f as Features), promptTokens };
147
159
  }
148
160
 
149
161
  /**