auto-model-router 0.4.4 → 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.
@@ -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.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.4",
17
+ "version": "0.4.5",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -837,6 +837,14 @@ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
837
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. |
838
838
  | `dailySummary` | `true` | Post the daily summary (below) into the transcript at the first interactive omp session start of each day. Hot-reloads. |
839
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
+
840
848
  ### `ledger` — cost measurement
841
849
 
842
850
  | Key | Default | Meaning |
@@ -929,6 +937,35 @@ router handles for you: no `models[]` fallback cascade, no `tool_choice`,
929
937
  `reasoning_effort` instead of the `reasoning` object, and no `cache_control`
930
938
  markers (they are stripped before dispatch).
931
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
+
932
969
  ## Multiple coding harnesses, one router
933
970
 
934
971
  A single embedded router can serve several omp sessions without them stepping
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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: [
@@ -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",
@@ -277,6 +277,13 @@ export const configInputSchema = z.strictObject({
277
277
  budget: budget.optional(),
278
278
  profiles: z.array(profile).optional(),
279
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(),
280
287
  digest: z
281
288
  .strictObject({
282
289
  enabled: z.boolean().optional(),
@@ -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;
@@ -802,6 +821,7 @@ export interface RouterConfig {
802
821
  budget: BudgetConfig;
803
822
  report: ReportConfig;
804
823
  digest: DigestConfig;
824
+ harnessSwitch: HarnessSwitchConfig;
805
825
  profiles: ProfileConfig[];
806
826
  ledger: LedgerConfig;
807
827
  /**
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Harness-side model switch: the router advises a tier for a prompt BEFORE
3
+ * the harness dispatches it, so the harness can move its active model to one
4
+ * the router cannot proxy (a Claude subscription model, say) for hard work
5
+ * and back to the router for the rest.
6
+ *
7
+ * Why this shape: proxying a subscription upstream would mean translating
8
+ * the OpenAI wire to that provider's and carrying its OAuth token through a
9
+ * third-party process. Advising is cheaper and keeps the token where it
10
+ * belongs. The cost of a native turn lands on the subscription, not the
11
+ * ledger; the router only sees the turns it serves.
12
+ *
13
+ * The advice is the heuristic classifier over the user's prompt text alone
14
+ * (the harness has not built the request yet), plus the session's last
15
+ * routed tier for context. No adjudicator call, no dispatch, nothing recorded.
16
+ */
17
+
18
+ import type { RouterConfig } from "../config/types.ts";
19
+ import type { Ledger } from "../cost/types.ts";
20
+ import { scoreHeuristic } from "../router/classify.ts";
21
+ import { extractFeatures } from "../router/features.ts";
22
+ import { estimateTokens } from "../tokens/estimate.ts";
23
+ import type { TaskType, Tier } from "../router/types.ts";
24
+ import type { NormRequest } from "../wire/types.ts";
25
+
26
+ export interface AdviseRequest {
27
+ ompSessionId: string;
28
+ harnessId: string;
29
+ /** The user's prompt as submitted. */
30
+ text: string;
31
+ }
32
+
33
+ export interface Advice {
34
+ tier: Tier;
35
+ task: TaskType;
36
+ confidence: number;
37
+ score: number;
38
+ reasons: string[];
39
+ /** The tier this session's last routed turn ran at, when the router served one. */
40
+ lastTier: Tier | null;
41
+ }
42
+
43
+ const SYSTEM_STUB = "You are a coding agent.";
44
+
45
+ function requestOf(req: AdviseRequest): NormRequest {
46
+ const text = req.text;
47
+ return {
48
+ protocol: "openai-chat",
49
+ conversationKey: `advise:${req.ompSessionId}`,
50
+ harnessId: req.harnessId,
51
+ ompSessionId: req.ompSessionId,
52
+ agentdoxScope: "",
53
+ isSubagent: false,
54
+ requestedModel: "auto",
55
+ messages: [
56
+ { role: "system", text: SYSTEM_STUB, images: 0, textBytes: Buffer.byteLength(SYSTEM_STUB), toolCalls: [] },
57
+ { role: "user", text, images: 0, textBytes: Buffer.byteLength(text), toolCalls: [] },
58
+ ],
59
+ tools: [],
60
+ forcedToolChoice: false,
61
+ stream: false,
62
+ hasImages: false,
63
+ promptBytes: Buffer.byteLength(SYSTEM_STUB) + Buffer.byteLength(text),
64
+ renderUpstreamBody: () => ({}),
65
+ };
66
+ }
67
+
68
+ /** Classifies a prompt the way the first turn of a conversation would be, without dispatching anything. */
69
+ export function advise(cfg: RouterConfig, ledger: Ledger | null, req: AdviseRequest): Advice {
70
+ const norm = requestOf(req);
71
+ const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", ledger));
72
+ const cls = scoreHeuristic(features, cfg);
73
+ const last = req.ompSessionId === "" ? null : (ledger?.latestForSession?.(req.ompSessionId)?.tier ?? null);
74
+ return {
75
+ tier: cls.tier,
76
+ task: cls.task,
77
+ confidence: cls.confidence,
78
+ score: cls.score,
79
+ reasons: cls.reasons,
80
+ lastTier: last === null ? null : (last as Tier),
81
+ };
82
+ }
@@ -7,6 +7,7 @@ import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
7
7
  import { createLedger } from "../cost/ledger.ts";
8
8
  import { createSessionOverrides } from "./overrides.ts";
9
9
  import { createDigester } from "./digest.ts";
10
+ import { advise } from "./advise.ts";
10
11
  import { TIER_ORDER, type Tier } from "../router/types.ts";
11
12
  import { baselinePrices, buildUsageReport } from "../cost/report.ts";
12
13
  import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
@@ -415,6 +416,25 @@ export function startServer(cfg: RouterConfig): StartedServer {
415
416
  const harnessId = url.searchParams.get("harness") ?? "";
416
417
  return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
417
418
  }
419
+ if (req.method === "GET" && url.pathname === "/v1/router/advise/policy") {
420
+ const h = cfg.harnessSwitch;
421
+ return json({ enabled: h.enabled, models: h.models, minConfidence: h.minConfidence });
422
+ }
423
+ if (req.method === "POST" && url.pathname === "/v1/router/advise") {
424
+ // Harness-side switch: classify a prompt before the harness builds
425
+ // the request. Heuristic only; nothing is dispatched or recorded.
426
+ const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
427
+ if (body === null || typeof body.text !== "string") {
428
+ return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "text required" });
429
+ }
430
+ return json(
431
+ advise(cfg, ledger, {
432
+ ompSessionId: typeof body.ompSessionId === "string" ? body.ompSessionId : "",
433
+ harnessId: typeof body.harnessId === "string" ? body.harnessId : "",
434
+ text: body.text.slice(0, 16_000),
435
+ }),
436
+ );
437
+ }
418
438
  if (req.method === "GET" && url.pathname === "/v1/router/summary") {
419
439
  // The last 24 hours in a few lines. `auto=1` is the session-start
420
440
  // caller: it gets `due: false` unless report.dailySummary is on, no
@@ -172,6 +172,7 @@ describe("WIZARD_SECTIONS coverage", () => {
172
172
  ]),
173
173
  ...["coding", "vision", "documentation", "data", "chat"].flatMap((t) => [`tasks.${t}.minQuality`, `tasks.${t}.requireImage`, `tasks.${t}.prefer`]),
174
174
  ...["trivial", "simple", "moderate", "hard"].map((t) => `exploration.rates.${t}`),
175
+ ...["trivial", "simple", "moderate", "hard"].map((t) => `harnessSwitch.models.${t}`),
175
176
  ]);
176
177
  const known = new Set(leaves(DEFAULT_CONFIG));
177
178
  const unknown = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path)).filter((p) => !known.has(p) && !KNOWN_OPTIONAL.has(p));
@@ -77,6 +77,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
77
77
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [], dailySummary: false },
80
+ harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
80
81
  digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
