auto-model-router 0.4.4 → 0.4.6

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.
@@ -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.6",
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: [
@@ -309,6 +309,20 @@ export const DEFAULT_CONFIG: RouterConfig = {
309
309
  maxOutputTokens: 700,
310
310
  maxCostUsd: 0.02,
311
311
  timeoutMs: 25_000,
312
+ // Hermes, Cline/Roo/Kilo, Codex and OpenCode spellings of the same tools.
313
+ toolAliases: {
314
+ read_file: "read",
315
+ search_files: "grep",
316
+ list_files: "ls",
317
+ list_dir: "ls",
318
+ list: "ls",
319
+ terminal: "bash",
320
+ execute_command: "bash",
321
+ execute_code: "bash",
322
+ shell: "bash",
323
+ web_extract: "web_fetch",
324
+ fetch_url: "web_fetch",
325
+ },
312
326
  },
313
327
  report: {
314
328
  // The frontier pair most omp users would otherwise run on.
@@ -316,6 +330,12 @@ export const DEFAULT_CONFIG: RouterConfig = {
316
330
  // One transcript message per day, at the first interactive session start.
317
331
  dailySummary: true,
318
332
  },
333
+ harnessSwitch: {
334
+ // Off: moving the harness's own model is a visible change the operator opts into.
335
+ enabled: false,
336
+ models: {},
337
+ minConfidence: 0.6,
338
+ },
319
339
  budget: {
320
340
  // No caps by default; at a configured ceiling, downgrade rather than fail.
321
341
  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(),
@@ -289,6 +296,7 @@ export const configInputSchema = z.strictObject({
289
296
  maxOutputTokens: z.number().int().positive().optional(),
290
297
  maxCostUsd: z.number().nonnegative().optional(),
291
298
  timeoutMs: z.number().int().positive().optional(),
299
+ toolAliases: z.record(z.string(), z.string()).optional(),
292
300
  })
293
301
  .optional(),
294
302
  ledger: ledger.optional(),
@@ -587,6 +587,14 @@ export interface DigestConfig {
587
587
  /** Skip when the digest itself would cost more than this, USD. */
588
588
  maxCostUsd: number;
589
589
  timeoutMs: number;
590
+ /**
591
+ * Harness tool names → the canonical names `tools` lists (read, grep,
592
+ * glob, bash, ls, web_fetch, …). Hermes calls its reader `read_file`,
593
+ * Cline `execute_command`, OpenCode `webfetch`; the alias table lets one
594
+ * `tools` list serve every harness. Lower-case keys; unknown names pass
595
+ * through unchanged.
596
+ */
597
+ toolAliases: Record<string, string>;
590
598
  }
591
599
 
592
600
  /** Usage-report options. */
@@ -607,6 +615,25 @@ export interface ReportConfig {
607
615
  dailySummary: boolean;
608
616
  }
609
617
 
618
+ /**
619
+ * Harness-side model switch (experimental): the router advises a tier for
620
+ * each user prompt and the harness moves its own active model to a
621
+ * harness-native one for the mapped tiers. See omp-extension/router-switch.ts.
622
+ */
623
+ export interface HarnessSwitchConfig {
624
+ enabled: boolean;
625
+ /**
626
+ * Tier → harness model as `provider/id` in the harness's own registry
627
+ * (e.g. `hard: anthropic/claude-opus-4-8`). A tier serves itself and every
628
+ * tier above it up to the next mapped one; unmapped low tiers stay on the
629
+ * router. Turns on a native model bill the harness's own provider (a
630
+ * subscription, typically) and never reach the ledger.
631
+ */
632
+ models: Partial<Record<"trivial" | "simple" | "moderate" | "hard", string>>;
633
+ /** Advice below this heuristic confidence leaves the model where it is. */
634
+ minConfidence: number;
635
+ }
636
+
610
637
  export interface BudgetConfig {
611
638
  /** Reject or downgrade when a turn's cold forecast exceeds this, USD. */
612
639
  perTurnUsd?: number;
@@ -802,6 +829,7 @@ export interface RouterConfig {
802
829
  budget: BudgetConfig;
803
830
  report: ReportConfig;
804
831
  digest: DigestConfig;
832
+ harnessSwitch: HarnessSwitchConfig;
805
833
  profiles: ProfileConfig[];
806
834
  ledger: LedgerConfig;
807
835
  /**
@@ -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
+ }
@@ -67,10 +67,16 @@ const DIGEST_SYSTEM = `You condense tool output for a coding agent that is mid-t
67
67
  const tierIdx = (t: string): number => TIER_ORDER.indexOf(t as Tier);
68
68
 
69
69
  /** Whether a session's current model is expensive enough for a digest to pay off. */
70
+ /** The canonical tool name a harness-specific one maps to (`digest.toolAliases`); lower-cased. */
71
+ export function canonicalTool(cfg: Pick<DigestConfig, "toolAliases">, toolName: string): string {
72
+ const lower = toolName.toLowerCase();
73
+ return cfg.toolAliases[lower] ?? lower;
74
+ }
75
+
70
76
  export function digestApplies(cfg: DigestConfig, toolName: string, bytes: number, isError: boolean, currentTier: string | null): { ok: true } | { ok: false; reason: string } {
71
77
  if (!cfg.enabled) return { ok: false, reason: "digest disabled" };
72
78
  if (isError) return { ok: false, reason: "error results are never digested" };
73
- if (!cfg.tools.includes(toolName.toLowerCase())) return { ok: false, reason: `tool ${toolName} not in digest.tools` };
79
+ if (!cfg.tools.includes(canonicalTool(cfg, toolName))) return { ok: false, reason: `tool ${toolName} not in digest.tools` };
74
80
  if (bytes < cfg.minBytes) return { ok: false, reason: `${bytes} bytes < minBytes ${cfg.minBytes}` };
75
81
  if (bytes > cfg.maxBytes) return { ok: false, reason: `${bytes} bytes > maxBytes ${cfg.maxBytes}` };
76
82
  if (currentTier === null) return { ok: false, reason: "no routed turn in this session yet" };
@@ -7,9 +7,10 @@ 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
- import { baselinePrices, buildUsageReport } from "../cost/report.ts";
12
- import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
12
+ import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
13
+ import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
13
14
  import type { Ledger, ModelTrust } from "../cost/types.ts";
14
15
  import { createRouter } from "../router/index.ts";
15
16
  import { createConversationStore } from "../router/state.ts";
@@ -413,7 +414,29 @@ export function startServer(cfg: RouterConfig): StartedServer {
413
414
  const parsedDays = rawDays === null ? 7 : Number.parseInt(rawDays, 10);
414
415
  const windowDays = Number.isInteger(parsedDays) ? Math.min(Math.max(parsedDays, 1), 365) : 7;
415
416
  const harnessId = url.searchParams.get("harness") ?? "";
416
- return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
417
+ const report = buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) });
418
+ // ?format=text: the rendered report for harnesses without a renderer of their own (the Hermes plugin).
419
+ if (url.searchParams.get("format") === "text") return new Response(renderUsageReport(report), { headers: { "content-type": "text/plain; charset=utf-8" } });
420
+ return json(report);
421
+ }
422
+ if (req.method === "GET" && url.pathname === "/v1/router/advise/policy") {
423
+ const h = cfg.harnessSwitch;
424
+ return json({ enabled: h.enabled, models: h.models, minConfidence: h.minConfidence });
425
+ }
426
+ if (req.method === "POST" && url.pathname === "/v1/router/advise") {
427
+ // Harness-side switch: classify a prompt before the harness builds
428
+ // the request. Heuristic only; nothing is dispatched or recorded.
429
+ const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
430
+ if (body === null || typeof body.text !== "string") {
431
+ return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "text required" });
432
+ }
433
+ return json(
434
+ advise(cfg, ledger, {
435
+ ompSessionId: typeof body.ompSessionId === "string" ? body.ompSessionId : "",
436
+ harnessId: typeof body.harnessId === "string" ? body.harnessId : "",
437
+ text: body.text.slice(0, 16_000),
438
+ }),
439
+ );
417
440
  }
418
441
  if (req.method === "GET" && url.pathname === "/v1/router/summary") {
419
442
  // The last 24 hours in a few lines. `auto=1` is the session-start
@@ -436,6 +459,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
436
459
  });
