opencode-model-router 1.0.6 → 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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +128 -17
  3. package/tiers.json +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-model-router",
3
- "version": "1.0.6",
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
@@ -160,6 +160,14 @@
160
160
  }
161
161
  }
162
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"]
169
+ }
170
+ },
163
171
  "rules": [
164
172
  "When a plan step contains [tier:fast], [tier:medium], or [tier:heavy], delegate to that agent",
165
173
  "When a plan says 'use a fast/cheap model' -> delegate to @fast",