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.
- package/package.json +1 -1
- package/src/index.ts +128 -17
- package/tiers.json +8 -0
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
|
@@ -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",
|