opencode-model-router 1.0.7 → 1.1.0

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 (3) hide show
  1. package/package.json +5 -1
  2. package/src/index.ts +193 -9
  3. package/tiers.json +80 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-model-router",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
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",
@@ -35,5 +35,9 @@
35
35
  ],
36
36
  "peerDependencies": {
37
37
  "@opencode-ai/plugin": ">=1.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^25.2.3",
41
+ "typescript": "^5.9.3"
38
42
  }
39
43
  }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ interface TierConfig {
22
22
  variant?: string;
23
23
  thinking?: ThinkingConfig;
24
24
  reasoning?: ReasoningConfig;
25
+ costRatio?: number;
25
26
  color?: string;
26
27
  description: string;
27
28
  steps?: number;
@@ -36,16 +37,26 @@ interface FallbackConfig {
36
37
  presets?: Record<string, Record<string, string[]>>;
37
38
  }
38
39
 
40
+ interface ModeConfig {
41
+ defaultTier: string;
42
+ description: string;
43
+ overrideRules?: string[];
44
+ }
45
+
39
46
  interface RouterConfig {
40
47
  activePreset: string;
48
+ activeMode?: string;
41
49
  presets: Record<string, Preset>;
42
50
  rules: string[];
43
51
  defaultTier: string;
44
52
  fallback?: FallbackConfig;
53
+ taskPatterns?: Record<string, string[]>;
54
+ modes?: Record<string, ModeConfig>;
45
55
  }
46
56
 
47
57
  interface RouterState {
48
58
  activePreset?: string;
59
+ activeMode?: string;
49
60
  }
50
61
 
51
62
  // ---------------------------------------------------------------------------
@@ -130,6 +141,39 @@ function validateConfig(raw: unknown): RouterConfig {
130
141
  throw new Error("tiers.json: 'defaultTier' must be a string");
131
142
  }
132
143
 
144
+ // Validate modes if present
145
+ if (obj.modes !== undefined) {
146
+ if (typeof obj.modes !== "object" || obj.modes === null || Array.isArray(obj.modes)) {
147
+ throw new Error("tiers.json: 'modes' must be an object");
148
+ }
149
+ const modes = obj.modes as Record<string, unknown>;
150
+ for (const [modeName, mode] of Object.entries(modes)) {
151
+ if (typeof mode !== "object" || mode === null) {
152
+ throw new Error(`tiers.json: mode '${modeName}' must be an object`);
153
+ }
154
+ const m = mode as Record<string, unknown>;
155
+ if (typeof m.defaultTier !== "string") {
156
+ throw new Error(`tiers.json: mode '${modeName}.defaultTier' must be a string`);
157
+ }
158
+ if (typeof m.description !== "string") {
159
+ throw new Error(`tiers.json: mode '${modeName}.description' must be a string`);
160
+ }
161
+ }
162
+ }
163
+
164
+ // Validate taskPatterns if present
165
+ if (obj.taskPatterns !== undefined) {
166
+ if (typeof obj.taskPatterns !== "object" || obj.taskPatterns === null || Array.isArray(obj.taskPatterns)) {
167
+ throw new Error("tiers.json: 'taskPatterns' must be an object");
168
+ }
169
+ const tp = obj.taskPatterns as Record<string, unknown>;
170
+ for (const [tierName, patterns] of Object.entries(tp)) {
171
+ if (!Array.isArray(patterns)) {
172
+ throw new Error(`tiers.json: taskPatterns.'${tierName}' must be an array of strings`);
173
+ }
174
+ }
175
+ }
176
+
133
177
  return raw as RouterConfig;
134
178
  }
135
179
 
@@ -150,9 +194,12 @@ function loadConfig(): RouterConfig {
150
194
  cfg.activePreset = resolved;
151
195
  }
152
196
  }
197
+ if (state.activeMode && cfg.modes?.[state.activeMode]) {
198
+ cfg.activeMode = state.activeMode;
199
+ }
153
200
  }
