auto-model-router 0.4.0 → 0.4.2
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/README.md +32 -2
- package/omp-extension/digest-logic.ts +53 -0
- package/omp-extension/pi-coding-agent.d.ts +14 -1
- package/omp-extension/router-digest.ts +93 -0
- package/omp-extension/router-embed.ts +9 -6
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +18 -0
- package/src/config/defaults.ts +21 -0
- package/src/config/schema.ts +17 -0
- package/src/config/types.ts +51 -0
- package/src/cost/ledger.ts +39 -6
- package/src/cost/report.ts +28 -0
- package/src/cost/types.ts +4 -1
- package/src/router/classify.ts +3 -0
- package/src/router/features.ts +39 -0
- package/src/router/index.ts +11 -4
- package/src/router/types.ts +8 -0
- package/src/server/digest.ts +233 -0
- package/src/server/http.ts +22 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +2 -0
- package/test/classify.test.ts +13 -0
- package/test/config-wizard.test.ts +6 -5
- package/test/controls.test.ts +50 -1
- package/test/digest.test.ts +207 -0
- package/test/embed-lifecycle.test.ts +1 -1
- package/test/escalate.test.ts +1 -0
- package/test/failover.test.ts +5 -3
- package/test/features.test.ts +32 -0
- package/test/http-resilience.test.ts +1 -1
- package/test/report-hub.test.ts +5 -0
- package/test/report.test.ts +17 -0
- package/test/trust-attribution.test.ts +53 -0
- package/test/turn.test.ts +5 -3
- package/tools/replay.ts +1 -0
|
@@ -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.4.
|
|
10
|
+
"version": "0.4.2",
|
|
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.4.
|
|
17
|
+
"version": "0.4.2",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -213,6 +213,7 @@ extensions:
|
|
|
213
213
|
- auto-model-router/omp-extension/router-embed.ts
|
|
214
214
|
- auto-model-router/omp-extension/router-toast.ts # optional: chosen-model toasts
|
|
215
215
|
- auto-model-router/omp-extension/router-configure.ts # optional: /router config, report, status
|
|
216
|
+
- auto-model-router/omp-extension/router-digest.ts # optional: cheap-model digest of large tool results
|
|
216
217
|
```
|
|
217
218
|
|
|
218
219
|
### From the repo (cross-platform installer)
|
|
@@ -236,6 +237,7 @@ The installer adds:
|
|
|
236
237
|
- `router-embed.ts` — **required**; runs the router in-process.
|
|
237
238
|
- `router-toast.ts` — optional; chosen-model toasts.
|
|
238
239
|
- `router-configure.ts` — optional; the `/router` command (configure, usage reports, status).
|
|
240
|
+
- `router-digest.ts` — optional; condenses large tool results with a cheap model before an expensive one reads them (needs `digest.enabled`).
|
|
239
241
|
|
|
240
242
|
Or add the paths by hand to omp's `~/.omp/agent/config.yml`:
|
|
241
243
|
|
|
@@ -245,6 +247,7 @@ extensions:
|
|
|
245
247
|
- /path/to/auto-model-router/omp-extension/router-embed.ts
|
|
246
248
|
- /path/to/auto-model-router/omp-extension/router-toast.ts # optional: chosen-model toasts
|
|
247
249
|
- /path/to/auto-model-router/omp-extension/router-configure.ts # optional: /router config, report, status
|
|
250
|
+
- /path/to/auto-model-router/omp-extension/router-digest.ts # optional: cheap-model digest of large tool results
|
|
248
251
|
```
|
|
249
252
|
|
|
250
253
|
Then restart the omp session (extensions load at session start).
|
|
@@ -499,7 +502,7 @@ What it shows, for the window:
|
|
|
499
502
|
|
|
500
503
|
| Block | Columns |
|
|
501
504
|
| --- | --- |
|
|
502
|
-
| totals | spend, dispatches, conversations, $/dispatch, prompt and completion tokens, cache hit rate, model switches, escalations, failovers, errors (aborted separately) |
|
|
505
|
+
| totals | spend, dispatches, conversations, $/dispatch, prompt and completion tokens, cache hit rate, model switches, escalations, failovers, errors (aborted separately), subagent dispatches and their share of spend |
|
|
503
506
|
| prompt anatomy | mean share of prompt bytes by role (tool results, assistant, user, system), tool schemas beside them, the older half of the conversation, and tool results older than the newest 20 messages — what compaction can reach. Recorded per turn from v0.3.5. |
|
|
504
507
|
| providers | per upstream (`openrouter`, `ollama`): dispatches, spend, share, cache hit, mean TTFT, tokens/s, escalations, errors |
|
|
505
508
|
| models | per served slug (top 12 by spend): the same plus user feedback (`+good/-bad` from `/router good\|bad`) and the tier mix it was routed for |
|
|
@@ -592,6 +595,7 @@ what each one does. All values are optional; omit a key to use its default.
|
|
|
592
595
|
| `host` | `127.0.0.1` | Bind address. `0.0.0.0`/`::` listen on all interfaces (the provider still advertises loopback). |
|
|
593
596
|
| `port` | `0` | Bind port. `0` = let the OS pick a free ephemeral port (the embedded router's default). |
|
|
594
597
|
| `apiKey` | unset | Optional client bearer token. When set, every request must send `Authorization: Bearer <key>`. |
|
|
598
|
+
| `subagentProfile` | `auto-sub` | Profile omp subagents are routed under when they ask for the default one. The embed extension marks sessions without a UI with `X-Omp-Subagent: 1`; delegated work (reads, searches, summaries) never needs the top tier. Empty disables the remap. |
|
|
595
599
|
| `harnessId` | unset | Harness identity sent as `X-Omp-Harness`; scopes per-harness daily budgets and toasts. |
|
|
596
600
|
|
|
597
601
|
### `openrouter`
|
|
@@ -663,6 +667,7 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
|
|
|
663
667
|
| `deny` | `[]` | Glob denylist; matching slugs are excluded. |
|
|
664
668
|
| `includeFree` | `false` | Include free models (rate-limited hard; usually excluded). |
|
|
665
669
|
| `requireToolSupport` | `true` | Only models that support tool calls. |
|
|
670
|
+
| `feedbackWeight` | `0` | How much a `/router good\|bad` verdict weighs in a model's trust rate: a bad verdict counts as this many failures, a good one as this many successes. `0` records verdicts without acting on them. |
|
|
666
671
|
| `minTrust` | `0.7` | Minimum success rate; models below this (after `minTrustSamples`) are demoted. |
|
|
667
672
|
| `minTrustSamples` | `12` | Attempts before trust is enforced. |
|
|
668
673
|
| `trustScopedByHarness` | `false` | `true` = each harness reads only its own trust rows. |
|
|
@@ -688,6 +693,7 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
|
|
|
688
693
|
| `toolAxis` | `coding` | Quality axis for tool-heavy turns. |
|
|
689
694
|
| `chatAxis` | `intelligence` | Quality axis for chat turns. |
|
|
690
695
|
| `agenticLoopDepth` | `3` | Tool-loop depth at which a turn is treated as agentic. |
|
|
696
|
+
| `readOnlyToolWeight` | `0` | Score subtracted when a tool-result continuation follows an assistant turn that used only read-only tools (read, grep, glob, ls, lsp…). Recorded as `features.readOnlyToolTail` either way; enable after `tools/replay.ts` prices it. |
|
|
691
697
|
| `mechanicalRetryFactor` | `0.2` | Fraction of the failed-tool and circular-call weights kept on a tool-result continuation; `1` disables the damping. |
|
|
692
698
|
|
|
693
699
|
### `escalation` — mid-stream retry upward
|
|
@@ -767,13 +773,37 @@ Each profile is a complete entry (arrays replace wholesale):
|
|
|
767
773
|
|
|
768
774
|
| Key | Default | Meaning |
|
|
769
775
|
| --- | --- | --- |
|
|
770
|
-
| `id` | `auto` / `auto-cheap` / `auto-max` | Model id omp selects. |
|
|
776
|
+
| `id` | `auto` / `auto-cheap` / `auto-max` / `auto-sub` | Model id omp selects. `auto-sub` (trivial..moderate) is what subagents get via `server.subagentProfile`. |
|
|
771
777
|
| `name` | `Auto (auto-model-router)` etc. | Display name. |
|
|
772
778
|
| `minTier` / `maxTier` | `trivial`/`hard`, `trivial`/`simple`, `moderate`/`hard` | Tier envelope. |
|
|
773
779
|
| `contextWindow` | `400000` | Advertised context window (drives omp's compaction). |
|
|
774
780
|
| `maxTokens` | `32000` | Advertised max output tokens. |
|
|
775
781
|
| `budget` | unset | Per-profile budget overrides. |
|
|
776
782
|
|
|
783
|
+
### `digest` — cheap-model digest of large tool results
|
|
784
|
+
|
|
785
|
+
Tool results are the bulk of every prompt (see the report's prompt anatomy),
|
|
786
|
+
and a prompt is ~96% of spend. With the `router-digest` extension installed
|
|
787
|
+
and `digest.enabled` on, a large read, grep, glob or bash result produced
|
|
788
|
+
while the session's current model is at or above `fromTier` is sent to
|
|
789
|
+
`POST /v1/router/digest`; the cheapest `tier` model rewrites it to what the
|
|
790
|
+
task needs (exact paths, line numbers, names, errors, code to be edited) and
|
|
791
|
+
the digest replaces the tool result. It begins with a marker naming the tool
|
|
792
|
+
and arguments to re-run for the full output, so nothing is lost, only
|
|
793
|
+
deferred. Errors, images, edits and writes are never digested. Every digest
|
|
794
|
+
is a ledger row (`requestedModel` `digest`) and the report totals them.
|
|
795
|
+
|
|
796
|
+
| Key | Default | Meaning |
|
|
797
|
+
| --- | --- | --- |
|
|
798
|
+
| `enabled` | `false` | Master switch; the extension polls it every minute. |
|
|
799
|
+
| `minBytes` / `maxBytes` | `12000` / `400000` | Result size window that gets digested. |
|
|
800
|
+
| `tools` | `read, grep, glob, bash, web_fetch, webfetch, ls, find` | Eligible tool names (lower-case). |
|
|
801
|
+
| `fromTier` | `moderate` | Digest only when the session's current model is at or above this tier. |
|
|
802
|
+
| `tier` / `model` | `simple` / unset | Where the digest model is picked from, or a pinned slug. |
|
|
803
|
+
| `maxOutputTokens` | `700` | Digest length cap. |
|
|
804
|
+
| `maxCostUsd` | `0.02` | Skip when the digest itself would cost more. |
|
|
805
|
+
| `timeoutMs` | `25000` | The raw result stands if the cheap model is slower. |
|
|
806
|
+
|
|
777
807
|
### `report` — usage-report options
|
|
778
808
|
|
|
779
809
|
| Key | Default | Meaning |
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for the tool-result digest extension: which results to send,
|
|
3
|
+
* how to read a tool result's text, and how to shape the replacement.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface DigestPolicy {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
minBytes: number;
|
|
9
|
+
maxBytes: number;
|
|
10
|
+
tools: string[];
|
|
11
|
+
fromTier: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DISABLED_POLICY: DigestPolicy = { enabled: false, minBytes: 0, maxBytes: 0, tools: [], fromTier: "hard" };
|
|
15
|
+
|
|
16
|
+
/** The text of a tool result's content parts; images are left alone (and block digesting). */
|
|
17
|
+
export function textOf(content: ReadonlyArray<{ type: string; text?: string }>): { text: string; hasImage: boolean } {
|
|
18
|
+
let text = "";
|
|
19
|
+
let hasImage = false;
|
|
20
|
+
for (const part of content) {
|
|
21
|
+
if (part.type === "text" && typeof part.text === "string") text += (text === "" ? "" : "\n") + part.text;
|
|
22
|
+
else if (part.type === "image") hasImage = true;
|
|
23
|
+
}
|
|
24
|
+
return { text, hasImage };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Client-side gate: cheap checks before anything is sent to the router. */
|
|
28
|
+
export function shouldSend(policy: DigestPolicy, toolName: string, isError: boolean, text: string, hasImage: boolean): boolean {
|
|
29
|
+
if (!policy.enabled || isError || hasImage) return false;
|
|
30
|
+
if (!policy.tools.includes(toolName.toLowerCase())) return false;
|
|
31
|
+
const bytes = Buffer.byteLength(text);
|
|
32
|
+
return bytes >= policy.minBytes && bytes <= policy.maxBytes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parses the router's policy payload defensively; anything odd ⇒ disabled. */
|
|
36
|
+
export function parsePolicy(json: unknown): DigestPolicy {
|
|
37
|
+
if (typeof json !== "object" || json === null) return DISABLED_POLICY;
|
|
38
|
+
const p = json as Record<string, unknown>;
|
|
39
|
+
if (p.enabled !== true) return DISABLED_POLICY;
|
|
40
|
+
return {
|
|
41
|
+
enabled: true,
|
|
42
|
+
minBytes: typeof p.minBytes === "number" ? p.minBytes : 12_000,
|
|
43
|
+
maxBytes: typeof p.maxBytes === "number" ? p.maxBytes : 400_000,
|
|
44
|
+
tools: Array.isArray(p.tools) ? p.tools.filter((t): t is string => typeof t === "string").map((t) => t.toLowerCase()) : [],
|
|
45
|
+
fromTier: typeof p.fromTier === "string" ? p.fromTier : "hard",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One-line toast for a digest that happened. */
|
|
50
|
+
export function digestToast(toolName: string, inputBytes: number, outputChars: number, model: string, usd: number): string {
|
|
51
|
+
const kb = (n: number): string => `${(n / 1024).toFixed(0)}KB`;
|
|
52
|
+
return `digested ${toolName} ${kb(inputBytes)} → ${kb(outputChars)} via ${model.replace(/^ollama\//, "")} ($${usd.toFixed(4)})`;
|
|
53
|
+
}
|
|
@@ -127,9 +127,22 @@ declare module "@oh-my-pi/pi-coding-agent" {
|
|
|
127
127
|
details?: unknown;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** A tool result's content parts (text and images). */
|
|
131
|
+
export interface ToolResultPart {
|
|
132
|
+
type: string;
|
|
133
|
+
text?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What a `tool_result` handler may return to replace the result. */
|
|
137
|
+
export interface ToolResultEventResult {
|
|
138
|
+
content?: ToolResultPart[];
|
|
139
|
+
isError?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
130
142
|
export interface ExtensionAPI {
|
|
131
143
|
setLabel(label: string): void;
|
|
132
|
-
|
|
144
|
+
/** Handlers may return an event result (e.g. a `tool_result` replacement); omp ignores it where none applies. */
|
|
145
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown): void;
|
|
133
146
|
registerProvider(id: string, registration: ProviderRegistration): void;
|
|
134
147
|
unregisterProvider(id: string): void;
|
|
135
148
|
registerCommand(name: string, command: CommandDefinition): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* omp extension: condense large tool results with a cheap model before an
|
|
3
|
+
* expensive one reads them.
|
|
4
|
+
*
|
|
5
|
+
* Tool results are the bulk of every prompt, and a prompt is ~96% of spend.
|
|
6
|
+
* When a read, grep, glob or bash result is large and this session's
|
|
7
|
+
* current model sits at or above the router's `digest.fromTier`, the raw
|
|
8
|
+
* text goes to the router's `/v1/router/digest`, a simple-tier model
|
|
9
|
+
* rewrites it to what the task needs, and the digest replaces the tool
|
|
10
|
+
* result. The digest starts with a marker saying how to get the full output
|
|
11
|
+
* back (re-run the tool, or read a line range), so nothing is lost.
|
|
12
|
+
*
|
|
13
|
+
* The router decides (policy, session tier, cost guard); this extension only
|
|
14
|
+
* ships text that passes the cheap client-side checks. Off unless
|
|
15
|
+
* `digest.enabled` is set in the router config.
|
|
16
|
+
*
|
|
17
|
+
* Install beside router-embed.ts:
|
|
18
|
+
*
|
|
19
|
+
* # ~/.omp/agent/config.yml
|
|
20
|
+
* extensions:
|
|
21
|
+
* - /path/to/auto-model-router/omp-extension/router-embed.ts
|
|
22
|
+
* - /path/to/auto-model-router/omp-extension/router-digest.ts
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
26
|
+
|
|
27
|
+
import { DISABLED_POLICY, digestToast, parsePolicy, shouldSend, textOf, type DigestPolicy } from "./digest-logic.ts";
|
|
28
|
+
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
29
|
+
|
|
30
|
+
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
|
|
31
|
+
/** Re-read the policy this often, so a config change lands without a restart. */
|
|
32
|
+
const POLICY_TTL_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
export default function (pi: ExtensionAPI): void {
|
|
35
|
+
pi.setLabel("auto-model-router digest");
|
|
36
|
+
|
|
37
|
+
let policy: DigestPolicy = DISABLED_POLICY;
|
|
38
|
+
let policyAtMs = 0;
|
|
39
|
+
let lastUserText = "";
|
|
40
|
+
|
|
41
|
+
async function refreshPolicy(): Promise<void> {
|
|
42
|
+
if (Date.now() - policyAtMs < POLICY_TTL_MS) return;
|
|
43
|
+
policyAtMs = Date.now();
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${routerBaseUrl()}/v1/router/digest/policy`, { headers: routerAuthHeaders(), signal: AbortSignal.timeout(2_000) });
|
|
46
|
+
policy = res.ok ? parsePolicy(await res.json()) : DISABLED_POLICY;
|
|
47
|
+
} catch {
|
|
48
|
+
policy = DISABLED_POLICY;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pi.on("session_start", async () => {
|
|
53
|
+
policyAtMs = 0;
|
|
54
|
+
await refreshPolicy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The user's latest ask steers what the digest keeps.
|
|
58
|
+
pi.on("input", (event) => {
|
|
59
|
+
const e = event as { text?: string };
|
|
60
|
+
if (typeof e.text === "string" && e.text.trim() !== "") lastUserText = e.text.trim().slice(0, 400);
|
|
61
|
+
return undefined;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
65
|
+
const e = event as { toolName: string; input: Record<string, unknown>; content: Array<{ type: string; text?: string }>; isError: boolean };
|
|
66
|
+
await refreshPolicy();
|
|
67
|
+
const { text, hasImage } = textOf(e.content);
|
|
68
|
+
if (!shouldSend(policy, e.toolName, e.isError, text, hasImage)) return undefined;
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`${routerBaseUrl()}/v1/router/digest`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { ...routerAuthHeaders(), "content-type": "application/json" },
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
ompSessionId: ctx.sessionManager.getSessionId(),
|
|
75
|
+
harnessId: HARNESS_ID,
|
|
76
|
+
toolName: e.toolName,
|
|
77
|
+
input: e.input,
|
|
78
|
+
content: text,
|
|
79
|
+
query: lastUserText,
|
|
80
|
+
}),
|
|
81
|
+
signal: AbortSignal.timeout(30_000),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) return undefined;
|
|
84
|
+
const r = (await res.json()) as { digested: boolean; text?: string; model?: string; usd?: number; inputBytes?: number; outputChars?: number };
|
|
85
|
+
if (!r.digested || typeof r.text !== "string") return undefined;
|
|
86
|
+
if (ctx.hasUI) ctx.ui.notify(digestToast(e.toolName, r.inputBytes ?? 0, r.outputChars ?? 0, r.model ?? "?", r.usd ?? 0), "info");
|
|
87
|
+
return { content: [{ type: "text", text: r.text }] };
|
|
88
|
+
} catch {
|
|
89
|
+
// Router unreachable or slow: the raw result stands.
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -89,7 +89,7 @@ function trackProcessExit(): void {
|
|
|
89
89
|
* Registers the auto-model-router provider (and its virtual models) into omp's model
|
|
90
90
|
* registry at a specific bound port.
|
|
91
91
|
*/
|
|
92
|
-
function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig, sessionId: string): void {
|
|
92
|
+
function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig, sessionId: string, subagent: boolean): void {
|
|
93
93
|
// cwd is omp's workspace, which is what the agentdox scope is derived from
|
|
94
94
|
// when none is configured explicitly.
|
|
95
95
|
const providerConfig = buildProviderConfig(port, cfg, process.cwd());
|
|
@@ -100,6 +100,9 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
|
|
|
100
100
|
// Per-session scoping: lets the toast surface only this session's decisions
|
|
101
101
|
// even when several omp sessions share one embedded router's ledger.
|
|
102
102
|
if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
|
|
103
|
+
// A session without a UI is a subagent (or a headless run): the router
|
|
104
|
+
// routes its turns under server.subagentProfile.
|
|
105
|
+
if (subagent) headers["X-Omp-Subagent"] = "1";
|
|
103
106
|
// Which agentdox project's shared context this workspace's turns draw on.
|
|
104
107
|
if (providerConfig.agentdoxScope !== undefined && providerConfig.agentdoxScope !== "") {
|
|
105
108
|
headers["X-Agentdox-Scope"] = providerConfig.agentdoxScope;
|
|
@@ -171,7 +174,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
171
174
|
// second bind would take a different port and orphan every model handle
|
|
172
175
|
// omp already resolved against the first one.
|
|
173
176
|
if (app !== null && boundPort !== null) {
|
|
174
|
-
registerRouterProvider(pi, boundPort, cfg, sessionId);
|
|
177
|
+
registerRouterProvider(pi, boundPort, cfg, sessionId, !ctx.hasUI);
|
|
175
178
|
return;
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -181,7 +184,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
181
184
|
// The main writes the port file before spawning subagents.
|
|
182
185
|
const shared = readEmbedPort(portFile);
|
|
183
186
|
if (shared !== null && (await probeEmbed(shared))) {
|
|
184
|
-
registerRouterProvider(pi, shared, cfg, sessionId);
|
|
187
|
+
registerRouterProvider(pi, shared, cfg, sessionId, !ctx.hasUI);
|
|
185
188
|
return;
|
|
186
189
|
}
|
|
187
190
|
// No live interactive session (headless batch runs, CI, the
|
|
@@ -193,7 +196,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
193
196
|
if (started.server.port === undefined) return;
|
|
194
197
|
app = started;
|
|
195
198
|
boundPort = started.server.port;
|
|
196
|
-
registerRouterProvider(pi, boundPort, cfg, sessionId);
|
|
199
|
+
registerRouterProvider(pi, boundPort, cfg, sessionId, !ctx.hasUI);
|
|
197
200
|
return;
|
|
198
201
|
}
|
|
199
202
|
|
|
@@ -207,7 +210,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
207
210
|
// default — never take this path, so sessions stay independent.
|
|
208
211
|
if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
|
|
209
212
|
writeEmbedPort(portFile, requestedPort);
|
|
210
|
-
registerRouterProvider(pi, requestedPort, cfg, sessionId);
|
|
213
|
+
registerRouterProvider(pi, requestedPort, cfg, sessionId, !ctx.hasUI);
|
|
211
214
|
pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
|
|
212
215
|
return;
|
|
213
216
|
}
|
|
@@ -266,7 +269,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
266
269
|
|
|
267
270
|
// Register BEFORE any await: everything omp resolves after this point
|
|
268
271
|
// picks up the live URL, so the registration must not sit behind I/O.
|
|
269
|
-
registerRouterProvider(pi, actualPort, cfg, sessionId);
|
|
272
|
+
registerRouterProvider(pi, actualPort, cfg, sessionId, !ctx.hasUI);
|
|
270
273
|
pi.setLabel(`auto-model-router embed :${actualPort}`);
|
|
271
274
|
|
|
272
275
|
// NO `session_shutdown` teardown. That event is emitted from session
|
package/package.json
CHANGED
package/src/cli/config-wizard.ts
CHANGED
|
@@ -114,6 +114,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
114
114
|
{ path: "server.port", label: "Listen port", kind: "number", min: 1, max: 65535 },
|
|
115
115
|
{ path: "server.apiKey", label: "Client bearer token", kind: "string", optional: true, secret: true },
|
|
116
116
|
{ path: "server.harnessId", label: "Default harness id", kind: "string", optional: true },
|
|
117
|
+
{ path: "server.subagentProfile", label: "Subagent profile", kind: "string", optional: true, hint: "profile id for omp subagents; blank = none" },
|
|
117
118
|
{ path: "server.maxConcurrentTurns", label: "Max concurrent turns", kind: "number", min: 1, hint: "per process, all sessions" },
|
|
118
119
|
],
|
|
119
120
|
},
|
|
@@ -182,6 +183,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
182
183
|
{ path: "filters.includeFree", label: "Include free models", kind: "boolean" },
|
|
183
184
|
{ path: "filters.requireToolSupport", label: "Require tool support", kind: "boolean" },
|
|
184
185
|
{ path: "filters.minTrust", label: "Min trust", kind: "number", min: 0, max: 1 },
|
|
186
|
+
{ path: "filters.feedbackWeight", label: "Feedback weight in trust", kind: "number", min: 0, hint: "0=record only; a bad verdict = this many failures" },
|
|
185
187
|
{ path: "filters.minTrustSamples", label: "Min trust samples", kind: "number", min: 0 },
|
|
186
188
|
{ path: "filters.trustScopedByHarness", label: "Scope trust per harness", kind: "boolean" },
|
|
187
189
|
{ path: "filters.trustWindowDays", label: "Trust window", kind: "number", min: 0, hint: "days, 0=all time" },
|
|
@@ -210,6 +212,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
210
212
|
{ path: "classifier.chatAxis", label: "Chat axis", kind: "enum", options: AXES },
|
|
211
213
|
{ path: "classifier.agenticLoopDepth", label: "Agentic loop depth", kind: "number", min: 0, hint: "tool rounds before damping" },
|
|
212
214
|
{ path: "classifier.mechanicalRetryFactor", label: "Mechanical retry factor", kind: "number", min: 0, max: 1 },
|
|
215
|
+
{ path: "classifier.readOnlyToolWeight", label: "Read-only tool loop weight", kind: "number", min: 0, hint: "0=record only" },
|
|
213
216
|
{ path: "classifier.reasoningWeights.medium", label: "Reasoning weight: medium", kind: "number", min: 0 },
|
|
214
217
|
{ path: "classifier.reasoningWeights.high", label: "Reasoning weight: high", kind: "number", min: 0 },
|
|
215
218
|
{ path: "classifier.reasoningWeights.xhigh", label: "Reasoning weight: xhigh", kind: "number", min: 0 },
|
|
@@ -307,6 +310,21 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
307
310
|
{ path: "budget.onExceeded", label: "On exceeded", kind: "enum", options: ["downgrade", "reject"] },
|
|
308
311
|
],
|
|
309
312
|
},
|
|
313
|
+
{
|
|
314
|
+
title: "Digest",
|
|
315
|
+
fields: [
|
|
316
|
+
{ path: "digest.enabled", label: "Digest large tool results with a cheap model", kind: "boolean" },
|
|
317
|
+
{ path: "digest.minBytes", label: "Min result bytes", kind: "number", min: 0 },
|
|
318
|
+
{ path: "digest.maxBytes", label: "Max result bytes", kind: "number", min: 1 },
|
|
319
|
+
{ path: "digest.tools", label: "Tools eligible", kind: "stringArray", hint: "comma-separated, lower-case" },
|
|
320
|
+
{ path: "digest.fromTier", label: "Digest when the session is at or above", kind: "enum", options: TIER_NAMES },
|
|
321
|
+
{ path: "digest.tier", label: "Pick the digest model from tier", kind: "enum", options: TIER_NAMES },
|
|
322
|
+
{ path: "digest.model", label: "Pinned digest model", kind: "string", optional: true, hint: "blank = cheapest in tier" },
|
|
323
|
+
{ path: "digest.maxOutputTokens", label: "Max digest tokens", kind: "number", min: 1 },
|
|
324
|
+
{ path: "digest.maxCostUsd", label: "Max cost per digest $", kind: "number", min: 0 },
|
|
325
|
+
{ path: "digest.timeoutMs", label: "Digest timeout", kind: "number", min: 1, hint: "ms" },
|
|
326
|
+
],
|
|
327
|
+
},
|
|
310
328
|
{
|
|
311
329
|
title: "Report",
|
|
312
330
|
fields: [{ path: "report.baselines", label: "Counterfactual baseline models", kind: "stringArray", hint: "comma-separated slugs" }],
|
package/src/config/defaults.ts
CHANGED
|
@@ -15,6 +15,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
15
15
|
// is deterministic, so peers reuse it), so this covers N sessions plus
|
|
16
16
|
// their subagents. Was effectively 8 per session when each bound its own.
|
|
17
17
|
maxConcurrentTurns: 24,
|
|
18
|
+
// omp subagents (no UI) route under this profile: delegated work, capped at moderate.
|
|
19
|
+
subagentProfile: "auto-sub",
|
|
18
20
|
},
|
|
19
21
|
openrouter: {
|
|
20
22
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
@@ -99,6 +101,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
99
101
|
includeFree: false,
|
|
100
102
|
requireToolSupport: true,
|
|
101
103
|
minTrust: 0.7,
|
|
104
|
+
// Verdicts are recorded and reported first; weigh them once there are some.
|
|
105
|
+
feedbackWeight: 0,
|
|
102
106
|
minTrustSamples: 12,
|
|
103
107
|
// Shared trust by default: more samples, demotion guard stays effective
|
|
104
108
|
// even with a tiny guardrail-narrowed catalog.
|
|
@@ -142,6 +146,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
142
146
|
// A mechanical retry (failed tool call + tool-result continuation) keeps
|
|
143
147
|
// only a fifth of the +0.26; a user-visible failure keeps the full weight.
|
|
144
148
|
mechanicalRetryFactor: 0.2,
|
|
149
|
+
// Recorded, not acted on, until replay prices it. See ClassifierConfig.readOnlyToolWeight.
|
|
150
|
+
readOnlyToolWeight: 0,
|
|
145
151
|
// Shipped reasoning values, unchanged. See ClassifierConfig.reasoningWeights:
|
|
146
152
|
// a harness that pins the level for a whole session turns these into a
|
|
147
153
|
// constant tier offset, in which case `medium` belongs near 0.
|
|
@@ -286,6 +292,19 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
286
292
|
elideSupersededReads: true,
|
|
287
293
|
collapseDuplicateResults: true,
|
|
288
294
|
},
|
|
295
|
+
digest: {
|
|
296
|
+
// Off until an operator turns it on: it changes what the model reads.
|
|
297
|
+
enabled: false,
|
|
298
|
+
minBytes: 12_000,
|
|
299
|
+
maxBytes: 400_000,
|
|
300
|
+
tools: ["read", "grep", "glob", "bash", "web_fetch", "webfetch", "ls", "find"],
|
|
301
|
+
fromTier: "moderate",
|
|
302
|
+
tier: "simple",
|
|
303
|
+
model: "",
|
|
304
|
+
maxOutputTokens: 700,
|
|
305
|
+
maxCostUsd: 0.02,
|
|
306
|
+
timeoutMs: 25_000,
|
|
307
|
+
},
|
|
289
308
|
report: {
|
|
290
309
|
// The frontier pair most omp users would otherwise run on.
|
|
291
310
|
baselines: ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5"],
|
|
@@ -298,6 +317,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
298
317
|
{ id: "auto", name: "Auto (auto-model-router)", minTier: "trivial", maxTier: "hard", contextWindow: 400_000, maxTokens: 32_000 },
|
|
299
318
|
{ id: "auto-cheap", name: "Auto Cheap (auto-model-router)", minTier: "trivial", maxTier: "simple", contextWindow: 400_000, maxTokens: 32_000 },
|
|
300
319
|
{ id: "auto-max", name: "Auto Max (auto-model-router)", minTier: "moderate", maxTier: "hard", contextWindow: 400_000, maxTokens: 32_000 },
|
|
320
|
+
// Subagent envelope (server.subagentProfile): never the top tier for delegated work.
|
|
321
|
+
{ id: "auto-sub", name: "Auto Subagent (auto-model-router)", minTier: "trivial", maxTier: "moderate", contextWindow: 400_000, maxTokens: 32_000 },
|
|
301
322
|
],
|
|
302
323
|
ledger: {
|
|
303
324
|
// Resolved by loadConfig: empty ⇒ `$AUTO_MODEL_ROUTER_HOME/router.db`.
|
package/src/config/schema.ts
CHANGED
|
@@ -20,6 +20,7 @@ const server = z.strictObject({
|
|
|
20
20
|
port: z.number().int().min(0).max(65_535).optional(),
|
|
21
21
|
apiKey: z.string().optional(),
|
|
22
22
|
harnessId: z.string().optional(),
|
|
23
|
+
subagentProfile: z.string().optional(),
|
|
23
24
|
maxConcurrentTurns: z.number().int().positive().max(1_000).optional(),
|
|
24
25
|
});
|
|
25
26
|
|
|
@@ -88,6 +89,7 @@ const filters = z.strictObject({
|
|
|
88
89
|
includeFree: z.boolean().optional(),
|
|
89
90
|
requireToolSupport: z.boolean().optional(),
|
|
90
91
|
minTrust: z.number().min(0).max(1).optional(),
|
|
92
|
+
feedbackWeight: z.number().nonnegative().optional(),
|
|
91
93
|
minTrustSamples: z.number().int().nonnegative().optional(),
|
|
92
94
|
trustScopedByHarness: z.boolean().optional(),
|
|
93
95
|
trustWindowDays: z.number().nonnegative().optional(),
|
|
@@ -114,6 +116,7 @@ const classifier = z.strictObject({
|
|
|
114
116
|
chatAxis: qualityAxis.optional(),
|
|
115
117
|
agenticLoopDepth: z.number().int().nonnegative().optional(),
|
|
116
118
|
mechanicalRetryFactor: z.number().min(0).max(1).optional(),
|
|
119
|
+
readOnlyToolWeight: z.number().nonnegative().optional(),
|
|
117
120
|
reasoningWeights: z
|
|
118
121
|
.strictObject({
|
|
119
122
|
medium: z.number().nonnegative().optional(),
|
|
@@ -270,6 +273,20 @@ export const configInputSchema = z.strictObject({
|
|
|
270
273
|
budget: budget.optional(),
|
|
271
274
|
profiles: z.array(profile).optional(),
|
|
272
275
|
report: z.strictObject({ baselines: z.array(z.string()).optional() }).optional(),
|
|
276
|
+
digest: z
|
|
277
|
+
.strictObject({
|
|
278
|
+
enabled: z.boolean().optional(),
|
|
279
|
+
minBytes: z.number().int().nonnegative().optional(),
|
|
280
|
+
maxBytes: z.number().int().positive().optional(),
|
|
281
|
+
tools: z.array(z.string()).optional(),
|
|
282
|
+
fromTier: tier.optional(),
|
|
283
|
+
tier: tier.optional(),
|
|
284
|
+
model: z.string().optional(),
|
|
285
|
+
maxOutputTokens: z.number().int().positive().optional(),
|
|
286
|
+
maxCostUsd: z.number().nonnegative().optional(),
|
|
287
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
288
|
+
})
|
|
289
|
+
.optional(),
|
|
273
290
|
ledger: ledger.optional(),
|
|
274
291
|
adaptiveTierFloors: z.boolean().optional(),
|
|
275
292
|
adaptivePriceCeilings: z.boolean().optional(),
|
package/src/config/types.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface ServerConfig {
|
|
|
38
38
|
* ⇒ no header (single-harness default).
|
|
39
39
|
*/
|
|
40
40
|
harnessId?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Profile that requests from omp subagents (`X-Omp-Subagent: 1`, set by
|
|
43
|
+
* the embed extension for sessions without a UI) are routed under when they
|
|
44
|
+
* ask for the default profile. Subagents do delegated, bounded work — file
|
|
45
|
+
* reads, searches, summaries — that rarely needs the top tier. Empty
|
|
46
|
+
* disables the remap; a name with no matching profile is ignored.
|
|
47
|
+
*/
|
|
48
|
+
subagentProfile: string;
|
|
41
49
|
/**
|
|
42
50
|
* Concurrent in-flight turns this router process will accept; excess gets a
|
|
43
51
|
* 429 rather than being queued, so a local flood cannot pile up unbounded
|
|
@@ -215,6 +223,15 @@ export interface FilterConfig {
|
|
|
215
223
|
requireToolSupport: boolean;
|
|
216
224
|
/** Drop models whose ledger success rate is below this, once `minTrustSamples` is met. */
|
|
217
225
|
minTrust: number;
|
|
226
|
+
/**
|
|
227
|
+
* How much a user verdict (/router good|bad) weighs in a model's trust
|
|
228
|
+
* rate: each bad verdict counts as this many failures and each good one as
|
|
229
|
+
* this many successes, beside escalations and errors. 0 (default) records
|
|
230
|
+
* verdicts without acting on them. A person judging an answer wrong is a
|
|
231
|
+
* stronger signal than a probe rejection, so values of 2-5 are sensible
|
|
232
|
+
* once a week of verdicts is in the report.
|
|
233
|
+
*/
|
|
234
|
+
feedbackWeight: number;
|
|
218
235
|
/** Attempts required before `minTrust` is enforced against a model. */
|
|
219
236
|
minTrustSamples: number;
|
|
220
237
|
/**
|
|
@@ -347,6 +364,13 @@ export interface ClassifierConfig {
|
|
|
347
364
|
* loops buy the hard tier. 1 preserves the shipped behaviour.
|
|
348
365
|
*/
|
|
349
366
|
mechanicalRetryFactor: number;
|
|
367
|
+
/**
|
|
368
|
+
* Score subtracted when the newest assistant turn issued only read-only
|
|
369
|
+
* tools (read, grep, glob, ls, lsp…) and this is the tool-result
|
|
370
|
+
* continuation: the model is looking, not deciding. 0 (default) records
|
|
371
|
+
* the feature without acting on it — enable after a replay prices it.
|
|
372
|
+
*/
|
|
373
|
+
readOnlyToolWeight: number;
|
|
350
374
|
/**
|
|
351
375
|
* Score added when the CLIENT asks for a reasoning effort, per level. The
|
|
352
376
|
* premise is that asking for reasoning states expected difficulty directly.
|
|
@@ -530,6 +554,32 @@ export interface CacheConfig {
|
|
|
530
554
|
milestoneTokens: number;
|
|
531
555
|
}
|
|
532
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Tool-result digest: a cheap model condenses large tool outputs before an
|
|
559
|
+
* expensive one reads them (see server/digest.ts and the router-digest omp
|
|
560
|
+
* extension).
|
|
561
|
+
*/
|
|
562
|
+
export interface DigestConfig {
|
|
563
|
+
/** Master switch; the omp extension polls this as its policy. */
|
|
564
|
+
enabled: boolean;
|
|
565
|
+
/** Tool results smaller than this pass through untouched. */
|
|
566
|
+
minBytes: number;
|
|
567
|
+
/** Results larger than this are left alone (too costly even for a cheap model). */
|
|
568
|
+
maxBytes: number;
|
|
569
|
+
/** Tool names (lower-case) whose results may be digested. Never errors, never edits/writes. */
|
|
570
|
+
tools: string[];
|
|
571
|
+
/** Digest only when the session's current model is at or above this tier. */
|
|
572
|
+
fromTier: Tier;
|
|
573
|
+
/** Tier the digest model is picked from (cheapest candidate that fits). */
|
|
574
|
+
tier: Tier;
|
|
575
|
+
/** Pin a specific digest model; empty ⇒ pick from `tier`. */
|
|
576
|
+
model: string;
|
|
577
|
+
maxOutputTokens: number;
|
|
578
|
+
/** Skip when the digest itself would cost more than this, USD. */
|
|
579
|
+
maxCostUsd: number;
|
|
580
|
+
timeoutMs: number;
|
|
581
|
+
}
|
|
582
|
+
|
|
533
583
|
/** Usage-report options. */
|
|
534
584
|
export interface ReportConfig {
|
|
535
585
|
/**
|
|
@@ -717,6 +767,7 @@ export interface RouterConfig {
|
|
|
717
767
|
compaction: CompactionConfig;
|
|
718
768
|
budget: BudgetConfig;
|
|
719
769
|
report: ReportConfig;
|
|
770
|
+
digest: DigestConfig;
|
|
720
771
|
profiles: ProfileConfig[];
|
|
721
772
|
ledger: LedgerConfig;
|
|
722
773
|
/**
|