auto-model-router 0.2.13 → 0.2.14
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/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/config/defaults.ts +9 -0
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +4 -0
- package/src/context/agentdox.ts +16 -3
- package/src/context/bridge.ts +9 -2
- package/src/context/index.ts +2 -0
- package/test/context-bridge.test.ts +25 -2
- package/test/failover.test.ts +1 -1
- package/test/turn.test.ts +1 -1
|
@@ -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.2.
|
|
10
|
+
"version": "0.2.14",
|
|
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.2.
|
|
17
|
+
"version": "0.2.14",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -161,6 +161,15 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
161
161
|
// faster than the server reassembles buys nothing but cache misses.
|
|
162
162
|
maxStalenessMs: 900_000,
|
|
163
163
|
maxBlockChars: 24_000,
|
|
164
|
+
// Bound what agentdox SELECTS, rather than letting the block grow and then
|
|
165
|
+
// slicing it at `maxBlockChars`. Byte truncation cuts an entry mid-sentence
|
|
166
|
+
// and is blind to relevance; a limit lets the server rank first. Left
|
|
167
|
+
// unbounded, this scope reached 15 memory entries = 23.5k chars (~5.9k
|
|
168
|
+
// tokens) injected into every turn, against a 24k cap it was about to hit.
|
|
169
|
+
memoryLimit: 8,
|
|
170
|
+
// Session messages are cheap today but grow once recordTurns is on, and
|
|
171
|
+
// they feed straight back into the next assembly.
|
|
172
|
+
sessionLimit: 6,
|
|
164
173
|
recordTurns: true,
|
|
165
174
|
maxQueue: 64,
|
|
166
175
|
},
|
package/src/config/schema.ts
CHANGED
|
@@ -138,6 +138,8 @@ const context = z.strictObject({
|
|
|
138
138
|
timeoutMs: z.number().int().positive().optional(),
|
|
139
139
|
maxStalenessMs: z.number().int().nonnegative().optional(),
|
|
140
140
|
maxBlockChars: z.number().int().positive().optional(),
|
|
141
|
+
memoryLimit: z.number().int().positive().optional(),
|
|
142
|
+
sessionLimit: z.number().int().nonnegative().optional(),
|
|
141
143
|
recordTurns: z.boolean().optional(),
|
|
142
144
|
maxQueue: z.number().int().positive().optional(),
|
|
143
145
|
});
|
package/src/config/types.ts
CHANGED
|
@@ -372,6 +372,10 @@ export interface ContextConfig {
|
|
|
372
372
|
maxStalenessMs: number;
|
|
373
373
|
/** Hard cap on injected block size, characters. */
|
|
374
374
|
maxBlockChars: number;
|
|
375
|
+
/** Max memory entries agentdox may select for the block. */
|
|
376
|
+
memoryLimit: number;
|
|
377
|
+
/** Max recent session messages agentdox may select for the block. */
|
|
378
|
+
sessionLimit: number;
|
|
375
379
|
/** Write settled turns back to agentdox sessions, tagged with the served model. */
|
|
376
380
|
recordTurns: boolean;
|
|
377
381
|
/** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */
|
package/src/context/agentdox.ts
CHANGED
|
@@ -15,13 +15,19 @@ export interface AgentDoxClientOptions {
|
|
|
15
15
|
log: Logger;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Bounds on what agentdox may select for one block. */
|
|
19
|
+
export interface AssembleLimits {
|
|
20
|
+
memoryLimit: number;
|
|
21
|
+
sessionLimit: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export interface AgentDoxClient {
|
|
19
25
|
/**
|
|
20
26
|
* Assembles a context slice for `scope`, biased by `query`. Falls back to
|
|
21
27
|
* the server's pre-assembled baseline when assembly is unavailable (older
|
|
22
28
|
* server, or no query-relevant content).
|
|
23
29
|
*/
|
|
24
|
-
assemble(scope: string, query: string): Promise<string | null>;
|
|
30
|
+
assemble(scope: string, query: string, limits: AssembleLimits): Promise<string | null>;
|
|
25
31
|
createSession(scope: string, title: string): Promise<string | null>;
|
|
26
32
|
append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise<boolean>;
|
|
27
33
|
}
|
|
@@ -75,8 +81,15 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
|
|
|
75
81
|
};
|
|
76
82
|
|
|
77
83
|
return {
|
|
78
|
-
async assemble(scope, query) {
|
|
79
|
-
|
|
84
|
+
async assemble(scope, query, limits) {
|
|
85
|
+
// camelCase: the REST endpoint ignores snake_case limit keys entirely,
|
|
86
|
+
// which silently reads as "unbounded".
|
|
87
|
+
const res = await request("POST", "/context/assemble", {
|
|
88
|
+
scope,
|
|
89
|
+
query,
|
|
90
|
+
memoryLimit: limits.memoryLimit,
|
|
91
|
+
sessionLimit: limits.sessionLimit,
|
|
92
|
+
});
|
|
80
93
|
if (res !== null && res.status === 200) {
|
|
81
94
|
const prompt = promptOf(res.json);
|
|
82
95
|
if (prompt !== null) return prompt;
|
package/src/context/bridge.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface BridgeOptions {
|
|
|
30
30
|
maxStalenessMs: number;
|
|
31
31
|
/** Hard cap on injected block size; a runaway context must not dominate the prompt. */
|
|
32
32
|
maxBlockChars: number;
|
|
33
|
+
/**
|
|
34
|
+
* Bounds on what agentdox SELECTS. Preferred over `maxBlockChars`, which can
|
|
35
|
+
* only slice bytes: the server ranks by relevance, so a limit drops the least
|
|
36
|
+
* useful entry instead of severing whatever straddles the cap.
|
|
37
|
+
*/
|
|
38
|
+
memoryLimit: number;
|
|
39
|
+
sessionLimit: number;
|
|
33
40
|
/** Record settled turns back into agentdox sessions. */
|
|
34
41
|
recordTurns: boolean;
|
|
35
42
|
/** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
|
|
@@ -73,7 +80,7 @@ function appendFragment(prior: string, next: string): string {
|
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
76
|
-
const { client, store, log, maxStalenessMs, maxBlockChars, recordTurns, maxQueue } = opts;
|
|
83
|
+
const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, sessionLimit, recordTurns, maxQueue } = opts;
|
|
77
84
|
|
|
78
85
|
// Serialized write-back queue. Session appends for one conversation must
|
|
79
86
|
// stay ordered, and agentdox is a local service — one worker is plenty.
|
|
@@ -111,7 +118,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
111
118
|
return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
const raw = await client.assemble(input.scope, input.query);
|
|
121
|
+
const raw = await client.assemble(input.scope, input.query, { memoryLimit, sessionLimit });
|
|
115
122
|
if (raw === null) {
|
|
116
123
|
// agentdox unreachable or empty. Keep serving the pinned block if we
|
|
117
124
|
// have one: stale shared context beats none, and re-using it also
|
package/src/context/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Context
|
|
|
27
27
|
log,
|
|
28
28
|
maxStalenessMs: c.maxStalenessMs,
|
|
29
29
|
maxBlockChars: c.maxBlockChars,
|
|
30
|
+
memoryLimit: c.memoryLimit,
|
|
31
|
+
sessionLimit: c.sessionLimit,
|
|
30
32
|
recordTurns: c.recordTurns,
|
|
31
33
|
maxQueue: c.maxQueue,
|
|
32
34
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import type { AgentDoxClient } from "../src/context/agentdox.ts";
|
|
3
|
+
import type { AgentDoxClient, AssembleLimits } from "../src/context/agentdox.ts";
|
|
4
4
|
import { createContextBridge } from "../src/context/bridge.ts";
|
|
5
5
|
import { createContextStore } from "../src/context/store.ts";
|
|
6
6
|
import type { ContextResolveInput, TurnRecord } from "../src/context/types.ts";
|
|
@@ -15,16 +15,19 @@ interface FakeClient extends AgentDoxClient {
|
|
|
15
15
|
appended: { sessionId: string; role: string; content: string; refs: string[] }[];
|
|
16
16
|
sessionsCreated: number;
|
|
17
17
|
prompt: string;
|
|
18
|
+
lastLimits: AssembleLimits | null;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function mkClient(prompt = "MEMORY: player digs in 3/4 top-down"): FakeClient {
|
|
21
22
|
const c: FakeClient = {
|
|
22
23
|
assembleCalls: 0,
|
|
24
|
+
lastLimits: null,
|
|
23
25
|
appended: [],
|
|
24
26
|
sessionsCreated: 0,
|
|
25
27
|
prompt,
|
|
26
|
-
async assemble() {
|
|
28
|
+
async assemble(_scope, _query, limits) {
|
|
27
29
|
c.assembleCalls++;
|
|
30
|
+
c.lastLimits = limits;
|
|
28
31
|
return c.prompt;
|
|
29
32
|
},
|
|
30
33
|
async createSession() {
|
|
@@ -49,6 +52,8 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
|
|
|
49
52
|
log,
|
|
50
53
|
maxStalenessMs: 900_000,
|
|
51
54
|
maxBlockChars: 24_000,
|
|
55
|
+
memoryLimit: 8,
|
|
56
|
+
sessionLimit: 6,
|
|
52
57
|
recordTurns: true,
|
|
53
58
|
maxQueue: 64,
|
|
54
59
|
...over,
|
|
@@ -89,6 +94,22 @@ describe("context bridge refresh policy", () => {
|
|
|
89
94
|
}
|
|
90
95
|
});
|
|
91
96
|
|
|
97
|
+
test("assembly is bounded, so the block cannot grow until bytes get severed", async () => {
|
|
98
|
+
// The block reached 23.5k chars (~5.9k tokens, 15 memory entries) against a
|
|
99
|
+
// 24k maxBlockChars cap, at which point renderBlock slices mid-entry. Byte
|
|
100
|
+
// truncation is blind to relevance, so the server must be told to rank and
|
|
101
|
+
// select instead. The REST endpoint ignores snake_case limit keys, which
|
|
102
|
+
// silently reads as unbounded — hence pinning that the limits are passed.
|
|
103
|
+
const client = mkClient();
|
|
104
|
+
const { bridge, db } = mkBridge(client, { memoryLimit: 5, sessionLimit: 2 });
|
|
105
|
+
try {
|
|
106
|
+
await bridge.resolve(input());
|
|
107
|
+
expect(client.lastLimits).toEqual({ memoryLimit: 5, sessionLimit: 2 });
|
|
108
|
+
} finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
92
113
|
test("refreshes when the model switches, because the cache is already forfeit", async () => {
|
|
93
114
|
const client = mkClient();
|
|
94
115
|
const { bridge, db } = mkBridge(client);
|
|
@@ -217,6 +238,8 @@ describe("context bridge refresh policy", () => {
|
|
|
217
238
|
log,
|
|
218
239
|
maxStalenessMs: 900_000,
|
|
219
240
|
maxBlockChars: 24_000,
|
|
241
|
+
memoryLimit: 8,
|
|
242
|
+
sessionLimit: 6,
|
|
220
243
|
recordTurns: true,
|
|
221
244
|
maxQueue: 64,
|
|
222
245
|
};
|
package/test/failover.test.ts
CHANGED
|
@@ -69,7 +69,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
69
69
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
70
70
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
71
71
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
72
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, recordTurns: false, maxQueue: 64 },
|
|
72
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
|
|
73
73
|
compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
|
|
74
74
|
budget: { onExceeded: "downgrade" },
|
|
75
75
|
profiles: [],
|
package/test/turn.test.ts
CHANGED
|
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
70
70
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
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
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, recordTurns: false, maxQueue: 64 },
|
|
73
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
|
|
74
74
|
compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
|
|
75
75
|
budget: { onExceeded: "downgrade" },
|
|
76
76
|
profiles: [],
|