opencode-model-router 1.1.6 → 1.1.7
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 +96 -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.7",
|
|
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");
|
|
@@ -544,7 +592,7 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
544
592
|
model: tier.model,
|
|
545
593
|
mode: "subagent",
|
|
546
594
|
description: tier.description,
|
|
547
|
-
|
|
595
|
+
maxSteps: tier.steps,
|
|
548
596
|
prompt: tier.prompt,
|
|
549
597
|
color: tier.color,
|
|
550
598
|
};
|
|
@@ -575,7 +623,8 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
575
623
|
};
|
|
576
624
|
opencodeConfig.command["budget"] = {
|
|
577
625
|
template: "$ARGUMENTS",
|
|
578
|
-
description:
|
|
626
|
+
description:
|
|
627
|
+
"Show or switch routing mode (e.g., /budget, /budget budget, /budget quality)",
|
|
579
628
|
};
|
|
580
629
|
opencodeConfig.command["annotate-plan"] = {
|
|
581
630
|
template: [
|
|
@@ -602,12 +651,16 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
602
651
|
"## Output",
|
|
603
652
|
"Rewrite the entire plan in the file with the tags. Do not change the substance — only add tags and break mixed steps.",
|
|
604
653
|
].join("\n"),
|
|
605
|
-
description:
|
|
654
|
+
description:
|
|
655
|
+
"Annotate a plan with [tier:fast/medium/heavy] delegation tags",
|
|
606
656
|
};
|
|
607
657
|
},
|
|
608
658
|
|
|
609
659
|
// -----------------------------------------------------------------------
|
|
610
660
|
// Inject delegation protocol — uses cached config (invalidated on /preset or /budget)
|
|
661
|
+
// Only inject for the primary orchestrator, NOT for subagent calls.
|
|
662
|
+
// Smaller models (e.g. Haiku) get confused by delegation instructions
|
|
663
|
+
// when they're supposed to just execute a task.
|
|
611
664
|
// -----------------------------------------------------------------------
|
|
612
665
|
"experimental.chat.system.transform": async (_input: any, output: any) => {
|
|
613
666
|
try {
|
|
@@ -615,6 +668,22 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
615
668
|
} catch {
|
|
616
669
|
// Use last known config if file read fails
|
|
617
670
|
}
|
|
671
|
+
|
|
672
|
+
// Skip injection when the model matches a registered subagent tier.
|
|
673
|
+
// This prevents subagents from seeing delegation instructions that
|
|
674
|
+
// conflict with their task-executor role.
|
|
675
|
+
const model = _input?.model;
|
|
676
|
+
if (model) {
|
|
677
|
+
const tiers = getActiveTiers(cfg);
|
|
678
|
+
const isSubagentModel = Object.values(tiers).some((tier) => {
|
|
679
|
+
const parts = tier.model.split("/");
|
|
680
|
+
const providerID = parts[0];
|
|
681
|
+
const modelID = parts.slice(1).join("/");
|
|
682
|
+
return model.providerID === providerID && model.id === modelID;
|
|
683
|
+
});
|
|
684
|
+
if (isSubagentModel) return;
|
|
685
|
+
}
|
|
686
|
+
|
|
618
687
|
output.system.push(buildDelegationProtocol(cfg));
|
|
619
688
|
},
|
|
620
689
|
|
|
@@ -626,7 +695,10 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
|
|
|
626
695
|
try {
|
|
627
696
|
cfg = loadConfig();
|
|
628
697
|
} catch {}
|
|
629
|
-
output.parts.push({
|
|
698
|
+
output.parts.push({
|
|
699
|
+
type: "text" as const,
|
|
700
|
+
text: buildTiersOutput(cfg),
|
|
701
|
+
});
|
|
630
702
|
}
|
|
631
703
|
|
|
632
704
|
if (input.command === "preset") {
|