154
201
  } catch {
155
- // Ignore state read errors and keep tiers.json active preset
202
+ // Ignore state read errors and keep tiers.json defaults
156
203
  }
157
204
 
158
205
  _cachedConfig = cfg;
@@ -160,6 +207,30 @@ function loadConfig(): RouterConfig {
160
207
  return cfg;
161
208
  }
162
209
 
210
+ // ---------------------------------------------------------------------------
211
+ // State persistence helpers
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /** Read current persisted state (or empty object on failure). */
215
+ function readState(): RouterState {
216
+ try {
217
+ if (existsSync(statePath())) {
218
+ return JSON.parse(readFileSync(statePath(), "utf-8")) as RouterState;
219
+ }
220
+ } catch {
221
+ // ignore
222
+ }
223
+ return {};
224
+ }
225
+
226
+ /** Write state to disk (merges with existing keys). */
227
+ function writeState(patch: Partial<RouterState>): void {
228
+ const state = { ...readState(), ...patch };
229
+ const p = statePath();
230
+ mkdirSync(dirname(p), { recursive: true });
231
+ writeFileSync(p, JSON.stringify(state, null, 2) + "\n", "utf-8");
232
+ }
233
+
163
234
  function saveActivePreset(presetName: string): void {
164
235
  const cfg = loadConfig();
165
236
  const resolved = resolvePresetName(cfg, presetName);
@@ -170,15 +241,23 @@ function saveActivePreset(presetName: string): void {
170
241
  cfg.activePreset = resolved;
171
242
 
172
243
  // Persist user-selected preset to state file only — never mutate tiers.json
173
- const presetState: RouterState = { activePreset: resolved };
174
- const p = statePath();
175
- mkdirSync(dirname(p), { recursive: true });
176
- writeFileSync(p, JSON.stringify(presetState, null, 2) + "\n", "utf-8");
244
+ writeState({ activePreset: resolved });
177
245
 
178
246
  // Invalidate cache so next read picks up the new active preset
179
247
  invalidateConfigCache();
180
248
  }
181
249
 
250
+ function saveActiveMode(modeName: string): void {
251
+ const cfg = loadConfig();
252
+ if (!cfg.modes?.[modeName]) {
253
+ return;
254
+ }
255
+
256
+ cfg.activeMode = modeName;
257
+ writeState({ activeMode: modeName });
258
+ invalidateConfigCache();
259
+ }
260
+
182
261
  function getActiveTiers(cfg: RouterConfig): Preset {
183
262
  return cfg.presets[cfg.activePreset] ?? Object.values(cfg.presets)[0]!;
184
263
  }
@@ -210,6 +289,15 @@ function buildAgentOptions(tier: TierConfig): Record<string, unknown> {
210
289
  return Object.keys(opts).length > 0 ? opts : {};
211
290
  }
212
291
 
292
+ // ---------------------------------------------------------------------------
293
+ // Mode helpers
294
+ // ---------------------------------------------------------------------------
295
+
296
+ function getActiveMode(cfg: RouterConfig): ModeConfig | undefined {
297
+ if (!cfg.modes || !cfg.activeMode) return undefined;
298
+ return cfg.modes[cfg.activeMode];
299
+ }
300
+
213
301
  // ---------------------------------------------------------------------------
214
302
  // Fallback instructions builder
215
303
  // ---------------------------------------------------------------------------
@@ -241,6 +329,33 @@ function buildFallbackInstructions(cfg: RouterConfig): string {
241
329
  ].join("\n");
242
330
  }
243
331
 
