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.
Files changed (2) hide show
  1. package/package.json +2 -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.6",
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(homedir(), ".config", "opencode", "opencode-model-router.state.json");
84
+ return join(
85
+ homedir(),
86
+ ".config",
87
+ "opencode",
88
+ "opencode-model-router.state.json",
89
+ );
85
90
  }
86
91
 
87
- function resolvePresetName(cfg: RouterConfig, requestedPreset: string): string | undefined {
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((name) => name.toLowerCase() === normalized);
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 (typeof obj.presets !== "object" || obj.presets === null || Array.isArray(obj.presets)) {
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 (typeof preset !== "object" || preset === null || Array.isArray(preset)) {
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(`tiers.json: tier '${presetName}.${tierName}' must be an object`);
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(`tiers.json: '${presetName}.${tierName}.model' must be a non-empty string`);
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(`tiers.json: '${presetName}.${tierName}.description' must be a string`);
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(`tiers.json: '${presetName}.${tierName}.whenToUse' must be an array`);
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 (typeof obj.modes !== "object" || obj.modes === null || Array.isArray(obj.modes)) {
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(`tiers.json: mode '${modeName}.defaultTier' must be a string`);
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(`tiers.json: mode '${modeName}.description' must be a string`);
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 (typeof obj.taskPatterns !== "object" || obj.taskPatterns === null || Array.isArray(obj.taskPatterns)) {
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(`tiers.json: taskPatterns.'${tierName}' must be an array of strings`);
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(readFileSync(statePath(), "utf-8")) as RouterState;
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 = presetMap && Object.keys(presetMap).length > 0 ? presetMap : fb.global;
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) return "";
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 ? mode.overrideRules : cfg.rules;
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(`- **${name}**${active}: ${mode.description} (default tier: @${mode.defaultTier})`);
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
- steps: tier.steps,
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: "Show or switch routing mode (e.g., /budget, /budget budget, /budget quality)",
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: "Annotate a plan with [tier:fast/medium/heavy] delegation tags",
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({ type: "text" as const, text: buildTiersOutput(cfg) });
698
+ output.parts.push({
699
+ type: "text" as const,
700
+ text: buildTiersOutput(cfg),
701
+ });
630
702
  }
631
703
 
632
704
  if (input.command === "preset") {