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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +152 -0
- package/hermes-plugin/__init__.py +3 -0
- package/hermes-plugin/native/__init__.py +348 -0
- package/hermes-plugin/native/__pycache__/__init__.cpython-311.pyc +0 -0
- package/hermes-plugin/native/plugin.yaml +5 -0
- package/omp-extension/digest-logic.ts +9 -2
- 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/cli/config-wizard.ts +8 -0
- package/src/config/defaults.ts +20 -0
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +28 -0
- package/src/server/advise.ts +82 -0
- package/src/server/digest.ts +7 -1
- package/src/server/http.ts +28 -4
- package/src/wire/openai/request.ts +7 -0
- package/test/config-wizard.test.ts +2 -1
- package/test/failover.test.ts +2 -1
- package/test/harness-requests.test.ts +137 -0
- package/test/harness-switch.test.ts +59 -0
- package/test/turn.test.ts +2 -1
|
@@ -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/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: [
|
package/src/config/defaults.ts
CHANGED
|
@@ -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",
|
package/src/config/schema.ts
CHANGED
|
@@ -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(),
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/digest.ts
CHANGED
|
@@ -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
|
|
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" };
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/test/failover.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|