opencode-model-router 1.0.5 → 1.0.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
@@ -95,7 +95,7 @@ All configuration lives in `tiers.json` at the plugin root. Edit it to match you
95
95
 
96
96
  ### Presets
97
97
 
98
- The plugin ships with two presets:
98
+ The plugin ships with four presets:
99
99
 
100
100
  **anthropic** (default):
101
101
  | Tier | Model | Notes |
@@ -111,6 +111,20 @@ The plugin ships with two presets:
111
111
  | medium | `openai/gpt-5.3-codex` | Default settings (no variant/reasoning override) |
112
112
  | heavy | `openai/gpt-5.3-codex` | Variant: `xhigh` |
113
113
 
114
+ **github-copilot**:
115
+ | Tier | Model | Notes |
116
+ |------|-------|-------|
117
+ | fast | `github-copilot/claude-haiku-4-5` | Cheapest, fastest |
118
+ | medium | `github-copilot/claude-sonnet-4-5` | Balanced coding model |
119
+ | heavy | `github-copilot/claude-opus-4-6` | Variant: `thinking` |
120
+
121
+ **google**:
122
+ | Tier | Model | Notes |
123
+ |------|-------|-------|
124
+ | fast | `google/gemini-2.5-flash` | Cheapest, fastest |
125
+ | medium | `google/gemini-2.5-pro` | Balanced coding model |
126
+ | heavy | `google/gemini-3-pro-preview` | Strongest reasoning in default set |
127
+
114
128
  Switch presets with the `/preset` command:
115
129
 
