auto-model-router 0.4.3 → 0.4.5
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/.gitattributes +2 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +47 -1
- package/bunfig.toml +2 -0
- package/omp-extension/pi-coding-agent.d.ts +6 -0
- package/omp-extension/router-switch.ts +100 -0
- package/omp-extension/switch-logic.ts +81 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +4 -1
- package/src/cli/config-wizard.ts +9 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/hot-reload.ts +58 -9
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +27 -0
- package/src/cost/ledger.ts +9 -0
- package/src/cost/report.ts +26 -1
- package/src/cost/types.ts +4 -0
- package/src/router/compaction.ts +1 -1
- package/src/router/select.ts +22 -2
- package/src/server/advise.ts +82 -0
- package/src/server/compaction-digest.ts +2 -0
- package/src/server/digest.ts +55 -1
- package/src/server/http.ts +41 -6
- package/src/server/providers.ts +1 -0
- package/src/server/turn.ts +11 -0
- package/test/cache-control.test.ts +1 -1
- package/test/config-wizard.test.ts +1 -0
- package/test/digest.test.ts +22 -0
- package/test/failover.test.ts +2 -1
- package/test/harness-switch.test.ts +59 -0
- package/test/hot-reload.test.ts +37 -1
- package/test/migrations.test.ts +84 -0
- package/test/report-hub.test.ts +1 -1
- package/test/report.test.ts +29 -1
- package/test/select.test.ts +24 -0
- package/test/support/preload.ts +19 -0
- package/test/tokens.test.ts +24 -0
- package/test/turn.test.ts +2 -1
- package/tools/gen-migration-fixtures.ts +69 -0
package/.gitattributes
ADDED
|
@@ -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.5",
|
|
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.5",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -562,7 +562,9 @@ as the placeholder — empty input keeps it, `-` clears an optional field,
|
|
|
562
562
|
credentials show as `set`/`unset` and are never echoed. `Save and exit` writes the merged config
|
|
563
563
|
(schema-checked and backed up first). Tier, task, filter, classifier,
|
|
564
564
|
hysteresis, exploration, compaction, cache and budget changes hot-reload;
|
|
565
|
-
restart omp for `server
|
|
565
|
+
restart omp for `server` (except `subagentProfile`), `openrouter`, `context`,
|
|
566
|
+
`ledger.path` and the Ollama connection keys; `ollama.costBias`,
|
|
567
|
+
`ollama.biasUntilUsage` and `ledger.retentionDays` hot-reload too.
|
|
566
568
|
|
|
567
569
|
### Via `auto-model-router config` (text wizard / CLI)
|
|
568
570
|
|
|
@@ -822,6 +824,12 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
|
|
|
822
824
|
| `maxCostUsd` | `0.02` | Skip when the digest itself would cost more. |
|
|
823
825
|
| `timeoutMs` | `25000` | The raw result stands if the cheap model is slower. |
|
|
824
826
|
|
|
827
|
+
Quality signal: when the agent later calls the same tool with the same
|
|
828
|
+
primary argument (re-reads a digested file, re-runs a digested grep), the
|
|
829
|
+
router marks that digest's ledger row wasted. The report's `digests` line
|
|
830
|
+
shows the re-run rate; a high rate means the digest is dropping what the
|
|
831
|
+
task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
|
|
832
|
+
|
|
825
833
|
### `report` — usage-report options
|
|
826
834
|
|
|
827
835
|
| Key | Default | Meaning |
|
|
@@ -829,6 +837,14 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
|
|
|
829
837
|
| `baselines` | `anthropic/claude-opus-5`, `anthropic/claude-sonnet-5` | Models the report prices the window's traffic on as a single-model counterfactual. Unknown slugs are skipped. |
|
|
830
838
|
| `dailySummary` | `true` | Post the daily summary (below) into the transcript at the first interactive omp session start of each day. Hot-reloads. |
|
|
831
839
|
|
|
840
|
+
### `harnessSwitch` — harness-side model switch (experimental)
|
|
841
|
+
|
|
842
|
+
| Key | Default | Meaning |
|
|
843
|
+
| --- | --- | --- |
|
|
844
|
+
| `enabled` | `false` | Let the `router-switch` extension move omp's active model for mapped tiers. |
|
|
845
|
+
| `models` | `{}` | Tier → harness model as `provider/id` in omp's own registry, e.g. `hard: anthropic/claude-opus-4-8`. A tier serves itself and every tier above it up to the next mapped one; unmapped tiers stay on the router. |
|
|
846
|
+
| `minConfidence` | `0.6` | Advice below this heuristic confidence leaves the model where it is. |
|
|
847
|
+
|
|
832
848
|
### `ledger` — cost measurement
|
|
833
849
|
|
|
834
850
|
| Key | Default | Meaning |
|
|
@@ -838,6 +854,7 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
|
|
|
838
854
|
| `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
|
|
839
855
|
| `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
|
|
840
856
|
| `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
|
|
857
|
+
| `retentionDays` | `365` | Delete ledger rows older than this, checked hourly; `0` keeps everything. The ledger grows about 2.5 MB a day under steady use. Freed pages are reused, so the file stops growing rather than shrinking. |
|
|
841
858
|
|
|
842
859
|
### Top-level
|
|
843
860
|
|
|
@@ -920,6 +937,35 @@ router handles for you: no `models[]` fallback cascade, no `tool_choice`,
|
|
|
920
937
|
`reasoning_effort` instead of the `reasoning` object, and no `cache_control`
|
|
921
938
|
markers (they are stripped before dispatch).
|
|
922
939
|
|
|
940
|
+
## Harness-side model switch (experimental)
|
|
941
|
+
|
|
942
|
+
Most engineers reach Claude through a subscription, not an API key, and a
|
|
943
|
+
subscription model cannot be proxied: the router would have to translate to
|
|
944
|
+
Anthropic's wire format and carry omp's OAuth token through a third-party
|
|
945
|
+
process. The `router-switch` extension takes the other route. Before omp
|
|
946
|
+
starts a turn on a user prompt it asks the router which tier the prompt is
|
|
947
|
+
(`POST /v1/router/advise`, the heuristic classifier over the prompt text,
|
|
948
|
+
nothing dispatched or recorded). When that tier is mapped in
|
|
949
|
+
`harnessSwitch.models`, the extension moves omp's active model to the mapped
|
|
950
|
+
harness model; when a later prompt is advised below every mapped tier, it
|
|
951
|
+
moves back to the router model it left. A model the user picked by hand is
|
|
952
|
+
never touched. Native turns bill the subscription and never reach the
|
|
953
|
+
ledger; the router serves and accounts for the rest.
|
|
954
|
+
|
|
955
|
+
```yaml
|
|
956
|
+
# ~/.auto-model-router/config.yml
|
|
957
|
+
harnessSwitch:
|
|
958
|
+
enabled: true
|
|
959
|
+
models:
|
|
960
|
+
hard: anthropic/claude-opus-4-8
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
Install `omp-extension/router-switch.ts` beside the embed extension and
|
|
964
|
+
restart omp. Known limits of the prototype: the advice sees only the prompt
|
|
965
|
+
text, not the conversation, so a hard task that only becomes hard three tool
|
|
966
|
+
calls in stays on the router (the router's own escalation still applies
|
|
967
|
+
there); and the switch happens at prompt boundaries, never mid-turn.
|
|
968
|
+
|
|
923
969
|
## Multiple coding harnesses, one router
|
|
924
970
|
|
|
925
971
|
A single embedded router can serve several omp sessions without them stepping
|
package/bunfig.toml
ADDED
|
@@ -109,6 +109,12 @@ declare module "@oh-my-pi/pi-coding-agent" {
|
|
|
109
109
|
/** Interval whose errors omp isolates, and whose handle `clearTimer` cancels. */
|
|
110
110
|
setInterval(handler: () => void | Promise<void>, ms: number): unknown;
|
|
111
111
|
clearTimer(timer: unknown): void;
|
|
112
|
+
/** The active model, when one is set. (Real type: `Model`.) */
|
|
113
|
+
model: { provider: string; id: string } | undefined;
|
|
114
|
+
/** Looks a model up in omp's registry by provider and id. */
|
|
115
|
+
modelRegistry: { find(provider: string, modelId: string): unknown };
|
|
116
|
+
/** Sets the session's active model; false when omp has no key for it. */
|
|
117
|
+
setModel(model: unknown): Promise<boolean>;
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
export interface CommandDefinition {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* omp extension: harness-side model switch (experimental).
|
|
3
|
+
*
|
|
4
|
+
* Before omp starts a turn on a user prompt, ask the router which tier the
|
|
5
|
+
* prompt is, and when that tier is mapped to a harness-native model in the
|
|
6
|
+
* router's `harnessSwitch.models` (a Claude subscription model, typically),
|
|
7
|
+
* move omp's active model there; when a later prompt is advised below the
|
|
8
|
+
* mapped tiers, move back to the router model we left. A model the user
|
|
9
|
+
* picked by hand is never touched.
|
|
10
|
+
*
|
|
11
|
+
* Nothing is proxied and no token leaves omp: the native turns bill the
|
|
12
|
+
* subscription, the router serves the rest and keeps the ledger for those.
|
|
13
|
+
* Off unless `harnessSwitch.enabled` is set in the router config.
|
|
14
|
+
*
|
|
15
|
+
* # ~/.omp/agent/config.yml
|
|
16
|
+
* extensions:
|
|
17
|
+
* - /path/to/auto-model-router/omp-extension/router-embed.ts
|
|
18
|
+
* - /path/to/auto-model-router/omp-extension/router-switch.ts
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
import { EMBED_PROVIDER_ID } from "./embed-logic.ts";
|
|
24
|
+
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
25
|
+
import { DISABLED_SWITCH, decideSwitch, parseSwitchPolicy, type SwitchPolicy, type TierName } from "./switch-logic.ts";
|
|
26
|
+
|
|
27
|
+
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
|
|
28
|
+
const POLICY_TTL_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
export default function (pi: ExtensionAPI): void {
|
|
31
|
+
pi.setLabel("auto-model-router switch");
|
|
32
|
+
|
|
33
|
+
let policy: SwitchPolicy = DISABLED_SWITCH;
|
|
34
|
+
let policyAtMs = 0;
|
|
35
|
+
let switchedTo: string | null = null;
|
|
36
|
+
let returnTo: string | null = null;
|
|
37
|
+
|
|
38
|
+
async function refreshPolicy(): Promise<void> {
|
|
39
|
+
if (Date.now() - policyAtMs < POLICY_TTL_MS) return;
|
|
40
|
+
policyAtMs = Date.now();
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`${routerBaseUrl()}/v1/router/advise/policy`, { headers: routerAuthHeaders(), signal: AbortSignal.timeout(2_000) });
|
|
43
|
+
policy = res.ok ? parseSwitchPolicy(await res.json()) : DISABLED_SWITCH;
|
|
44
|
+
} catch {
|
|
45
|
+
policy = DISABLED_SWITCH;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pi.on("session_start", async () => {
|
|
50
|
+
policyAtMs = 0;
|
|
51
|
+
switchedTo = null;
|
|
52
|
+
returnTo = null;
|
|
53
|
+
await refreshPolicy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
57
|
+
const e = event as { prompt?: string };
|
|
58
|
+
if (typeof e.prompt !== "string" || e.prompt.trim() === "") return undefined;
|
|
59
|
+
await refreshPolicy();
|
|
60
|
+
if (!policy.enabled) return undefined;
|
|
61
|
+
const activeModel = ctx.model;
|
|
62
|
+
const active = activeModel === undefined ? null : `${activeModel.provider}/${activeModel.id}`;
|
|
63
|
+
const activeIsRouter = activeModel?.provider === EMBED_PROVIDER_ID;
|
|
64
|
+
let advised: { tier: TierName; confidence: number };
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`${routerBaseUrl()}/v1/router/advise`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { ...routerAuthHeaders(), "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ ompSessionId: ctx.sessionManager.getSessionId(), harnessId: HARNESS_ID, text: e.prompt.slice(0, 8_000) }),
|
|
70
|
+
signal: AbortSignal.timeout(2_000),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) return undefined;
|
|
73
|
+
advised = (await res.json()) as { tier: TierName; confidence: number };
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const decision = decideSwitch({ policy, advised, active, activeIsRouter, switchedTo, returnTo });
|
|
78
|
+
if (decision.action === "none") return undefined;
|
|
79
|
+
const [provider = "", ...rest] = decision.model.split("/");
|
|
80
|
+
const target = ctx.modelRegistry.find(provider, rest.join("/"));
|
|
81
|
+
if (target === undefined) {
|
|
82
|
+
if (ctx.hasUI) ctx.ui.notify(`router switch: ${decision.model} is not in omp's model registry`, "warn");
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const ok = await ctx.setModel(target);
|
|
86
|
+
if (!ok) {
|
|
87
|
+
if (ctx.hasUI) ctx.ui.notify(`router switch: omp has no key for ${decision.model}`, "warn");
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (decision.action === "up") {
|
|
91
|
+
if (activeIsRouter) returnTo = active;
|
|
92
|
+
switchedTo = decision.model;
|
|
93
|
+
} else {
|
|
94
|
+
switchedTo = null;
|
|
95
|
+
returnTo = null;
|
|
96
|
+
}
|
|
97
|
+
if (ctx.hasUI) ctx.ui.notify(`router switch: ${decision.reason}`, "info");
|
|
98
|
+
return undefined;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision logic behind the harness-side model switch (router-switch.ts):
|
|
3
|
+
* given the router's advice for a prompt and what model omp is on, say whether
|
|
4
|
+
* to move up to a harness-native model, back to the router, or stay put.
|
|
5
|
+
* Free of omp types so it is unit-testable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const TIERS = ["trivial", "simple", "moderate", "hard"] as const;
|
|
9
|
+
export type TierName = (typeof TIERS)[number];
|
|
10
|
+
|
|
11
|
+
export interface SwitchPolicy {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** Tier → harness model (`provider/id`) that serves that tier and above, up to the next configured tier. */
|
|
14
|
+
models: Partial<Record<TierName, string>>;
|
|
15
|
+
/** Advice below this confidence never moves the model. */
|
|
16
|
+
minConfidence: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DISABLED_SWITCH: SwitchPolicy = { enabled: false, models: {}, minConfidence: 1 };
|
|
20
|
+
|
|
21
|
+
export function parseSwitchPolicy(raw: unknown): SwitchPolicy {
|
|
22
|
+
if (raw === null || typeof raw !== "object") return DISABLED_SWITCH;
|
|
23
|
+
const r = raw as Record<string, unknown>;
|
|
24
|
+
const models: Partial<Record<TierName, string>> = {};
|
|
25
|
+
if (r.models !== null && typeof r.models === "object") {
|
|
26
|
+
for (const t of TIERS) {
|
|
27
|
+
const v = (r.models as Record<string, unknown>)[t];
|
|
28
|
+
if (typeof v === "string" && v.includes("/")) models[t] = v;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
enabled: r.enabled === true,
|
|
33
|
+
models,
|
|
34
|
+
minConfidence: typeof r.minConfidence === "number" ? r.minConfidence : 0.6,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The harness model configured for the highest tier at or below `tier`, if any. */
|
|
39
|
+
export function nativeModelFor(policy: SwitchPolicy, tier: TierName): string | undefined {
|
|
40
|
+
for (let i = TIERS.indexOf(tier); i >= 0; i--) {
|
|
41
|
+
const m = policy.models[TIERS[i]!];
|
|
42
|
+
if (m !== undefined) return m;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SwitchInput {
|
|
48
|
+
policy: SwitchPolicy;
|
|
49
|
+
advised: { tier: TierName; confidence: number };
|
|
50
|
+
/** omp's active model as `provider/id`, or null when none. */
|
|
51
|
+
active: string | null;
|
|
52
|
+
/** True when the active model belongs to the router's provider. */
|
|
53
|
+
activeIsRouter: boolean;
|
|
54
|
+
/** The harness model this extension last switched TO, if omp is still on it. */
|
|
55
|
+
switchedTo: string | null;
|
|
56
|
+
/** The router model omp was on before the switch, to return to. */
|
|
57
|
+
returnTo: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type SwitchDecision =
|
|
61
|
+
| { action: "up"; model: string; reason: string }
|
|
62
|
+
| { action: "back"; model: string; reason: string }
|
|
63
|
+
| { action: "none"; reason: string };
|
|
64
|
+
|
|
65
|
+
export function decideSwitch(input: SwitchInput): SwitchDecision {
|
|
66
|
+
const { policy, advised, active, activeIsRouter, switchedTo, returnTo } = input;
|
|
67
|
+
if (!policy.enabled) return { action: "none", reason: "harnessSwitch.enabled is off" };
|
|
68
|
+
const onOurSwitch = switchedTo !== null && active === switchedTo;
|
|
69
|
+
// The user picked something else by hand: never fight a manual choice.
|
|
70
|
+
if (!activeIsRouter && !onOurSwitch) return { action: "none", reason: `active model ${active ?? "(none)"} was chosen by the user` };
|
|
71
|
+
const native = nativeModelFor(policy, advised.tier);
|
|
72
|
+
if (native === undefined) {
|
|
73
|
+
if (onOurSwitch && returnTo !== null) return { action: "back", model: returnTo, reason: `${advised.tier} work: back to the router` };
|
|
74
|
+
return { action: "none", reason: `${advised.tier} work stays on the router` };
|
|
75
|
+
}
|
|
76
|
+
if (advised.confidence < policy.minConfidence) {
|
|
77
|
+
return { action: "none", reason: `${advised.tier} at confidence ${advised.confidence.toFixed(2)} < ${policy.minConfidence}` };
|
|
78
|
+
}
|
|
79
|
+
if (active === native) return { action: "none", reason: `already on ${native}` };
|
|
80
|
+
return { action: "up", model: native, reason: `${advised.tier} work (confidence ${advised.confidence.toFixed(2)}) → ${native}` };
|
|
81
|
+
}
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface CompositeBias {
|
|
|
23
23
|
/** Plan usage fraction at which the bias switches off (list price). */
|
|
24
24
|
biasUntilUsage: number;
|
|
25
25
|
usage: OllamaUsageSource;
|
|
26
|
+
/** When given, read on every use instead of the static pair, so a config hot reload applies. */
|
|
27
|
+
live?: () => { costBias: number; biasUntilUsage: number };
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function createCompositeCatalog(
|
|
@@ -39,7 +41,8 @@ export function createCompositeCatalog(
|
|
|
39
41
|
|
|
40
42
|
/** The multiplier in force from the latest usage reading (no network). */
|
|
41
43
|
function currentBias(): number {
|
|
42
|
-
|
|
44
|
+
const b = bias.live?.() ?? bias;
|
|
45
|
+
return effectiveOllamaBias(b.costBias, b.biasUntilUsage, bias.usage.peek());
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
|
package/src/cli/config-wizard.ts
CHANGED
|
@@ -335,6 +335,14 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
335
335
|
{ path: "report.dailySummary", label: "Daily summary at session start", kind: "boolean" },
|
|
336
336
|
],
|
|
337
337
|
},
|
|
338
|
+
{
|
|
339
|
+
title: "Harness switch",
|
|
340
|
+
fields: [
|
|
341
|
+
{ path: "harnessSwitch.enabled", label: "Switch omp's model for mapped tiers (experimental)", kind: "boolean" },
|
|
342
|
+
{ path: "harnessSwitch.minConfidence", label: "Minimum advice confidence to switch", kind: "number", min: 0, max: 1 },
|
|
343
|
+
...TIER_NAMES.map((t): FieldSpec => ({ path: `harnessSwitch.models.${t}`, label: `${t}: harness model (provider/id)`, kind: "string", optional: true, hint: "e.g. anthropic/claude-opus-4-8" })),
|
|
344
|
+
],
|
|
345
|
+
},
|
|
338
346
|
{
|
|
339
347
|
title: "Ledger",
|
|
340
348
|
fields: [
|
|
@@ -344,6 +352,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
344
352
|
{ path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
|
|
345
353
|
{ path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
|
|
346
354
|
{ path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
|
|
355
|
+
{ path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, hint: "days; 0 keeps everything" },
|
|
347
356
|
],
|
|
348
357
|
},
|
|
349
358
|
{
|
package/src/config/defaults.ts
CHANGED
|
@@ -316,6 +316,12 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
316
316
|
// One transcript message per day, at the first interactive session start.
|
|
317
317
|
dailySummary: true,
|
|
318
318
|
},
|
|
319
|
+
harnessSwitch: {
|
|
320
|
+
// Off: moving the harness's own model is a visible change the operator opts into.
|
|
321
|
+
enabled: false,
|
|
322
|
+
models: {},
|
|
323
|
+
minConfidence: 0.6,
|
|
324
|
+
},
|
|
319
325
|
budget: {
|
|
320
326
|
// No caps by default; at a configured ceiling, downgrade rather than fail.
|
|
321
327
|
onExceeded: "downgrade",
|
|
@@ -337,6 +343,7 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
337
343
|
// so early cost reporting never underreports.
|
|
338
344
|
fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
|
|
339
345
|
conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
346
|
+
retentionDays: 365,
|
|
340
347
|
},
|
|
341
348
|
// On by default: an absolute floor that no available model meets is how the
|
|
342
349
|
// router ends up serving every turn from the cheapest tier.
|
package/src/config/hot-reload.ts
CHANGED
|
@@ -90,15 +90,47 @@ export interface WatchConfigOptions {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* Config paths captured at construction, so a file edit cannot reach the
|
|
94
|
+
* running process: the socket, the upstream clients, the agentdox bridge, the
|
|
95
|
+
* ledger file. Everything else, including `ollama.costBias`,
|
|
96
|
+
* `ollama.biasUntilUsage`, `server.subagentProfile` and `ledger.retentionDays`,
|
|
97
|
+
* is read at call time and hot-reloads. A bare block name pins the whole
|
|
98
|
+
* block; `block.key` pins one key and lets its siblings through.
|
|
99
|
+
*/
|
|
100
|
+
export const PINNED_CONFIG_PATHS: readonly string[] = [
|
|
101
|
+
"server.host",
|
|
102
|
+
"server.port",
|
|
103
|
+
"server.apiKey",
|
|
104
|
+
"server.harnessId",
|
|
105
|
+
"server.maxConcurrentTurns",
|
|
106
|
+
"openrouter",
|
|
107
|
+
"ollama.enabled",
|
|
108
|
+
"ollama.baseUrl",
|
|
109
|
+
"ollama.apiKey",
|
|
110
|
+
"ollama.timeoutMs",
|
|
111
|
+
"ollama.catalogTtlMs",
|
|
112
|
+
"ollama.includeLocal",
|
|
113
|
+
"ollama.prices",
|
|
114
|
+
"ollama.twins",
|
|
115
|
+
"ollama.usagePollMs",
|
|
116
|
+
"ollama.quotaCooldownMs",
|
|
117
|
+
"ollama.rateLimitCooldownMs",
|
|
118
|
+
"ollama.planCreditsUsd",
|
|
119
|
+
"context",
|
|
120
|
+
"ledger.path",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Watches `path` and applies valid changes to `live` in place. `frozen`
|
|
125
|
+
* entries are re-copied from `pinned` after every reload so file edits to
|
|
126
|
+
* construction-captured settings cannot silently diverge: a top-level name
|
|
127
|
+
* pins the whole block, `block.key` pins one key of it.
|
|
96
128
|
*/
|
|
97
129
|
export function watchConfig(
|
|
98
130
|
path: string,
|
|
99
131
|
live: RouterConfig,
|
|
100
132
|
pinned: RouterConfig,
|
|
101
|
-
frozen: readonly
|
|
133
|
+
frozen: readonly string[],
|
|
102
134
|
opts: WatchConfigOptions = {},
|
|
103
135
|
): ConfigWatcher {
|
|
104
136
|
let closed = false;
|
|
@@ -119,14 +151,31 @@ export function watchConfig(
|
|
|
119
151
|
}
|
|
120
152
|
lastError = "";
|
|
121
153
|
|
|
122
|
-
const
|
|
154
|
+
const frozenBlocks = new Set(frozen.filter((f) => !f.includes(".")));
|
|
155
|
+
const frozenKeys = new Map<string, string[]>();
|
|
156
|
+
for (const f of frozen) {
|
|
157
|
+
const dot = f.indexOf(".");
|
|
158
|
+
if (dot < 0) continue;
|
|
159
|
+
const block = f.slice(0, dot);
|
|
160
|
+
frozenKeys.set(block, [...(frozenKeys.get(block) ?? []), f.slice(dot + 1)]);
|
|
161
|
+
}
|
|
123
162
|
const changed: string[] = [];
|
|
124
163
|
const next = result.cfg as unknown as Record<string, unknown>;
|
|
164
|
+
const pinnedRec = pinned as unknown as Record<string, unknown>;
|
|
125
165
|
for (const key of Object.keys(next)) {
|
|
126
|
-
// Frozen blocks belong to construction: keep the pinned values.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
166
|
+
// Frozen blocks belong to construction: keep the pinned values. A
|
|
167
|
+
// partially frozen block takes the file's siblings and the pinned keys.
|
|
168
|
+
let value = frozenBlocks.has(key) ? pinnedRec[key] : next[key];
|
|
169
|
+
const keys = frozenKeys.get(key);
|
|
170
|
+
if (keys !== undefined && !frozenBlocks.has(key) && value !== null && typeof value === "object") {
|
|
171
|
+
const merged: Record<string, unknown> = { ...(value as Record<string, unknown>) };
|
|
172
|
+
const pinnedBlock = (pinnedRec[key] ?? {}) as Record<string, unknown>;
|
|
173
|
+
for (const k of keys) {
|
|
174
|
+
if (pinnedBlock[k] === undefined) delete merged[k];
|
|
175
|
+
else merged[k] = pinnedBlock[k];
|
|
176
|
+
}
|
|
177
|
+
value = merged;
|
|
178
|
+
}
|
|
130
179
|
const before = JSON.stringify((live as unknown as Record<string, unknown>)[key]);
|
|
131
180
|
const after = JSON.stringify(value);
|
|
132
181
|
if (before !== after) changed.push(key);
|
package/src/config/schema.ts
CHANGED
|
@@ -229,6 +229,7 @@ const ledger = z.strictObject({
|
|
|
229
229
|
blendMinSamples: z.number().int().nonnegative().optional(),
|
|
230
230
|
fallbackBlend: fallbackBlend.optional(),
|
|
231
231
|
conversationTtlMs: z.number().positive().optional(),
|
|
232
|
+
retentionDays: z.number().int().nonnegative().optional(),
|
|
232
233
|
});
|
|
233
234
|
|
|
234
235
|
// Complete entries: arrays replace wholesale, so a partial profile would
|
|
@@ -276,6 +277,13 @@ export const configInputSchema = z.strictObject({
|
|
|
276
277
|
budget: budget.optional(),
|
|
277
278
|
profiles: z.array(profile).optional(),
|
|
278
279
|
report: z.strictObject({ baselines: z.array(z.string()).optional(), dailySummary: z.boolean().optional() }).optional(),
|
|
280
|
+
harnessSwitch: z
|
|
281
|
+
.strictObject({
|
|
282
|
+
enabled: z.boolean().optional(),
|
|
283
|
+
models: z.record(tier, z.string().regex(/^[^/]+\/.+$/, "provider/id")).optional(),
|
|
284
|
+
minConfidence: z.number().min(0).max(1).optional(),
|
|
285
|
+
})
|
|
286
|
+
.optional(),
|
|
279
287
|
digest: z
|
|
280
288
|
.strictObject({
|
|
281
289
|
enabled: z.boolean().optional(),
|
package/src/config/types.ts
CHANGED
|
@@ -607,6 +607,25 @@ export interface ReportConfig {
|
|
|
607
607
|
dailySummary: boolean;
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
+
/**
|
|
611
|
+
* Harness-side model switch (experimental): the router advises a tier for
|
|
612
|
+
* each user prompt and the harness moves its own active model to a
|
|
613
|
+
* harness-native one for the mapped tiers. See omp-extension/router-switch.ts.
|
|
614
|
+
*/
|
|
615
|
+
export interface HarnessSwitchConfig {
|
|
616
|
+
enabled: boolean;
|
|
617
|
+
/**
|
|
618
|
+
* Tier → harness model as `provider/id` in the harness's own registry
|
|
619
|
+
* (e.g. `hard: anthropic/claude-opus-4-8`). A tier serves itself and every
|
|
620
|
+
* tier above it up to the next mapped one; unmapped low tiers stay on the
|
|
621
|
+
* router. Turns on a native model bill the harness's own provider (a
|
|
622
|
+
* subscription, typically) and never reach the ledger.
|
|
623
|
+
*/
|
|
624
|
+
models: Partial<Record<"trivial" | "simple" | "moderate" | "hard", string>>;
|
|
625
|
+
/** Advice below this heuristic confidence leaves the model where it is. */
|
|
626
|
+
minConfidence: number;
|
|
627
|
+
}
|
|
628
|
+
|
|
610
629
|
export interface BudgetConfig {
|
|
611
630
|
/** Reject or downgrade when a turn's cold forecast exceeds this, USD. */
|
|
612
631
|
perTurnUsd?: number;
|
|
@@ -657,6 +676,13 @@ export interface LedgerConfig {
|
|
|
657
676
|
fallbackBlend: { inputPerMtok: number; outputPerMtok: number };
|
|
658
677
|
/** Drop conversation state untouched for longer than this, ms. */
|
|
659
678
|
conversationTtlMs: number;
|
|
679
|
+
/**
|
|
680
|
+
* Delete ledger rows older than this many days (checked hourly). 0 keeps
|
|
681
|
+
* everything. The ledger grows ~2.5 MB a day under steady use; trust,
|
|
682
|
+
* reports and replay only read windows well inside a year. Freed pages
|
|
683
|
+
* are reused, so the file stops growing rather than shrinking.
|
|
684
|
+
*/
|
|
685
|
+
retentionDays: number;
|
|
660
686
|
}
|
|
661
687
|
|
|
662
688
|
/**
|
|
@@ -795,6 +821,7 @@ export interface RouterConfig {
|
|
|
795
821
|
budget: BudgetConfig;
|
|
796
822
|
report: ReportConfig;
|
|
797
823
|
digest: DigestConfig;
|
|
824
|
+
harnessSwitch: HarnessSwitchConfig;
|
|
798
825
|
profiles: ProfileConfig[];
|
|
799
826
|
ledger: LedgerConfig;
|
|
800
827
|
/**
|
package/src/cost/ledger.ts
CHANGED
|
@@ -375,6 +375,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
375
375
|
);
|
|
376
376
|
const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
|
|
377
377
|
const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
|
|
378
|
+
const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
|
|
379
|
+
const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
|
|
378
380
|
const providerSpendStmt = db.query(
|
|
379
381
|
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
|
|
380
382
|
);
|
|
@@ -623,6 +625,13 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
623
625
|
const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
|
|
624
626
|
return row?.total ?? 0;
|
|
625
627
|
},
|
|
628
|
+
prune(retentionDays: number, nowMs = Date.now()): number {
|
|
629
|
+
if (retentionDays <= 0) return 0;
|
|
630
|
+
return pruneStmt.run(nowMs - retentionDays * DAY_MS).changes;
|
|
631
|
+
},
|
|
632
|
+
markWasted(id: string): void {
|
|
633
|
+
wasteStmt.run(id);
|
|
634
|
+
},
|
|
626
635
|
latestForSession(ompSessionId: string): LedgerEntry | null {
|
|
627
636
|
if (ompSessionId === "") return null;
|
|
628
637
|
const row = sessionStmt.get(ompSessionId, 1) as LedgerRow | null;
|
package/src/cost/report.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface ReportTotals {
|
|
|
34
34
|
digests: number;
|
|
35
35
|
digestSpendUsd: number;
|
|
36
36
|
digestInputTokens: number;
|
|
37
|
+
/** Digests the agent went back on: the same tool re-run with the same primary argument afterwards (row marked wasted). */
|
|
38
|
+
digestReruns: number;
|
|
39
|
+
/** Forecast accuracy over clean kept rows with a reported cost: mean |predicted − reported| ÷ reported, and the share over-predicted. */
|
|
40
|
+
forecastSamples: number;
|
|
41
|
+
forecastMeanError: number;
|
|
42
|
+
forecastOverShare: number;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
export interface ReportRow {
|
|
@@ -134,6 +140,8 @@ const COMP = "json_extract(usage, '$.completionTokens')";
|
|
|
134
140
|
const PROVIDER = "CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ELSE 'openrouter' END";
|
|
135
141
|
const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
|
|
136
142
|
const EST = "json_extract(usage, '$.cachedEstimated') = 1";
|
|
143
|
+
/** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
|
|
144
|
+
const FORECASTABLE = "reported_usd > 0 AND predicted_usd IS NOT NULL AND wasted = 0 AND error IS NULL AND requested_model <> 'digest'";
|
|
137
145
|
|
|
138
146
|
const ROW_SELECT = `
|
|
139
147
|
COUNT(*) AS dispatches,
|
|
@@ -213,6 +221,10 @@ export function buildUsageReport(
|
|
|
213
221
|
SUM(CASE WHEN requested_model = 'digest' THEN 1 ELSE 0 END) AS digests,
|
|
214
222
|
COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
|
|
215
223
|
COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
|
|
224
|
+
SUM(CASE WHEN requested_model = 'digest' AND wasted = 1 THEN 1 ELSE 0 END) AS digest_reruns,
|
|
225
|
+
SUM(CASE WHEN ${FORECASTABLE} THEN 1 ELSE 0 END) AS fc_n,
|
|
226
|
+
COALESCE(SUM(CASE WHEN ${FORECASTABLE} THEN ABS(predicted_usd - reported_usd) / reported_usd END), 0) AS fc_err,
|
|
227
|
+
SUM(CASE WHEN ${FORECASTABLE} AND predicted_usd > reported_usd THEN 1 ELSE 0 END) AS fc_over,
|
|
216
228
|
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
217
229
|
SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
|
|
218
230
|
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
|
|
@@ -232,6 +244,10 @@ export function buildUsageReport(
|
|
|
232
244
|
digests: number | null;
|
|
233
245
|
digest_spend: number;
|
|
234
246
|
digest_input: number;
|
|
247
|
+
digest_reruns: number | null;
|
|
248
|
+
fc_n: number | null;
|
|
249
|
+
fc_err: number;
|
|
250
|
+
fc_over: number | null;
|
|
235
251
|
escalations: number | null;
|
|
236
252
|
failovers: number | null;
|
|
237
253
|
errors: number | null;
|
|
@@ -354,6 +370,10 @@ export function buildUsageReport(
|
|
|
354
370
|
digests: t.digests ?? 0,
|
|
355
371
|
digestSpendUsd: t.digest_spend,
|
|
356
372
|
digestInputTokens: t.digest_input,
|
|
373
|
+
digestReruns: t.digest_reruns ?? 0,
|
|
374
|
+
forecastSamples: t.fc_n ?? 0,
|
|
375
|
+
forecastMeanError: (t.fc_n ?? 0) > 0 ? t.fc_err / (t.fc_n ?? 1) : 0,
|
|
376
|
+
forecastOverShare: (t.fc_n ?? 0) > 0 ? (t.fc_over ?? 0) / (t.fc_n ?? 1) : 0,
|
|
357
377
|
},
|
|
358
378
|
providers,
|
|
359
379
|
models,
|
|
@@ -419,7 +439,12 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
|
|
|
419
439
|
);
|
|
420
440
|
}
|
|
421
441
|
if (t.digests > 0) {
|
|
422
|
-
summary.push(
|
|
442
|
+
summary.push(
|
|
443
|
+
`digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)} · re-run rate ${pct(t.digestReruns / t.digests)} (${num(t.digestReruns)} fetched again in full)`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (t.forecastSamples > 0) {
|
|
447
|
+
summary.push(`forecast: mean error ${pct(t.forecastMeanError)} of reported cost over ${num(t.forecastSamples)} turns · ${pct(t.forecastOverShare)} over-predicted`);
|
|
423
448
|
}
|
|
424
449
|
if (t.subagentDispatches > 0) {
|
|
425
450
|
summary.push(`subagents: ${num(t.subagentDispatches)} dispatches, ${usd(t.subagentSpendUsd)} (${pct(t.spendUsd > 0 ? t.subagentSpendUsd / t.spendUsd : 0)} of spend)`);
|
package/src/cost/types.ts
CHANGED
|
@@ -303,4 +303,8 @@ export interface Ledger {
|
|
|
303
303
|
latestForSession?(ompSessionId: string): LedgerEntry | null;
|
|
304
304
|
/** Newest entries for an omp session, newest first. Optional. */
|
|
305
305
|
entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
|
|
306
|
+
/** Deletes rows older than `retentionDays` (0 ⇒ none); returns how many. Optional. */
|
|
307
|
+
prune?(retentionDays: number, nowMs?: number): number;
|
|
308
|
+
/** Marks one row wasted after the fact (a digest the agent went back on). Optional. */
|
|
309
|
+
markWasted?(id: string): void;
|
|
306
310
|
}
|
package/src/router/compaction.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function validatePlan(
|
|
|
64
64
|
* for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
|
|
65
65
|
* detect when a later call supersedes an earlier read of the same resource.
|
|
66
66
|
*/
|
|
67
|
-
function primaryArg(argsJson: string): string | null {
|
|
67
|
+
export function primaryArg(argsJson: string): string | null {
|
|
68
68
|
try {
|
|
69
69
|
const parsed: unknown = JSON.parse(argsJson);
|
|
70
70
|
if (parsed !== null && typeof parsed === "object") {
|