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 +3 -2
- package/README.md +4 -3
- package/docs/AGENTDOX-BRIDGE.md +4 -3
- package/package.json +1 -1
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +22 -0
- package/src/router/classify.ts +14 -7
- package/test/classify.test.ts +30 -0
- package/test/failover.test.ts +1 -0
- package/test/turn.test.ts +1 -0
- package/tools/replay.ts +16 -4
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
|
|
123
|
-
`
|
|
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
|
|
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/
|
|
748
|
-
agentdox repo.
|
|
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
|
|
package/docs/AGENTDOX-BRIDGE.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# agentdox bridge — handoff
|
|
2
2
|
|
|
3
|
-
**Status:** implemented, typechecks clean,
|
|
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
|
-
`
|
|
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
package/src/config/defaults.ts
CHANGED
|
@@ -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,
|
package/src/config/schema.ts
CHANGED
|
@@ -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({
|
package/src/config/types.ts
CHANGED
|
@@ -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 {
|
package/src/router/classify.ts
CHANGED
|
@@ -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
|
-
/**
|
|
80
|
-
|
|
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
|
|
90
|
+
return w.medium;
|
|
84
91
|
case "high":
|
|
85
|
-
return
|
|
92
|
+
return w.high;
|
|
86
93
|
case "xhigh":
|
|
87
|
-
return
|
|
94
|
+
return w.xhigh;
|
|
88
95
|
case "max":
|
|
89
|
-
return
|
|
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`);
|
package/test/classify.test.ts
CHANGED
|
@@ -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);
|
package/test/failover.test.ts
CHANGED
|
@@ -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`
|
|
41
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
158
|
+
return { ...(f as Features), promptTokens };
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
/**
|