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 +15 -1
- package/package.json +1 -1
- package/src/index.ts +128 -17
- package/tiers.json +89 -2
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
|
|
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
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
|
-
|
|
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
|
|
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
|
-
//
|
|
111
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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 —
|
|
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(); //
|
|
450
|
+
cfg = loadConfig(); // Returns cache unless invalidated
|
|
340
451
|
} catch {
|
|
341
|
-
// Use
|
|
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
|
|
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
|
|
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
|
}
|