332
+ // ---------------------------------------------------------------------------
333
+ // Cost & taxonomy builders
334
+ // ---------------------------------------------------------------------------
335
+
336
+ function buildTaskTaxonomy(cfg: RouterConfig): string {
337
+ if (!cfg.taskPatterns || Object.keys(cfg.taskPatterns).length === 0) return "";
338
+
339
+ const lines = ["Coding task routing guide:"];
340
+ for (const [tier, patterns] of Object.entries(cfg.taskPatterns)) {
341
+ if (Array.isArray(patterns) && patterns.length > 0) {
342
+ lines.push(`- @${tier}: ${patterns.join(", ")}`);
343
+ }
344
+ }
345
+ return lines.join("\n");
346
+ }
347
+
348
+ function buildCostAwareness(cfg: RouterConfig): string {
349
+ const tiers = getActiveTiers(cfg);
350
+ const costs = Object.entries(tiers)
351
+ .filter(([_, t]) => t.costRatio != null)
352
+ .map(([name, t]) => `@${name}=${t.costRatio}x`)
353
+ .join(", ");
354
+
355
+ if (!costs) return "";
356
+ return `Cost ratios: ${costs}. Always use the cheapest tier that can reliably handle the task.`;
357
+ }
358
+
244
359
  // ---------------------------------------------------------------------------
245
360
  // System prompt builder
246
361
  // ---------------------------------------------------------------------------
@@ -264,8 +379,16 @@ function buildDelegationProtocol(cfg: RouterConfig): string {
264
379
  })
265
380
  .join("\n");
266
381
 
267
- // Use configurable rules from tiers.json instead of hardcoded ones
268
- const numberedRules = cfg.rules
382
+ // Task taxonomy from config
383
+ const taxonomy = buildTaskTaxonomy(cfg);
384
+
385
+ // Cost awareness
386
+ const costLine = buildCostAwareness(cfg);
387
+
388
+ // Mode-aware rules: if active mode has overrideRules, use those; otherwise use global rules
389
+ const mode = getActiveMode(cfg);
390
+ const effectiveRules = mode?.overrideRules?.length ? mode.overrideRules : cfg.rules;
391
+ const numberedRules = effectiveRules
269
392
  .map((rule, i) => `${i + 1}. ${rule}`)
270
393
  .join("\n");
271
394
 
@@ -277,6 +400,9 @@ function buildDelegationProtocol(cfg: RouterConfig): string {
277
400
  "",
278
401
  "Tier capabilities:",
279
402
  tierDescriptions,
403
+ ...(taxonomy ? ["", taxonomy] : []),
404
+ ...(costLine ? ["", costLine] : []),
405
+ ...(mode ? [`\nActive mode: ${cfg.activeMode} (${mode.description})`] : []),
280
406
  "",
281
407
  "Apply to every user message (plan and ad-hoc):",
282
408
  numberedRules,
@@ -320,6 +446,50 @@ function buildTiersOutput(cfg: RouterConfig): string {
320
446
  return lines.join("\n");
321
447
  }
322
448
 
449
+ // ---------------------------------------------------------------------------
450
+ // /budget command output
451
+ // ---------------------------------------------------------------------------
452
+
453
+ function buildBudgetOutput(cfg: RouterConfig, args: string): string {
454
+ const modes = cfg.modes;
455
+ if (!modes || Object.keys(modes).length === 0) {
456
+ return 'No modes configured in tiers.json. Add a "modes" section to enable budget mode.';
457
+ }
458
+
459
+ const requested = args.trim().toLowerCase();
460
+ const currentMode = cfg.activeMode || "normal";
461
+
462
+ // No args: show current mode and available modes
463
+ if (!requested) {
464
+ const lines = ["# Routing Modes\n"];
465
+ for (const [name, mode] of Object.entries(modes)) {
466
+ const active = name === currentMode ? " <- active" : "";
467
+ lines.push(`- **${name}**${active}: ${mode.description} (default tier: @${mode.defaultTier})`);
468
+ }
469
+ lines.push(`\nSwitch with: \`/budget <mode>\``);
470
+ return lines.join("\n");
471
+ }
472
+
473
+ // Switch mode
474
+ if (modes[requested]) {
475
+ saveActiveMode(requested);
476
+ const mode = modes[requested];
477
+ return [
478
+ `Routing mode switched to **${requested}**.`,
479
+ "",
480
+ mode.description,
481
+ `Default tier: @${mode.defaultTier}`,
482
+ ...(mode.overrideRules?.length
483
+ ? ["", "Active rules:", ...mode.overrideRules.map((r) => `- ${r}`)]
484
+ : []),
485
+ "",
486
+ "Mode change takes effect immediately on the next message.",
487
+ ].join("\n");
488
+ }
489
+
490
+ return `Unknown mode: "${requested}". Available: ${Object.keys(modes).join(", ")}`;
491
+ }
492
+
323
493
  // ---------------------------------------------------------------------------
324
494
  // /preset command output
325
495
  // ---------------------------------------------------------------------------
@@ -413,6 +583,10 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
413
583
  template: "$ARGUMENTS",
414
584
  description: "Show or switch model presets (e.g., /preset openai)",
415
585
  };