437
460
  if (auto && !summaryHasNews(summary)) return json({ due: false, reason: "nothing to report", summary: null });
438
461
  if (auto) markSummaryShown(kv, harnessId);
462
+ if (url.searchParams.get("format") === "text") return new Response(renderDailySummary(summary), { headers: { "content-type": "text/plain; charset=utf-8" } });
439
463
  return json({ due: true, summary });
440
464
  }
441
465
  if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
@@ -480,7 +504,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
480
504
  }
481
505
  if (req.method === "GET" && url.pathname === "/v1/router/digest/policy") {
482
506
  const d = cfg.digest;
483
- return json({ enabled: d.enabled, minBytes: d.minBytes, maxBytes: d.maxBytes, tools: d.tools, fromTier: d.fromTier });
507
+ return json({ enabled: d.enabled, minBytes: d.minBytes, maxBytes: d.maxBytes, tools: d.tools, toolAliases: d.toolAliases, fromTier: d.fromTier });
484
508
  }
485
509
  if (req.method === "POST" && url.pathname === "/v1/router/digest") {
486
510
  const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
@@ -213,6 +213,9 @@ function applyCompaction(messages: Record<string, unknown>[], edits: readonly Co
213
213
  }
214
214
  }
215
215
 
216
+ /** Request parameters that exist only on OpenAI's own platform; dropped before dispatch. */
217
+ export const OPENAI_ONLY_PARAMS: readonly string[] = ["store", "prompt_cache_key", "safety_identifier", "service_tier", "metadata", "web_search_options"];
218
+
216
219
  function renderUpstreamBody(
217
220
  original: Record<string, unknown>,
218
221
  m: UpstreamMutations,
@@ -227,6 +230,10 @@ function renderUpstreamBody(
227
230
  body.stream = true;
228
231
  // OpenRouter returns usage unconditionally and the parameter is deprecated.
229
232
  delete body.stream_options;
233
+ // OpenAI-platform-only parameters other harnesses send (Codex, Aider,
234
+ // Cline): storage, cache keys, tiers and abuse ids mean nothing upstream
235
+ // and some providers reject unknown fields.
236
+ for (const key of OPENAI_ONLY_PARAMS) delete body[key];
230
237
 
231
238
  if (m.maxTokens !== undefined) {
232
239
  // Respect whichever max-token spelling the client used.
@@ -142,7 +142,7 @@ describe("validateField", () => {
142
142
 
143
143
  describe("WIZARD_SECTIONS coverage", () => {
144
144
  /** Leaves that are edited as whole records/arrays rather than fields. */
145
- const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "profiles"]);
145
+ const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles"]);
146
146
 
147
147
  function leaves(obj: unknown, prefix = ""): string[] {
148
148
  if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return [prefix];
@@ -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,7 +77,8 @@ 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
- digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
80
+ harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
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, toolAliases: {} },
81
82
  profiles: [],
82
83
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
83
84
  adaptiveTierFloors: true,
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import { canonicalTool, digestApplies } from "../src/server/digest.ts";
5
+ import { OPENAI_ONLY_PARAMS, parseChatRequest } from "../src/wire/openai/request.ts";
6
+ import type { UpstreamMutations } from "../src/wire/types.ts";
7
+ import { parsePolicy, shouldSend } from "../omp-extension/digest-logic.ts";
8
+
9
+ /**
10
+ * Request shapes the config-only harnesses send to an OpenAI-compatible
11
+ * endpoint, as each harness documents them: the router must parse them,
12
+ * keep their tool calls and headers, and drop the OpenAI-platform-only
13
+ * parameters before dispatch. These are representative bodies, not captured
14
+ * traffic; a harness release that changes its shape belongs here as a new case.
15
+ */
16
+
17
+ const MUT: UpstreamMutations = { slug: "x/y", fallbacks: [], sessionId: "s", cacheBreakpointMessageIndices: [], reasoning: undefined, maxTokens: undefined, stripAssistantReasoning: false };
18
+
19
+ const TOOL = (name: string) => ({ type: "function", function: { name, description: name, parameters: { type: "object", properties: { path: { type: "string" } } } } });
20
+
21
+ const HARNESSES: Record<string, { headers: Record<string, string>; body: Record<string, unknown>; toolCall?: string }> = {
22
+ codex: {
23
+ headers: { "X-Omp-Harness": "codex" },
24
+ body: {
25
+ model: "auto",
26
+ messages: [
27
+ { role: "developer", content: "You are Codex." },
28
+ { role: "user", content: "fix the failing test" },
29
+ { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "shell", arguments: '{"command":["cat","x.ts"]}' } }] },
30
+ { role: "tool", tool_call_id: "call_1", content: "export const x = 1;" },
31
+ ],
32
+ tools: [TOOL("shell"), TOOL("apply_patch")],
33
+ stream: true,
34
+ store: false,
35
+ prompt_cache_key: "session-abc",
36
+ reasoning_effort: "medium",
37
+ parallel_tool_calls: false,
38
+ },
39
+ toolCall: "shell",
40
+ },
41
+ aider: {
42
+ headers: { "X-Omp-Harness": "aider" },
43
+ body: { model: "auto", messages: [{ role: "system", content: "Act as an expert software developer." }, { role: "user", content: "add a retry helper" }], stream: true, temperature: 0, extra_body: {} },
44
+ },
45
+ cline: {
46
+ headers: { "X-Omp-Harness": "cline" },
47
+ body: {
48
+ model: "auto",
49
+ messages: [
50
+ { role: "system", content: "You are Cline." },
51
+ { role: "user", content: [{ type: "text", text: "<task>rename the helper</task>" }] },
52
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read_file", arguments: '{"path":"src/a.ts"}' } }] },
53
+ { role: "tool", tool_call_id: "c1", content: [{ type: "text", text: "line 1\nline 2" }] },
54
+ ],
55
+ tools: [TOOL("read_file"), TOOL("execute_command"), TOOL("search_files")],
56
+ stream: true,
57
+ stream_options: { include_usage: true },
58
+ temperature: 0,
59
+ },
60
+ toolCall: "read_file",
61
+ },
62
+ opencode: {
63
+ headers: { "X-Omp-Harness": "opencode" },
64
+ body: {
65
+ model: "auto",
66
+ messages: [{ role: "system", content: "opencode" }, { role: "user", content: "list the tests" }],
67
+ tools: [TOOL("read"), TOOL("bash"), TOOL("glob"), TOOL("webfetch")],
68
+ stream: true,
69
+ stream_options: { include_usage: true },
70
+ max_tokens: 8192,
71
+ service_tier: "auto",
72
+ },
73
+ },
74
+ hermes: {
75
+ headers: { "X-Omp-Harness": "hermes", "X-Omp-Session": "hermes-session-1", "X-Omp-Subagent": "1" },
76
+ body: {
77
+ model: "auto",
78
+ messages: [
79
+ { role: "system", content: "You are Hermes." },
80
+ { role: "user", content: "summarise the repo" },
81
+ { role: "assistant", content: null, tool_calls: [{ id: "h1", type: "function", function: { name: "read_file", arguments: '{"path":"README.md"}' } }] },
82
+ { role: "tool", tool_call_id: "h1", content: '{"content":"# repo","path":"README.md"}' },
83
+ ],
84
+ tools: [TOOL("read_file"), TOOL("terminal"), TOOL("search_files"), TOOL("delegate_task")],
85
+ stream: true,
86
+ max_tokens: 4096,
87
+ },
88
+ toolCall: "read_file",
89
+ },
90
+ };
91
+
92
+ describe("config-only harness request shapes", () => {
93
+ for (const [name, h] of Object.entries(HARNESSES)) {
94
+ test(`${name}: parses, keeps headers and tool calls, and drops OpenAI-only parameters`, () => {
95
+ const req = parseChatRequest(structuredClone(h.body), new Headers(h.headers));
96
+ expect(req.harnessId).toBe(name);
97
+ expect(req.requestedModel).toBe("auto");
98
+ expect(req.messages.length).toBe((h.body.messages as unknown[]).length);
99
+ if (h.toolCall !== undefined) {
100
+ const assistant = req.messages.find((m) => m.role === "assistant");
101
+ expect(assistant?.toolCalls[0]?.name).toBe(h.toolCall);
102
+ expect(req.messages.find((m) => m.role === "tool")?.toolCallId).toBeDefined();
103
+ }
104
+ const out = req.renderUpstreamBody(MUT);
105
+ for (const key of OPENAI_ONLY_PARAMS) expect(key in out).toBe(false);
106
+ expect("stream_options" in out).toBe(false);
107
+ expect(out.model).toBe("x/y");
108
+ expect(out.stream).toBe(true);
109
+ // Parameters every provider understands survive.
110
+ if ("temperature" in h.body) expect(out.temperature).toBe(h.body.temperature);
111
+ if ("tools" in h.body) expect((out.tools as unknown[]).length).toBe((h.body.tools as unknown[]).length);
112
+ });
113
+ }
114
+
115
+ test("hermes headers carry session and subagent identity", () => {
116
+ const req = parseChatRequest(structuredClone(HARNESSES.hermes!.body), new Headers(HARNESSES.hermes!.headers));
117
+ expect(req.ompSessionId).toBe("hermes-session-1");
118
+ expect(req.isSubagent).toBe(true);
119
+ });
120
+ });
121
+
122
+ describe("digest tool aliases across harnesses", () => {
123
+ const d = { ...DEFAULT_CONFIG.digest, enabled: true, minBytes: 10, maxBytes: 10_000 };
124
+ test("harness spellings map onto the canonical tools list on both sides", () => {
125
+ for (const [alias, canonical] of [["read_file", "read"], ["search_files", "grep"], ["terminal", "bash"], ["execute_command", "bash"], ["shell", "bash"], ["list_files", "ls"], ["web_extract", "web_fetch"], ["READ_FILE", "read"]] as const) {
126
+ expect(canonicalTool(d, alias)).toBe(canonical);
127
+ expect(digestApplies(d, alias, 500, false, "hard").ok).toBe(true);
128
+ }
129
+ expect(canonicalTool(d, "write_file")).toBe("write_file");
130
+ expect(digestApplies(d, "write_file", 500, false, "hard").ok).toBe(false);
131
+ // The policy the router publishes carries the aliases, and the client gate honours them.
132
+ const policy = parsePolicy({ enabled: true, minBytes: 10, maxBytes: 10_000, tools: d.tools, toolAliases: d.toolAliases, fromTier: "moderate" });
133
+ expect(shouldSend(policy, "search_files", false, "x".repeat(100), false)).toBe(true);
134
+ expect(shouldSend(policy, "delegate_task", false, "x".repeat(100), false)).toBe(false);
135
+ expect(parsePolicy({ enabled: true }).toolAliases).toEqual({});
136
+ });
137
+ });