opencode-model-router 1.1.6 → 1.1.8
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/package.json +2 -2
- package/src/index.ts +103 -24
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-model-router",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "OpenCode plugin that routes tasks to tiered subagents (fast/medium/heavy) based on complexity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"license": "GPL-3.0-only",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "https://github.com/marco-jardim/opencode-model-router.git"
|
|
10
|
+
"url": "git+https://github.com/marco-jardim/opencode-model-router.git"
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://github.com/marco-jardim/opencode-model-router",
|
|
13
13
|
"bugs": {
|
package/src/index.ts
CHANGED
|
@@ -81,10 +81,18 @@ function configPath(): string {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function statePath(): string {
|
|
84
|
-
return join(
|
|
84
|
+
return join(
|
|
85
|
+
homedir(),
|
|
86
|
+
".config",
|
|
87
|
+
"opencode",
|
|
88
|
+
"opencode-model-router.state.json",
|
|
89
|
+
);
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
function resolvePresetName(
|
|
92
|
+
function resolvePresetName(
|
|
93
|
+
cfg: RouterConfig,
|
|
94
|
+
requestedPreset: string,
|
|
95
|
+
): string | undefined {
|
|
88
96
|
if (cfg.presets[requestedPreset]) {
|
|
89
97
|
return requestedPreset;
|
|
90
98
|
}
|
|
@@ -94,7 +102,9 @@ function resolvePresetName(cfg: RouterConfig, requestedPreset: string): string |
|
|
|
94
102
|
return undefined;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
return Object.keys(cfg.presets).find(
|
|
105
|
+
return Object.keys(cfg.presets).find(
|
|
106
|
+
(name) => name.toLowerCase() === normalized,
|
|
107
|
+
);
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
function validateConfig(raw: unknown): RouterConfig {
|
|
@@ -107,29 +117,45 @@ function validateConfig(raw: unknown): RouterConfig {
|
|
|
107
117
|
if (typeof obj.activePreset !== "string" || !obj.activePreset) {
|
|
108
118
|
throw new Error("tiers.json: 'activePreset' must be a non-empty string");
|
|
109
119
|
}
|
|
110
|
-
if (
|
|
120
|
+
if (
|
|
121
|
+
typeof obj.presets !== "object" ||
|
|
122
|
+
obj.presets === null ||
|
|
123
|
+
Array.isArray(obj.presets)
|
|
124
|
+
) {
|
|
111
125
|
throw new Error("tiers.json: 'presets' must be a non-null object");
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
const presets = obj.presets as Record<string, unknown>;
|
|
115
129
|
for (const [presetName, preset] of Object.entries(presets)) {
|
|
116
|
-
if (
|
|
130
|
+
if (
|
|
131
|
+
typeof preset !== "object" ||
|
|
132
|
+
preset === null ||
|
|
133
|
+
Array.isArray(preset)
|
|
134
|
+
) {
|
|
117
135
|
throw new Error(`tiers.json: preset '${presetName}' must be an object`);
|
|
118
136
|
}
|
|
119
137
|
const tiers = preset as Record<string, unknown>;
|
|
120
138
|
for (const [tierName, tier] of Object.entries(tiers)) {
|
|
121
139
|
if (typeof tier !== "object" || tier === null) {
|
|
122
|
-
throw new Error(
|
|
140
|
+
throw new Error(
|
|
141
|
+
`tiers.json: tier '${presetName}.${tierName}' must be an object`,
|
|
142
|
+
);
|
|
123
143
|
}
|
|
124
144
|
const t = tier as Record<string, unknown>;
|
|
125
145
|
if (typeof t.model !== "string" || !t.model) {
|
|
126
|
-
throw new Error(
|
|
146
|
+
throw new Error(
|
|
147
|
+
`tiers.json: '${presetName}.${tierName}.model' must be a non-empty string`,
|
|
148
|
+
);
|
|
127
149
|
}
|
|
128
150
|
if (typeof t.description !== "string") {
|
|
129
|
-
throw new Error(
|
|
151
|
+
throw new Error(
|
|
152
|
+
`tiers.json: '${presetName}.${tierName}.description' must be a string`,
|
|
153
|
+
);
|
|
130
154
|
}
|
|
131
155
|
if (!Array.isArray(t.whenToUse)) {
|
|
132
|
-
throw new Error(
|
|
156
|
+
throw new Error(
|
|
157
|
+
`tiers.json: '${presetName}.${tierName}.whenToUse' must be an array`,
|
|
158
|
+
);
|
|
133
159
|
}
|
|
134
160
|
}
|
|
135
161
|
}
|
|
@@ -143,7 +169,11 @@ function validateConfig(raw: unknown): RouterConfig {
|
|
|
143
169
|
|
|
144
170
|
// Validate modes if present
|
|
145
171
|
if (obj.modes !== undefined) {
|
|
146
|
-
if (
|
|
172
|
+
if (
|
|
173
|
+
typeof obj.modes !== "object" ||
|
|
174
|
+
obj.modes === null ||
|
|
175
|
+
Array.isArray(obj.modes)
|
|
176
|
+
) {
|
|
147
177
|
throw new Error("tiers.json: 'modes' must be an object");
|
|
148
178
|
}
|
|
149
179
|
const modes = obj.modes as Record<string, unknown>;
|
|
@@ -153,23 +183,33 @@ function validateConfig(raw: unknown): RouterConfig {
|
|
|
153
183
|
}
|
|
154
184
|
const m = mode as Record<string, unknown>;
|
|
155
185
|
if (typeof m.defaultTier !== "string") {
|
|
156
|
-
throw new Error(
|
|
186
|
+
throw new Error(
|
|
187
|
+
`tiers.json: mode '${modeName}.defaultTier' must be a string`,
|
|
188
|
+
);
|
|
157
189
|
}
|
|
158
190
|
if (typeof m.description !== "string") {
|
|
159
|
-
throw new Error(
|
|
191
|
+
throw new Error(
|
|
192
|
+
`tiers.json: mode '${modeName}.description' must be a string`,
|
|
193
|
+
);
|
|
160
194
|
}
|
|
161
195
|
}
|
|
162
196
|
}
|
|
163
197
|
|
|
164
198
|
// Validate taskPatterns if present
|
|
165
199
|
if (obj.taskPatterns !== undefined) {
|
|
166
|
-
if (
|
|
200
|
+
if (
|
|
201
|
+
typeof obj.taskPatterns !== "object" ||
|
|
202
|
+
obj.taskPatterns === null ||
|
|
203
|
+
Array.isArray(obj.taskPatterns)
|
|
204
|
+
) {
|
|
167
205
|
throw new Error("tiers.json: 'taskPatterns' must be an object");
|
|
168
206
|
}
|
|
169
207
|
const tp = obj.taskPatterns as Record<string, unknown>;
|
|
170
208
|
for (const [tierName, patterns] of Object.entries(tp)) {
|
|
171
209
|
if (!Array.isArray(patterns)) {
|
|
172
|
-
throw new Error(
|
|
210
|
+
throw new Error(
|
|
211
|
+
`tiers.json: taskPatterns.'${tierName}' must be an array of strings`,
|
|
212
|
+
);
|
|
173
213
|
}
|
|
174
214
|
}
|
|
175
215
|
}
|
|
@@ -187,7 +227,9 @@ function loadConfig(): RouterConfig {
|
|
|
187
227
|
|
|
188
228
|
try {
|
|
189
229
|
if (existsSync(statePath())) {
|
|
190
|
-
const state = JSON.parse(
|
|
230
|
+
const state = JSON.parse(
|
|
231
|
+
readFileSync(statePath(), "utf-8"),
|
|
232
|
+
) as RouterState;
|
|
191
233
|
if (state.activePreset) {
|
|
192
234
|
const resolved = resolvePresetName(cfg, state.activePreset);
|
|
193
235
|
if (resolved) {
|
|
@@ -307,7 +349,8 @@ function buildFallbackInstructions(cfg: RouterConfig): string {
|
|
|
307
349
|
if (!fb) return "";
|
|
308
350
|
|
|
309
351
|
const presetMap = fb.presets?.[cfg.activePreset];
|
|
310
|
-
const map =
|
|
352
|
+
const map =
|
|
353
|
+
presetMap && Object.keys(presetMap).length > 0 ? presetMap : fb.global;
|
|
311
354
|
if (!map) return "";
|
|
312
355
|
|
|
313
356
|
const chains = Object.entries(map).flatMap(([provider, presetOrder]) => {
|
|
@@ -327,7 +370,8 @@ function buildFallbackInstructions(cfg: RouterConfig): string {
|
|
|
327
370
|
// ---------------------------------------------------------------------------
|
|
328
371
|
|
|
329
372
|
function buildTaskTaxonomy(cfg: RouterConfig): string {
|
|
330
|
-
if (!cfg.taskPatterns || Object.keys(cfg.taskPatterns).length === 0)
|
|
373
|
+
if (!cfg.taskPatterns || Object.keys(cfg.taskPatterns).length === 0)
|
|
374
|
+
return "";
|
|
331
375
|
const lines = ["R:"];
|
|
332
376
|
for (const [tier, patterns] of Object.entries(cfg.taskPatterns)) {
|
|
333
377
|
if (Array.isArray(patterns) && patterns.length > 0) {
|
|
@@ -354,7 +398,7 @@ function buildDecomposeHint(cfg: RouterConfig): string {
|
|
|
354
398
|
|
|
355
399
|
// Sort by costRatio ascending to find cheapest (explore) and next (execute) tiers
|
|
356
400
|
const sorted = [...entries].sort(
|
|
357
|
-
([, a], [, b]) => (a.costRatio ?? 1) - (b.costRatio ?? 1)
|
|
401
|
+
([, a], [, b]) => (a.costRatio ?? 1) - (b.costRatio ?? 1),
|
|
358
402
|
);
|
|
359
403
|
const cheapest = sorted[0]?.[0];
|
|
360
404
|
const mid = sorted[1]?.[0];
|
|
@@ -386,7 +430,9 @@ function buildDelegationProtocol(cfg: RouterConfig): string {
|
|
|
386
430
|
const taxonomy = buildTaskTaxonomy(cfg);
|
|
387
431
|
const decompose = buildDecomposeHint(cfg);
|
|
388
432
|
|
|
389
|
-
const effectiveRules = mode?.overrideRules?.length
|
|
433
|
+
const effectiveRules = mode?.overrideRules?.length
|
|
434
|
+
? mode.overrideRules
|
|
435
|
+
: cfg.rules;
|
|
390
436
|
const rulesLine = effectiveRules.map((r, i) => `${i + 1}.${r}`).join(" ");
|
|
391
437
|
|
|
392
438
|
const fallback = buildFallbackInstructions(cfg);
|
|
@@ -454,7 +500,9 @@ function buildBudgetOutput(cfg: RouterConfig, args: string): string {
|
|
|
454
500
|
const lines = ["# Routing Modes\n"];
|
|
455
501
|
for (const [name, mode] of Object.entries(modes)) {
|
|
456
502
|
const active = name === currentMode ? " <- active" : "";
|
|
457
|
-
lines.push(
|
|
503
|
+
lines.push(
|
|
504
|
+
`- **${name}**${active}: ${mode.description} (default tier: @${mode.defaultTier})`,
|
|
505
|
+
);
|
|
458
506
|
}
|
|
459
507
|
lines.push(`\nSwitch with: \`/budget <mode>\``);
|
|
460
508
|
return lines.join("\n");
|
|
@@ -532,7 +580,24 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
532
580
|
let cfg = loadConfig();
|
|
533
581
|
const activeTiers = getActiveTiers(cfg);
|
|
534
582
|
|
|
583
|
+
// Track child (subagent) sessions so we can skip delegation protocol
|
|
584
|
+
// injection for them. Child sessions have a parentID — primary sessions don't.
|
|
585
|
+
const subagentSessionIDs = new Set<string>();
|
|
586
|
+
|
|
535
587
|
return {
|
|
588
|
+
// -----------------------------------------------------------------------
|
|
589
|
+
// Track subagent sessions via session lifecycle events
|
|
590
|
+
// -----------------------------------------------------------------------
|
|
591
|
+
event: async (input: { event: any }) => {
|
|
592
|
+
const evt = input.event;
|
|
593
|
+
if (evt.type === "session.created" && evt.properties?.info?.parentID) {
|
|
594
|
+
subagentSessionIDs.add(evt.properties.info.id);
|
|
595
|
+
}
|
|
596
|
+
if (evt.type === "session.deleted") {
|
|
597
|
+
subagentSessionIDs.delete(evt.properties?.info?.id);
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
|
|
536
601
|
// -----------------------------------------------------------------------
|
|
537
602
|
// Register tier agents + commands at load time
|
|
538
603
|
// -----------------------------------------------------------------------
|
|
@@ -544,7 +609,7 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
544
609
|
model: tier.model,
|
|
545
610
|
mode: "subagent",
|
|
546
611
|
description: tier.description,
|
|
547
|
-
|
|
612
|
+
maxSteps: tier.steps,
|
|
548
613
|
prompt: tier.prompt,
|
|
549
614
|
color: tier.color,
|
|
550
615
|
};
|
|
@@ -575,7 +640,8 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
575
640
|
};
|
|
576
641
|
opencodeConfig.command["budget"] = {
|
|
577
642
|
template: "$ARGUMENTS",
|
|
578
|
-
description:
|
|
643
|
+
description:
|
|
644
|
+
"Show or switch routing mode (e.g., /budget, /budget budget, /budget quality)",
|
|
579
645
|
};
|
|
580
646
|
opencodeConfig.command["annotate-plan"] = {
|
|
581
647
|
template: [
|
|
@@ -602,12 +668,16 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
602
668
|
"## Output",
|
|
603
669
|
"Rewrite the entire plan in the file with the tags. Do not change the substance — only add tags and break mixed steps.",
|
|
604
670
|
].join("\n"),
|
|
605
|
-
description:
|
|
671
|
+
description:
|
|
672
|
+
"Annotate a plan with [tier:fast/medium/heavy] delegation tags",
|
|
606
673
|
};
|
|
607
674
|
},
|
|
608
675
|
|
|
609
676
|
// -----------------------------------------------------------------------
|
|
610
677
|
// Inject delegation protocol — uses cached config (invalidated on /preset or /budget)
|
|
678
|
+
// Only inject for the primary orchestrator, NOT for subagent calls.
|
|
679
|
+
// Subagents get confused by delegation instructions when they should
|
|
680
|
+
// just execute a task (especially smaller models like Haiku).
|
|
611
681
|
// -----------------------------------------------------------------------
|
|
612
682
|
"experimental.chat.system.transform": async (_input: any, output: any) => {
|
|
613
683
|
try {
|
|
@@ -615,6 +685,12 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
615
685
|
} catch {
|
|
616
686
|
// Use last known config if file read fails
|
|
617
687
|
}
|
|
688
|
+
|
|
689
|
+
// Skip injection for child (subagent) sessions.
|
|
690
|
+
// Child sessions are detected via session.created events with a parentID.
|
|
691
|
+
const sessionID = _input?.sessionID;
|
|
692
|
+
if (sessionID && subagentSessionIDs.has(sessionID)) return;
|
|
693
|
+
|
|
618
694
|
output.system.push(buildDelegationProtocol(cfg));
|
|
619
695
|
},
|
|
620
696
|
|
|
@@ -626,7 +702,10 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
626
702
|
try {
|
|
627
703
|
cfg = loadConfig();
|
|
628
704
|
} catch {}
|
|
629
|
-
output.parts.push({
|
|
705
|
+
output.parts.push({
|
|
706
|
+
type: "text" as const,
|
|
707
|
+
text: buildTiersOutput(cfg),
|
|
708
|
+
});
|
|
630
709
|
}
|
|
631
710
|
|
|
632
711
|
if (input.command === "preset") {
|