@renjfk/opencode-model-fallback 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -55,7 +55,6 @@ If you want to set plugin options, use the tuple form:
55
55
  - `retry_on_errors`: retryable HTTP status codes. Defaults to `429`.
56
56
  - `retryable_error_patterns`: retryable error message patterns. Defaults to `["rate.?limit"]`.
57
57
  - `cooldown_seconds`: how long a failed original model remains on fallback. Defaults to `3600`.
58
- - `timeout_seconds`: abort and retry if a response is inactive for this long. Defaults to `30`.
59
58
  - `notify_on_fallback`: show fallback/recovery toasts. Defaults to `true`.
60
59
 
61
60
  ## How it works
@@ -227,7 +226,7 @@ at the local repo path, not the npm package name:
227
226
 
228
227
  ```json
229
228
  {
230
- "plugin": ["/Users/your-user/opencode-model-fallback"]
229
+ "plugin": ["/Users/your-user/opencode-model-fallback/index.js"]
231
230
  }
232
231
  ```
233
232
 
package/lib/options.js CHANGED
@@ -3,7 +3,6 @@ const DEFAULT_OPTIONS = {
3
3
  retry_on_errors: [429],
4
4
  retryable_error_patterns: ["rate.?limit"],
5
5
  cooldown_seconds: 3600,
6
- timeout_seconds: 30,
7
6
  notify_on_fallback: true,
8
7
  };
9
8
 
package/lib/router.js CHANGED
@@ -13,9 +13,9 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
13
13
  );
14
14
  const store = createStateStore();
15
15
  const retrying = new Set();
16
- const timers = new Map();
17
16
  const selfAbortAt = new Map();
18
17
  const activeOriginals = new Map();
18
+ const activeTargets = new Map();
19
19
  let agentConfigs;
20
20
 
21
21
  function hasMapping(model) {
@@ -41,13 +41,6 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
41
41
  return cooldown ? fallback : original;
42
42
  }
43
43
 
44
- function timedOriginal(requested) {
45
- if (!hasMapping(requested)) return undefined;
46
- const cooldown = store.getModelCooldown(requested);
47
- if (cooldown) return undefined;
48
- return requested;
49
- }
50
-
51
44
  function shouldRoute(requested) {
52
45
  return hasMapping(requested) || !!fallbackToOriginal[requested];
53
46
  }
@@ -65,31 +58,6 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
65
58
  return !cooldown;
66
59
  }
67
60
 
68
- function clearTimer(sessionID) {
69
- const timer = timers.get(sessionID);
70
- if (timer) clearTimeout(timer);
71
- timers.delete(sessionID);
72
- }
73
-
74
- function scheduleTimeout(sessionID, original, agent) {
75
- clearTimer(sessionID);
76
- if (options.timeout_seconds <= 0 || !hasMapping(original)) return;
77
- timers.set(
78
- sessionID,
79
- setTimeout(async () => {
80
- timers.delete(sessionID);
81
- if (retrying.has(sessionID) || !timedOriginal(original)) return;
82
- retrying.add(sessionID);
83
- try {
84
- await abortCurrentSession(sessionID);
85
- await retryWithFallback(sessionID, original, agent, "timeout");
86
- } finally {
87
- retrying.delete(sessionID);
88
- }
89
- }, options.timeout_seconds * 1000),
90
- );
91
- }
92
-
93
61
  async function abortCurrentSession(sessionID) {
94
62
  const aborted = await abortSession(ctx.client, sessionID);
95
63
  if (aborted) selfAbortAt.set(sessionID, Date.now());
@@ -104,7 +72,6 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
104
72
  store.setModelCooldown(original, reason, failedAt, cooldownUntil);
105
73
  const parts = await getReplayParts(ctx.client, ctx.directory, sessionID);
106
74
  if (parts.length === 0) return;
107
- clearTimer(sessionID);
108
75
  try {
109
76
  await new Promise((resolve) => setTimeout(resolve, POST_ABORT_DELAY_MS));
110
77
  await ctx.client.session.promptAsync({
@@ -123,6 +90,19 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
123
90
  .catch(() => {});
124
91
  }
125
92
 
93
+ async function toastRouteChange(sessionID, requested, target, original) {
94
+ const previous = activeTargets.get(sessionID);
95
+ activeTargets.set(sessionID, target);
96
+ if (previous === target) return;
97
+ if (!previous && requested === target) return;
98
+ const isFallback = target !== original;
99
+ await toast(
100
+ isFallback ? "Model Fallback" : "Model Recovered",
101
+ previous ? `${previous} -> ${target}` : `Using ${target} instead of ${requested}`,
102
+ isFallback ? "warning" : "info",
103
+ );
104
+ }
105
+
126
106
  async function handleError(sessionID, error, model, agent, source) {
127
107
  if (!sessionID || retrying.has(sessionID)) return;
128
108
  const name = extractErrorName(error);
@@ -138,7 +118,6 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
138
118
  }
139
119
  if (!shouldFallbackFromError(failed, original)) return;
140
120
  retrying.add(sessionID);
141
- clearTimer(sessionID);
142
121
  try {
143
122
  await retryWithFallback(sessionID, original, agent, source);
144
123
  } finally {
@@ -166,7 +145,6 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
166
145
  }
167
146
  if (!shouldFallbackFromError(failed, original)) return;
168
147
  retrying.add(sessionID);
169
- clearTimer(sessionID);
170
148
  try {
171
149
  await abortCurrentSession(sessionID);
172
150
  await retryWithFallback(sessionID, original, agent, "session.status");
@@ -197,16 +175,7 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
197
175
  if (original) activeOriginals.set(sessionID, original);
198
176
  const model = modelObject(target);
199
177
  if (model && output.message) output.message.model = model;
200
- if (requested !== target) {
201
- const isFallback = hasMapping(requested);
202
- await toast(
203
- isFallback ? "Model Fallback" : "Model Recovered",
204
- `Using ${target} instead of ${requested}`,
205
- isFallback ? "warning" : "info",
206
- );
207
- }
208
- if (hasMapping(target)) scheduleTimeout(sessionID, target, input.agent);
209
- else clearTimer(sessionID);
178
+ await toastRouteChange(sessionID, requested, target, original);
210
179
  },
211
180
 
212
181
  event: async ({ event }) => {
@@ -216,7 +185,7 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
216
185
  if (id) {
217
186
  retrying.delete(id);
218
187
  activeOriginals.delete(id);
219
- clearTimer(id);
188
+ activeTargets.delete(id);
220
189
  }
221
190
  return;
222
191
  }
@@ -237,11 +206,8 @@ export function createMappedFallbackRouter(ctx, rawOptions) {
237
206
  if (event.type === "message.updated") {
238
207
  const info = props?.info;
239
208
  if (info?.role !== "assistant") return;
209
+ if (!info?.error) return;
240
210
  const sessionID = info?.sessionID;
241
- if (!info?.error) {
242
- if (sessionID) clearTimer(sessionID);
243
- return;
244
- }
245
211
  const model =
246
212
  info?.model ??
247
213
  (typeof info?.providerID === "string" && typeof info?.modelID === "string"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renjfk/opencode-model-fallback",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Mapped model fallback router for OpenCode. Routes retryable model failures to configured fallback models and recovers after cooldown.",
5
5
  "keywords": [
6
6
  "fallback",
@@ -39,6 +39,6 @@
39
39
  "devDependencies": {
40
40
  "oxfmt": "0.50.0",
41
41
  "oxlint": "1.65.0",
42
- "vitest": "latest"
42
+ "vitest": "4.1.6"
43
43
  }
44
44
  }