586
+ opencodeConfig.command["budget"] = {
587
+ template: "$ARGUMENTS",
588
+ description: "Show or switch routing mode (e.g., /budget, /budget budget, /budget quality)",
589
+ };
416
590
  opencodeConfig.command["annotate-plan"] = {
417
591
  template: [
418
592
  "Annotate the plan with tier directives for model delegation.",
@@ -443,7 +617,7 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
443
617
  },
444
618
 
445
619
  // -----------------------------------------------------------------------
446
- // Inject delegation protocol — uses cached config (invalidated on /preset)
620
+ // Inject delegation protocol — uses cached config (invalidated on /preset or /budget)
447
621
  // -----------------------------------------------------------------------
448
622
  "experimental.chat.system.transform": async (_input: any, output: any) => {
449
623
  try {
@@ -455,7 +629,7 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
455
629
  },
456
630
 
457
631
  // -----------------------------------------------------------------------
458
- // Handle /tiers and /preset commands
632
+ // Handle /tiers, /preset, and /budget commands
459
633
  // -----------------------------------------------------------------------
460
634
  "command.execute.before": async (input: any, output: any) => {
461
635
  if (input.command === "tiers") {
@@ -474,6 +648,16 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
474
648
  text: buildPresetOutput(cfg, input.arguments ?? ""),
475
649
  });
476
650
  }
651
+
652
+ if (input.command === "budget") {
653
+ try {
654
+ cfg = loadConfig();
655
+ } catch {}
656
+ output.parts.push({
657
+ type: "text" as const,
658
+ text: buildBudgetOutput(cfg, input.arguments ?? ""),
659
+ });
660
+ }
477
661
  },
478
662
  };
479
663
  };