81
82
  profiles: [],
82
83
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import { TIER_ORDER } from "../src/router/types.ts";
5
+ import { advise } from "../src/server/advise.ts";
6
+ import { decideSwitch, nativeModelFor, parseSwitchPolicy, type SwitchPolicy } from "../omp-extension/switch-logic.ts";
7
+
8
+ /**
9
+ * Harness-side model switch: the router's prompt-only advice, and the
10
+ * extension's decision about moving omp's active model.
11
+ */
12
+
13
+ describe("advise", () => {
14
+ test("classifies a prompt without a ledger or dispatch and reports the shape the extension reads", () => {
15
+ const a = advise(DEFAULT_CONFIG, null, { ompSessionId: "s", harnessId: "", text: "Redesign the routing pipeline so escalation and failover share one retry loop; consider cache costs and write the migration plan." });
16
+ expect(TIER_ORDER).toContain(a.tier);
17
+ expect(a.confidence).toBeGreaterThanOrEqual(0);
18
+ expect(a.confidence).toBeLessThanOrEqual(1);
19
+ expect(a.reasons.length).toBeGreaterThan(0);
20
+ expect(a.lastTier).toBeNull();
21
+ const terse = advise(DEFAULT_CONFIG, null, { ompSessionId: "", harnessId: "", text: "ok" });
22
+ expect(TIER_ORDER.indexOf(terse.tier)).toBeLessThanOrEqual(TIER_ORDER.indexOf(a.tier));
23
+ });
24
+ });
25
+
26
+ describe("switch policy", () => {
27
+ test("parses defensively and maps a tier to the nearest configured tier at or below it", () => {
28
+ expect(parseSwitchPolicy(null).enabled).toBe(false);
29
+ const p = parseSwitchPolicy({ enabled: true, models: { moderate: "anthropic/claude-sonnet-5", hard: "anthropic/claude-opus-4-8", simple: "no-slash" }, minConfidence: 0.5 });
30
+ expect(p.models).toEqual({ moderate: "anthropic/claude-sonnet-5", hard: "anthropic/claude-opus-4-8" });
31
+ expect(nativeModelFor(p, "hard")).toBe("anthropic/claude-opus-4-8");
32
+ expect(nativeModelFor(p, "moderate")).toBe("anthropic/claude-sonnet-5");
33
+ expect(nativeModelFor(p, "simple")).toBeUndefined();
34
+ expect(nativeModelFor(p, "trivial")).toBeUndefined();
35
+ });
36
+ });
37
+
38
+ describe("decideSwitch", () => {
39
+ const policy: SwitchPolicy = { enabled: true, models: { hard: "anthropic/claude-opus-4-8" }, minConfidence: 0.6 };
40
+ const router = "auto-model-router/auto";
41
+
42
+ test("moves up from the router for confident hard work, back for lighter work, and never past a manual choice", () => {
43
+ const up = decideSwitch({ policy, advised: { tier: "hard", confidence: 0.8 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null });
44
+ expect(up).toMatchObject({ action: "up", model: "anthropic/claude-opus-4-8" });
45
+ // Low confidence: stay.
46
+ expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.3 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
47
+ // Unmapped tier while on the router: stay.
48
+ expect(decideSwitch({ policy, advised: { tier: "moderate", confidence: 0.9 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
49
+ // On our own switch, hard again: already there.
50
+ expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.9 }, active: "anthropic/claude-opus-4-8", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router }).action).toBe("none");
51
+ // On our own switch, lighter work: back to the router model we left.
52
+ expect(decideSwitch({ policy, advised: { tier: "simple", confidence: 0.9 }, active: "anthropic/claude-opus-4-8", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router })).toMatchObject({ action: "back", model: router });
53
+ // The user chose a model by hand: leave it alone whatever the advice.
54
+ expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.9 }, active: "openai/gpt-5", activeIsRouter: false, switchedTo: null, returnTo: null }).action).toBe("none");
55
+ expect(decideSwitch({ policy, advised: { tier: "trivial", confidence: 0.9 }, active: "openai/gpt-5", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router }).action).toBe("none");
56
+ // Disabled: nothing moves.
57
+ expect(decideSwitch({ policy: { ...policy, enabled: false }, advised: { tier: "hard", confidence: 0.9 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
58
+ });
59
+ });
package/test/turn.test.ts CHANGED
@@ -77,6 +77,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
77
77
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [], dailySummary: false },
80
+ harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
80
81
  digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
81
82
  profiles: [],
82
83
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},