116
130
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-model-router",
3
- "version": "1.0.5",
3
+ "version": "1.0.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",
package/src/index.ts CHANGED
@@ -31,11 +31,17 @@ interface TierConfig {
31
31
 
32
32
  type Preset = Record<string, TierConfig>;
33
33
 
34
+ interface FallbackConfig {
35
+ global?: Record<string, string[]>;
36
+ presets?: Record<string, Record<string, string[]>>;
37
+ }
38
+
34
39
  interface RouterConfig {
35
40
  activePreset: string;
36
41
  presets: Record<string, Preset>;
37
42
  rules: string[];
38
43
  defaultTier: string;
44
+ fallback?: FallbackConfig;
39
45
  }
40
46
 
41
47
  interface RouterState {
@@ -43,9 +49,17 @@ interface RouterState {
43
49
  }
44
50
 
45
51
  // ---------------------------------------------------------------------------
46
- // Config loader
52
+ // Config loader with caching
47
53
  // ---------------------------------------------------------------------------
48
54
 
55
+ let _cachedConfig: RouterConfig | null = null;
56
+ let _configDirty = true;
57
+
58
+ /** Mark config cache as stale so it is re-read on next access. */
59
+ function invalidateConfigCache(): void {
60
+ _configDirty = true;
61
+ }
62
+
49
63
  function getPluginRoot(): string {
50
64
  const __dirname = dirname(fileURLToPath(import.meta.url));
51
65
  return join(__dirname, ".."); // src/ -> plugin root
@@ -72,8 +86,60 @@ function resolvePresetName(cfg: RouterConfig, requestedPreset: string): string |
72
86
  return Object.keys(cfg.presets).find((name) => name.toLowerCase() === normalized);
73
87
  }
74
88
 
89
+ function validateConfig(raw: unknown): RouterConfig {
90
+ if (typeof raw !== "object" || raw === null) {
91
+ throw new Error("tiers.json: expected a JSON object at root");
92
+ }
93
+
94
+ const obj = raw as Record<string, unknown>;
95
+
96
+ if (typeof obj.activePreset !== "string" || !obj.activePreset) {
97
+ throw new Error("tiers.json: 'activePreset' must be a non-empty string");
98
+ }
99
+ if (typeof obj.presets !== "object" || obj.presets === null || Array.isArray(obj.presets)) {
100
+ throw new Error("tiers.json: 'presets' must be a non-null object");
101
+ }
102
+
103
+ const presets = obj.presets as Record<string, unknown>;
104
+ for (const [presetName, preset] of Object.entries(presets)) {
105
+ if (typeof preset !== "object" || preset === null || Array.isArray(preset)) {
106
+ throw new Error(`tiers.json: preset '${presetName}' must be an object`);
107
+ }
108
+ const tiers = preset as Record<string, unknown>;
109
+ for (const [tierName, tier] of Object.entries(tiers)) {
110
+ if (typeof tier !== "object" || tier === null) {
111
+ throw new Error(`tiers.json: tier '${presetName}.${tierName}' must be an object`);
112
+ }
113
+ const t = tier as Record<string, unknown>;
114
+ if (typeof t.model !== "string" || !t.model) {
115
+ throw new Error(`tiers.json: '${presetName}.${tierName}.model' must be a non-empty string`);
116
+ }
117
+ if (typeof t.description !== "string") {
118
+ throw new Error(`tiers.json: '${presetName}.${tierName}.description' must be a string`);
119
+ }
120
+ if (!Array.isArray(t.whenToUse)) {
121
+ throw new Error(`tiers.json: '${presetName}.${tierName}.whenToUse' must be an array`);
122
+ }
123
+ }
124
+ }
125
+
126
+ if (!Array.isArray(obj.rules)) {
127
+ throw new Error("tiers.json: 'rules' must be an array of strings");
128
+ }
129
+ if (typeof obj.defaultTier !== "string") {
130
+ throw new Error("tiers.json: 'defaultTier' must be a string");
131
+ }
132
+
133
+ return raw as RouterConfig;
134
+ }
135
+
75
136
  function loadConfig(): RouterConfig {
76
- const cfg = JSON.parse(readFileSync(configPath(), "utf-8")) as RouterConfig;
137
+ if (_cachedConfig && !_configDirty) {
138
+ return _cachedConfig;
139
+ }
140
+
141
+ const raw = JSON.parse(readFileSync(configPath(), "utf-8"));
142
+ const cfg = validateConfig(raw);
77
143
 
78
144
  try {
79
145
  if (existsSync(statePath())) {
@@ -89,6 +155,8 @@ function loadConfig(): RouterConfig {
89
155
  // Ignore state read errors and keep tiers.json active preset
90
156
  }
91
157
 
158
+ _cachedConfig = cfg;
159
+ _configDirty = false;
92
160
  return cfg;
93
161
  }
94
162
 
@@ -101,14 +169,14 @@ function saveActivePreset(presetName: string): void {
101
169
 
102
170
  cfg.activePreset = resolved;
103
171
 
104
- // Persist user-selected preset outside package cache so it survives npm updates
172
+ // Persist user-selected preset to state file only never mutate tiers.json
105
173
  const presetState: RouterState = { activePreset: resolved };
106
174
  const p = statePath();
107
175
  mkdirSync(dirname(p), { recursive: true });
108
176
  writeFileSync(p, JSON.stringify(presetState, null, 2) + "\n", "utf-8");
109
177
 
110
- // Keep local tiers.json in sync as best effort
111
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n", "utf-8");
178
+ // Invalidate cache so next read picks up the new active preset
179
+ invalidateConfigCache();
112
180
  }
113
181
 
114
182
  function getActiveTiers(cfg: RouterConfig): Preset {
@@ -142,6 +210,37 @@ function buildAgentOptions(tier: TierConfig): Record<string, unknown> {
142
210
  return Object.keys(opts).length > 0 ? opts : {};
143
211
  }
144
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Fallback instructions builder
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function buildFallbackInstructions(cfg: RouterConfig): string {
218
+ const fb = cfg.fallback;
219
+ if (!fb) return "";
220
+
221
+ const presetMap = fb.presets?.[cfg.activePreset];
222
+ const map = presetMap && Object.keys(presetMap).length > 0 ? presetMap : fb.global;
223
+ if (!map) return "";
224
+
225
+ const providerLines = Object.entries(map).flatMap(([provider, presetOrder]) => {
226
+ if (!Array.isArray(presetOrder)) return [];
227
+ const validOrder = presetOrder.filter(
228
+ (preset) => preset !== cfg.activePreset && Boolean(cfg.presets[preset]),
229
+ );
230
+ return validOrder.length > 0 ? [`- ${provider}: ${validOrder.join(" -> ")}`] : [];
231
+ });
232
+
233
+ if (providerLines.length === 0) return "";
234
+
235
+ return [
236
+ "Fallback on delegated task errors:",
237
+ "1. If Task(...) returns provider/model/rate-limit/timeout/auth errors, retry once with a different tier suited to the same task.",
238
+ "2. If retry also fails, stop delegating that task and complete it directly in the primary agent.",
239
+ "3. Use the failing model prefix and this preset fallback order for next-run recovery (`/preset <name>` + restart):",
240
+ ...providerLines,
241
+ ].join("\n");
242
+ }
243
+
145
244
  // ---------------------------------------------------------------------------
146
245
  // System prompt builder
147
246
  // ---------------------------------------------------------------------------
@@ -157,21 +256,33 @@ function buildDelegationProtocol(cfg: RouterConfig): string {
157
256
  })
158
257
  .join(" | ");
159
258
 
259
+ // Build per-tier whenToUse descriptions so the agent knows when to pick each tier
260
+ const tierDescriptions = Object.entries(tiers)
261
+ .map(([name, t]) => {
262
+ const uses = t.whenToUse.length > 0 ? t.whenToUse.join(", ") : t.description;
263
+ return `- @${name}: ${uses}`;
264
+ })
265
+ .join("\n");
266
+
267
+ // Use configurable rules from tiers.json instead of hardcoded ones
268
+ const numberedRules = cfg.rules
269
+ .map((rule, i) => `${i + 1}. ${rule}`)
270
+ .join("\n");
271
+
272
+ const fallbackInstructions = buildFallbackInstructions(cfg);
273
+
160
274
  return [
161
275
  "## Model Delegation Protocol",
162
276
  `Preset: ${cfg.activePreset}. Tiers: ${tierSummary}.`,
163
277
  "",
278
+ "Tier capabilities:",
279
+ tierDescriptions,
280
+ "",
164
281
  "Apply to every user message (plan and ad-hoc):",
165
- "1. Split multi-part requests into atomic tasks.",
166
- "2. Respect explicit tier instructions and [tier:fast|medium|heavy] tags.",
167
- "3. Route read-only search/exploration tasks to @fast.",
168
- "4. Route implementation/edit/refactor/test/bugfix tasks to @medium.",
169
- "5. Route architecture/security/performance/complex debugging to @heavy.",
170
- "6. For mixed requests, delegate each subtask to the matching tier, then synthesize one final response.",
171
- "7. For trivial single read/grep tasks, execute directly.",
172
- `8. If uncertain, default to @${cfg.defaultTier}.`,
282
+ numberedRules,
283
+ ...(fallbackInstructions ? ["", fallbackInstructions] : []),
173
284
  "",
174
- "Delegate with Task(subagent_type=\"fast|medium|heavy\", prompt=\"...\").",
285
+ `Delegate with Task(subagent_type="fast|medium|heavy", prompt="...").`,
175
286
  "Keep orchestration and final synthesis in the primary agent.",
176
287
  ].join("\n");
177
288
  }
@@ -332,13 +443,13 @@ const ModelRouterPlugin: Plugin = async (_ctx: PluginInput) => {
332
443
  },
333
444
 
334
445
  // -----------------------------------------------------------------------
335
- // Inject delegation protocol — re-reads config each time for live updates
446
+ // Inject delegation protocol — uses cached config (invalidated on /preset)
336
447
  // -----------------------------------------------------------------------
337
448
  "experimental.chat.system.transform": async (_input: any, output: any) => {
338
449
  try {
339
- cfg = loadConfig(); // Re-read for live preset switches
450
+ cfg = loadConfig(); // Returns cache unless invalidated
340
451
  } catch {
341
- // Use cached config if file read fails
452
+ // Use last known config if file read fails
342
453
  }
343
454
  output.system.push(buildDelegationProtocol(cfg));
344
455
  },
package/tiers.json CHANGED
@@ -79,6 +79,93 @@
79
79
  "Performance optimization"
80
80
  ]
81
81
  }
82
+ },
83
+ "github-copilot": {
84
+ "fast": {
85
+ "model": "github-copilot/claude-haiku-4-5",
86
+ "description": "Claude Haiku 4.5 via GitHub Copilot for fast exploration and simple tasks",
87
+ "steps": 30,
88
+ "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.",
89
+ "whenToUse": [
90
+ "Codebase exploration and search",
91
+ "Simple file reads and listing",
92
+ "Grep/glob operations",
93
+ "Quick lookups and research"
94
+ ]
95
+ },
96
+ "medium": {
97
+ "model": "github-copilot/claude-sonnet-4-5",
98
+ "description": "Claude Sonnet 4.5 via GitHub Copilot for implementation, refactoring, and tests",
99
+ "steps": 50,
100
+ "prompt": "You are an implementation agent. Write clean, production-quality code matching existing project patterns. Run linters/tests after changes when possible.",
101
+ "whenToUse": [
102
+ "Feature implementation",
103
+ "Refactoring",
104
+ "Writing tests",
105
+ "Code review",
106
+ "Bug fixes"
107
+ ]
108
+ },
109
+ "heavy": {
110
+ "model": "github-copilot/claude-opus-4-6",
111
+ "variant": "thinking",
112
+ "description": "Claude Opus 4.6 via GitHub Copilot for architecture, complex debugging, and security",
113
+ "steps": 30,
114
+ "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
115
+ "whenToUse": [
116
+ "Architecture decisions",
117
+ "Complex debugging (after 2+ failures)",
118
+ "Security review",
119
+ "Performance optimization"
120
+ ]
121
+ }
122
+ },
123
+ "google": {
124
+ "fast": {
125
+ "model": "google/gemini-2.5-flash",
126
+ "description": "Gemini 2.5 Flash for fast exploration and simple tasks",
127
+ "steps": 30,
128
+ "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.",
129
+ "whenToUse": [
130
+ "Codebase exploration and search",
131
+ "Simple file reads and listing",
132
+ "Grep/glob operations",
133
+ "Quick lookups and research"
134
+ ]
135
+ },
136
+ "medium": {
137
+ "model": "google/gemini-2.5-pro",
138
+ "description": "Gemini 2.5 Pro for implementation, refactoring, and tests",
139
+ "steps": 50,
140
+ "prompt": "You are an implementation agent. Write clean, production-quality code matching existing project patterns. Run linters/tests after changes when possible.",
141
+ "whenToUse": [
142
+ "Feature implementation",
143
+ "Refactoring",
144
+ "Writing tests",
145
+ "Code review",
146
+ "Bug fixes"
147
+ ]
148
+ },
149
+ "heavy": {
150
+ "model": "google/gemini-3-pro-preview",
151
+ "description": "Gemini 3 Pro Preview for architecture, complex debugging, and security",
152
+ "steps": 30,
153
+ "prompt": "You are a senior architecture consultant. Analyze deeply, consider tradeoffs, and provide thorough reasoning. Be exhaustive in your analysis.",
154
+ "whenToUse": [
155
+ "Architecture decisions",
156
+ "Complex debugging (after 2+ failures)",
157
+ "Security review",
158
+ "Performance optimization"
159
+ ]
160
+ }
161
+ }
162
+ },
163
+ "fallback": {
164
+ "global": {
165
+ "anthropic": ["openai", "google", "github-copilot"],
166
+ "openai": ["anthropic", "google", "github-copilot"],
167
+ "github-copilot": ["anthropic", "openai", "google"],
168
+ "google": ["openai", "anthropic", "github-copilot"]
82
169
  }
83
170
  },
84
171
  "rules": [
@@ -88,9 +175,9 @@
88
175
  "When a plan says 'use a heavy/powerful model' -> delegate to @heavy",
89
176
  "Default to @medium for implementation tasks you could delegate",
90
177
  "Use @fast for any read-only exploration or research task",
91
- "Keep orchestration (planning, decisions, verification) for yourself delegate execution",
178
+ "Keep orchestration (planning, decisions, verification) for yourself - delegate execution",
92
179
  "For trivial tasks (single grep, single file read), execute directly without delegation",
93
- "Never delegate to @heavy if you are already running on an opus-class model do it yourself"
180
+ "Never delegate to @heavy if you are already running on an opus-class model - do it yourself"
94
181
  ],
95
182
  "defaultTier": "medium"
96
183
  }