package/tiers.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "activePreset": "anthropic",
3
+ "activeMode": "normal",
3
4
  "presets": {
4
5
  "anthropic": {
5
6
  "fast": {
6
7
  "model": "anthropic/claude-haiku-4-5",
8
+ "costRatio": 1,
7
9
  "description": "Haiku 4.5 for exploration, search, and simple reads",
8
10
  "steps": 30,
9
11
  "prompt": "You are a fast exploration agent. Focus on speed and efficiency. Read files, search code, and return findings concisely. Do NOT make edits unless explicitly asked.",
@@ -17,6 +19,7 @@
17
19
  "medium": {
18
20
  "model": "anthropic/claude-sonnet-4-5",
19
21
  "variant": "max",
22
+ "costRatio": 5,
20
23
  "description": "Sonnet 4.5 max for implementation, refactoring, and tests",
21
24
  "steps": 50,
22
25
  "prompt": "You are an implementation agent. Write clean, production-quality code matching existing project patterns. Run linters/tests after changes when possible.",
@@ -31,6 +34,7 @@
31
34
  "heavy": {
32
35
  "model": "anthropic/claude-opus-4-6",
33
36
  "variant": "max",
37
+ "costRatio": 20,
34
38
  "description": "Opus 4.6 max for architecture, complex debugging, and security",
35
39
  "steps": 30,
36
40
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
@@ -45,6 +49,7 @@
45
49
  "openai": {
46
50
  "fast": {
47
51
  "model": "openai/gpt-5.3-codex-spark",
52
+ "costRatio": 1,
48
53
  "description": "GPT-5.3 Codex Spark for fast exploration and simple tasks",
49
54
  "steps": 30,
50
55
  "prompt": "You are a fast exploration agent. Focus on speed and efficiency. Read files, search code, and return findings concisely. Do NOT make edits unless explicitly asked.",
@@ -56,6 +61,7 @@
56
61
  },
57
62
  "medium": {
58
63
  "model": "openai/gpt-5.3-codex",
64
+ "costRatio": 5,
59
65
  "description": "GPT-5.3 Codex default settings for implementation and standard coding",
60
66
  "steps": 50,
61
67
  "prompt": "You are an implementation agent. Write clean, production-quality code matching existing project patterns. Run linters/tests after changes when possible.",
@@ -69,6 +75,7 @@
69
75
  "heavy": {
70
76
  "model": "openai/gpt-5.3-codex",
71
77
  "variant": "xhigh",
78
+ "costRatio": 20,
72
79
  "description": "GPT-5.3 Codex xhigh for architecture and complex tasks",
73
80
  "steps": 30,
74
81
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning.",
@@ -83,6 +90,7 @@
83
90
  "github-copilot": {
84
91
  "fast": {
85
92
  "model": "github-copilot/claude-haiku-4-5",
93
+ "costRatio": 1,
86
94
  "description": "Claude Haiku 4.5 via GitHub Copilot for fast exploration and simple tasks",
87
95
  "steps": 30,
88
96
  "prompt": "You are a fast exploration agent. Focus on speed and efficiency. Read files, search code, and return findings concisely. Do NOT make edits unless explicitly asked.",
@@ -95,6 +103,7 @@
95
103
  },
96
104
  "medium": {
97
105
  "model": "github-copilot/claude-sonnet-4-5",
106
+ "costRatio": 5,
98
107
  "description": "Claude Sonnet 4.5 via GitHub Copilot for implementation, refactoring, and tests",
99
108
  "steps": 50,
100
109
  "prompt": "You are an implementation agent. Write clean, production-quality code matching existing project patterns. Run linters/tests after changes when possible.",
@@ -109,6 +118,7 @@
109
118
  "heavy": {
110
119
  "model": "github-copilot/claude-opus-4-6",
111
120
  "variant": "thinking",
121
+ "costRatio": 20,
112
122
  "description": "Claude Opus 4.6 via GitHub Copilot for architecture, complex debugging, and security",
113
123
  "steps": 30,
114
124
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
@@ -123,6 +133,7 @@
123
133
  "google": {
124
134
  "fast": {
125
135
  "model": "google/gemini-2.5-flash",
136
+ "costRatio": 1,
126
137
  "description": "Gemini 2.5 Flash for fast exploration and simple tasks",
127
138
  "steps": 30,
128
139
  "prompt": "You are a fast exploration agent. Focus on speed and efficiency. Read files, search code, and return findings concisely. Do NOT make edits unless explicitly asked.",
@@ -135,6 +146,7 @@
135
146
  },
136
147
  "medium": {
137
148
  "model": "google/gemini-2.5-pro",
149
+ "costRatio": 5,
138
150
  "description": "Gemini 2.5 Pro for implementation, refactoring, and tests",
139
151
  "steps": 50,
140
152
  "prompt": "You are an implementation agent. Write clean, production-quality code matching existing project patterns. Run linters/tests after changes when possible.",
@@ -148,6 +160,7 @@
148
160
  },
149
161
  "heavy": {
150
162
  "model": "google/gemini-3-pro-preview",
163
+ "costRatio": 20,
151
164
  "description": "Gemini 3 Pro Preview for architecture, complex debugging, and security",
152
165
  "steps": 30,
153
166
  "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
@@ -160,6 +173,69 @@
160
173
  }
161
174
  }
162
175
  },
176
+ "taskPatterns": {
177
+ "fast": [
178
+ "Find, search, locate, or grep files and code patterns",
179
+ "List or show directory structure and file contents",
180
+ "Read or display specific files or sections",
181
+ "Check git status, log, diff, or blame",
182
+ "Lookup documentation, API signatures, or type definitions",
183
+ "Count occurrences, lines, or matches",
184
+ "Check if a file, function, or class exists",
185
+ "Simple rename or string replacement across files"
186
+ ],
187
+ "medium": [
188
+ "Implement a new feature, function, or component",
189
+ "Refactor or restructure existing code",
190
+ "Write or update tests",
191
+ "Fix a bug (first or second attempt)",
192
+ "Modify or update existing code logic",
193
+ "Code review with suggested changes",
194
+ "Run build/lint/test and fix resulting errors",
195
+ "Create a new file from a template or pattern",
196
+ "Database migration or schema changes",
197
+ "API endpoint implementation",
198
+ "Configuration or dependency updates"
199
+ ],
200
+ "heavy": [
201
+ "Design system or module architecture from scratch",
202
+ "Debug a problem after 2+ failed attempts",
203
+ "Security audit or vulnerability review",
204
+ "Performance profiling and optimization",
205
+ "Migration strategy (framework, language, infrastructure)",
206
+ "Complex multi-system integration design",
207
+ "Evaluate tradeoffs between competing approaches",
208
+ "Root cause analysis of complex or elusive failures"
209
+ ]
210
+ },
211
+ "modes": {
212
+ "normal": {
213
+ "defaultTier": "medium",
214
+ "description": "Balanced quality and cost — delegates based on task complexity"
215
+ },
216
+ "budget": {
217
+ "defaultTier": "fast",
218
+ "description": "Aggressive cost savings — defaults to cheapest tier, escalates only when needed",
219
+ "overrideRules": [
220
+ "Default ALL tasks to @fast unless they clearly require code edits or complex reasoning",
221
+ "Use @medium ONLY for: multi-file edits, complex refactors, test suites, or build-fix cycles",
222
+ "Use @heavy ONLY when explicitly requested by user or after 2+ failed @medium attempts",
223
+ "Prefer executing simple tasks directly (grep, read, glob) over delegating — zero delegation overhead",
224
+ "Batch multiple related searches into a single @fast delegation instead of multiple calls",
225
+ "When uncertain between @fast and @medium, choose @fast — escalate only on failure"
226
+ ]
227
+ },
228
+ "quality": {
229
+ "defaultTier": "medium",
230
+ "description": "Quality-first — uses stronger models more liberally for better results",
231
+ "overrideRules": [
232
+ "Default to @medium for all tasks including exploration when deep context understanding matters",
233
+ "Use @heavy for any task involving architecture, debugging, security, or multi-file coordination",
234
+ "Use @fast only for trivial single-tool operations (one grep, one file read)",
235
+ "Prefer thoroughness over speed — better to over-qualify a task than under-qualify it"
236
+ ]
237
+ }
238
+ },
163
239
  "fallback": {
164
240
  "global": {
165
241
  "anthropic": ["openai", "google", "github-copilot"],
@@ -177,7 +253,10 @@
177
253
  "Use @fast for any read-only exploration or research task",
178
254
  "Keep orchestration (planning, decisions, verification) for yourself - delegate execution",
179
255
  "For trivial tasks (single grep, single file read), execute directly without delegation",
180
- "Never delegate to @heavy if you are already running on an opus-class model - do it yourself"
256
+ "Never delegate to @heavy if you are already running on an opus-class model - do it yourself",
257
+ "If a task takes 1-2 tool calls, execute directly — delegation overhead is not worth the cost",
258
+ "Consult the task routing guide below to match task type to the correct tier",
259
+ "Consider cost ratios when choosing tiers — always use the cheapest tier that can reliably handle the task"
181
260
  ],
182
261
  "defaultTier": "medium"
183
262
  }