opencode-model-router 1.1.5 → 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/README.md CHANGED
@@ -23,8 +23,8 @@ A keyword routing guide (`@fast→search/grep/read`, `@medium→impl/refactor/te
23
23
  **Skip delegation overhead for trivial work.**
24
24
  Single grep? One file read? The orchestrator executes directly — zero delegation cost, zero latency.
25
25
 
26
- **Three routing modes for different budgets.**
27
- `/budget normal` (balanced), `/budget budget` (aggressive savings, defaults everything to @fast), `/budget quality` (liberal use of stronger models). Mode persists across restarts.
26
+ **Four routing modes for different budgets.**
27
+ `/budget normal` (balanced), `/budget budget` (aggressive savings, defaults everything to @fast), `/budget quality` (liberal use of stronger models), `/budget deep` (heavy-first for long architecture/debug runs). Mode persists across restarts.
28
28
 
29
29
  **Cost ratios in the prompt.**
30
30
  Every tier carries its `costRatio` (fast=1x, medium=5x, heavy=20x) injected into the system prompt. The orchestrator sees the price before deciding. It picks the cheapest tier that can reliably handle the task.
@@ -278,6 +278,7 @@ Switch with `/budget <mode>`. Mode is persisted across restarts.
278
278
  | `normal` | @medium | Balanced — routes by task complexity |
279
279
  | `budget` | @fast | Aggressive savings — defaults cheap, escalates only when necessary |
280
280
  | `quality` | @medium | Quality-first — liberal use of @medium/@heavy |
281
+ | `deep` | @heavy | Deep-analysis mode — heavy-first for architecture/debug/security with longer heavy runs |
281
282
 
282
283
  ```json
283
284
  {
@@ -286,15 +287,28 @@ Switch with `/budget <mode>`. Mode is persisted across restarts.
286
287
  "defaultTier": "fast",
287
288
  "description": "Aggressive cost savings",
288
289
  "overrideRules": [
289
- "Default ALL tasks to @fast unless they clearly require code edits",
290
- "Use @medium ONLY for: multi-file edits, complex refactors, test suites",
291
- "Use @heavy ONLY when explicitly requested or after 2+ failed @medium attempts"
290
+ "default→@fast unless edits/complex-reasoning needed",
291
+ "@medium ONLY: multi-file-edit/refactor/test-suite/build-fix",
292
+ "@heavy ONLY: user-requested OR 2 @medium failures"
293
+ ]
294
+ },
295
+ "deep": {
296
+ "defaultTier": "heavy",
297
+ "description": "Deep analysis mode — prioritizes thorough architecture/debug work with long heavy runs",
298
+ "overrideRules": [
299
+ "default→@medium for implementation and multi-file changes",
300
+ "@heavy for architecture/debug/security/tradeoff-analysis by default",
301
+ "allow long heavy runs before fallback; avoid premature downshift",
302
+ "trivial(grep/read/glob)→direct,no-delegate",
303
+ "if task is composite: explore@fast then execute@heavy"
292
304
  ]
293
305
  }
294
306
  }
295
307
  }
296
308
  ```
297
309
 
310
+ **Heavy tool-call budget:** `@heavy.steps=120` by default across presets (raised from 60) to reduce premature cutoffs on long architecture/debug tasks.
311
+
298
312
  ### Task taxonomy (`taskPatterns`)
299
313
 
300
314
  Keyword routing guide injected into the system prompt. Customize to match your workflow:
@@ -381,7 +395,7 @@ Defines provider fallback order when a delegated task fails:
381
395
  | `/preset` | List available presets |
382
396
  | `/preset <name>` | Switch preset (e.g., `/preset openai`) |
383
397
  | `/budget` | Show available modes and which is active |
384
- | `/budget <mode>` | Switch routing mode (`normal`, `budget`, `quality`) |
398
+ | `/budget <mode>` | Switch routing mode (`normal`, `budget`, `quality`, `deep`) |
385
399
  | `/annotate-plan [path]` | Annotate a plan file with `[tier:X]` tags for each step |
386
400
 
387
401
  ## Plan annotation
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "opencode-model-router",
3
- "version": "1.1.5",
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") {
package/tiers.json CHANGED
@@ -36,7 +36,7 @@
36
36
  "variant": "max",
37
37
  "costRatio": 20,
38
38
  "description": "Opus 4.6 max for architecture, complex debugging, and security",
39
- "steps": 30,
39
+ "steps": 120,
40
40
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
41
41
  "whenToUse": [
42
42
  "Architecture decisions",
@@ -77,7 +77,7 @@
77
77
  "variant": "xhigh",
78
78
  "costRatio": 20,
79
79
  "description": "GPT-5.3 Codex xhigh for architecture and complex tasks",
80
- "steps": 30,
80
+ "steps": 120,
81
81
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning.",
82
82
  "whenToUse": [
83
83
  "Architecture decisions",
@@ -120,7 +120,7 @@
120
120
  "variant": "thinking",
121
121
  "costRatio": 20,
122
122
  "description": "Claude Opus 4.6 via GitHub Copilot for architecture, complex debugging, and security",
123
- "steps": 30,
123
+ "steps": 120,
124
124
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
125
125
  "whenToUse": [
126
126
  "Architecture decisions",
@@ -162,7 +162,7 @@
162
162
  "model": "google/gemini-3-pro-preview",
163
163
  "costRatio": 20,
164
164
  "description": "Gemini 3 Pro Preview for architecture, complex debugging, and security",
165
- "steps": 30,
165
+ "steps": 120,
166
166
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
167
167
  "whenToUse": [
168
168
  "Architecture decisions",
@@ -206,7 +206,7 @@
206
206
  "variant": "max",
207
207
  "costRatio": 20,
208
208
  "description": "Claude Opus 4.6 max for architecture, complex debugging, and security",
209
- "steps": 30,
209
+ "steps": 120,
210
210
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
211
211
  "whenToUse": [
212
212
  "Architecture decisions",
@@ -279,6 +279,17 @@
279
279
  "@fast ONLY: trivial single-tool ops (1 grep/1 read)",
280
280
  "prefer thoroughness over speed"
281
281
  ]
282
+ },
283
+ "deep": {
284
+ "defaultTier": "heavy",
285
+ "description": "Deep analysis mode — prioritizes thorough architecture/debug work with long heavy runs",
286
+ "overrideRules": [
287
+ "default→@medium for implementation and multi-file changes",
288
+ "@heavy for architecture/debug/security/tradeoff-analysis by default",
289
+ "allow long heavy runs before fallback; avoid premature downshift",
290
+ "trivial(grep/read/glob)→direct,no-delegate",
291
+ "if task is composite: explore@fast then execute@heavy"
292
+ ]
282
293
  }
283
294
  },
284
295
  "fallback": {