@shutovks/opencode-model-fallback 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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +385 -0
- package/dist/plugin.d.ts +129 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opencode-model-fallback contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @shutovks/opencode-model-fallback
|
|
2
|
+
|
|
3
|
+
OpenCode plugin that keeps a session alive when the current model fails with rate limits, quota exhaustion, overloads, or temporary provider errors.
|
|
4
|
+
|
|
5
|
+
It retries the last user message in the same session with the next configured fallback model.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add -g @shutovks/opencode-model-fallback
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or use any package manager OpenCode can resolve from your `plugin` config.
|
|
14
|
+
|
|
15
|
+
## Configure
|
|
16
|
+
|
|
17
|
+
Add the plugin to `opencode.json` or `.opencode/opencode.json`:
|
|
18
|
+
|
|
19
|
+
```jsonc
|
|
20
|
+
{
|
|
21
|
+
"$schema": "https://opencode.ai/config.json",
|
|
22
|
+
"plugin": [
|
|
23
|
+
[
|
|
24
|
+
"@shutovks/opencode-model-fallback",
|
|
25
|
+
{
|
|
26
|
+
"enabled": true,
|
|
27
|
+
"fallback_models": [
|
|
28
|
+
"anthropic/claude-sonnet-4-5",
|
|
29
|
+
"openai/gpt-5.1-codex",
|
|
30
|
+
"google/gemini-2.5-pro"
|
|
31
|
+
],
|
|
32
|
+
"unavailable_models": [],
|
|
33
|
+
"max_attempts": 3,
|
|
34
|
+
"cooldown_ms": 60000,
|
|
35
|
+
"notify": true
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart OpenCode after changing config.
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
| Option | Default | Meaning |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `enabled` | `true` | Default behavior for new sessions. |
|
|
49
|
+
| `fallback_models` | `[]` | Ordered fallback model list, each as `provider/model`. |
|
|
50
|
+
| `unavailable_models` | `[]` | Exact model IDs to skip in the config hook before the first provider call. Useful when OpenCode fails before emitting `session.error`. |
|
|
51
|
+
| `retry_on_errors` | `[429, 500, 502, 503, 504]` | HTTP status codes that trigger fallback. |
|
|
52
|
+
| `max_attempts` | `3` | Max fallback retries per session. |
|
|
53
|
+
| `cooldown_ms` | `60000` | How long to avoid a failed model. |
|
|
54
|
+
| `notify` | `true` | Show a toast when switching models. |
|
|
55
|
+
|
|
56
|
+
## Session Control
|
|
57
|
+
|
|
58
|
+
The plugin registers one tool:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
model_fallback_control(action: "enable" | "disable" | "status" | "reset")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Use it from inside OpenCode to control fallback for the current session:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Use model_fallback_control to disable fallback in this session.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Actions:
|
|
71
|
+
|
|
72
|
+
- `enable`: enable fallback for the current session.
|
|
73
|
+
- `disable`: disable fallback for the current session.
|
|
74
|
+
- `status`: return effective state, current model, attempts, and configured fallbacks.
|
|
75
|
+
- `reset`: clear session state and return to the config default.
|
|
76
|
+
|
|
77
|
+
Session overrides are in memory only. They disappear when the session is deleted or OpenCode restarts.
|
|
78
|
+
|
|
79
|
+
## What Counts As Retryable
|
|
80
|
+
|
|
81
|
+
Fallback runs on configured status codes and common transient error text:
|
|
82
|
+
|
|
83
|
+
- rate limit / too many requests
|
|
84
|
+
- quota exceeded
|
|
85
|
+
- all credentials for model exhausted
|
|
86
|
+
- model unsupported
|
|
87
|
+
- service unavailable / overloaded / temporarily unavailable
|
|
88
|
+
- `429`, `503`, `529` in plain text errors
|
|
89
|
+
|
|
90
|
+
## Limitations
|
|
91
|
+
|
|
92
|
+
OpenCode does not let a plugin swap the model inside a failed stream. This plugin starts a new prompt in the same session with the last user message and the fallback model.
|
|
93
|
+
|
|
94
|
+
It does not restore a half-finished tool-call turn. If the model failed after starting tool calls, the retry is a new model turn from the last user request.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bun install
|
|
100
|
+
bun test
|
|
101
|
+
bun run typecheck
|
|
102
|
+
bun run build
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ModelFallbackPlugin, default } from "./plugin";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/plugin.ts
|
|
3
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
4
|
+
var DEFAULT_CONFIG = {
|
|
5
|
+
enabled: true,
|
|
6
|
+
fallbackModels: [],
|
|
7
|
+
unavailableModels: [],
|
|
8
|
+
retryOnErrors: [429, 500, 502, 503, 504],
|
|
9
|
+
maxAttempts: 3,
|
|
10
|
+
cooldownMs: 60000,
|
|
11
|
+
notify: true
|
|
12
|
+
};
|
|
13
|
+
var RETRYABLE_PATTERNS = [
|
|
14
|
+
/rate.?limit/i,
|
|
15
|
+
/too.?many.?requests/i,
|
|
16
|
+
/quota.?exceeded/i,
|
|
17
|
+
/exceeded.*quota/i,
|
|
18
|
+
/usage\s*quota/i,
|
|
19
|
+
/all.*credentials.*for.*model/i,
|
|
20
|
+
/cool(?:ing)?.?down/i,
|
|
21
|
+
/model.{0,20}?not.{0,10}?supported/i,
|
|
22
|
+
/model_not_supported/i,
|
|
23
|
+
/service.?unavailable/i,
|
|
24
|
+
/overloaded/i,
|
|
25
|
+
/temporarily.?unavailable/i,
|
|
26
|
+
/try.?again/i,
|
|
27
|
+
/(?:^|\s)429(?:\s|$)/,
|
|
28
|
+
/(?:^|\s)503(?:\s|$)/,
|
|
29
|
+
/(?:^|\s)529(?:\s|$)/
|
|
30
|
+
];
|
|
31
|
+
function isRecord(value) {
|
|
32
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function stringField(record, key) {
|
|
35
|
+
const value = record?.[key];
|
|
36
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
function normalizeOptions(options = {}) {
|
|
39
|
+
const fallbackModels = Array.isArray(options.fallback_models) ? options.fallback_models : DEFAULT_CONFIG.fallbackModels;
|
|
40
|
+
const unavailableModels = Array.isArray(options.unavailable_models) ? options.unavailable_models : DEFAULT_CONFIG.unavailableModels;
|
|
41
|
+
return {
|
|
42
|
+
enabled: options.enabled ?? DEFAULT_CONFIG.enabled,
|
|
43
|
+
fallbackModels: fallbackModels.filter((model) => typeof model === "string" && parseModel(model) !== undefined),
|
|
44
|
+
unavailableModels: unavailableModels.filter((model) => typeof model === "string" && parseModel(model) !== undefined),
|
|
45
|
+
retryOnErrors: options.retry_on_errors ?? DEFAULT_CONFIG.retryOnErrors,
|
|
46
|
+
maxAttempts: options.max_attempts ?? DEFAULT_CONFIG.maxAttempts,
|
|
47
|
+
cooldownMs: options.cooldown_ms ?? DEFAULT_CONFIG.cooldownMs,
|
|
48
|
+
notify: options.notify ?? DEFAULT_CONFIG.notify
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function parseModel(model) {
|
|
52
|
+
const [providerID, ...modelParts] = model.split("/");
|
|
53
|
+
if (!providerID || modelParts.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
return { providerID, modelID: modelParts.join("/") };
|
|
56
|
+
}
|
|
57
|
+
function normalizeModel(model) {
|
|
58
|
+
if (typeof model === "string")
|
|
59
|
+
return model;
|
|
60
|
+
if (!isRecord(model))
|
|
61
|
+
return;
|
|
62
|
+
const providerID = stringField(model, "providerID");
|
|
63
|
+
const modelID = stringField(model, "modelID") ?? stringField(model, "id");
|
|
64
|
+
return providerID && modelID ? `${providerID}/${modelID}` : undefined;
|
|
65
|
+
}
|
|
66
|
+
function resolveSessionID(properties) {
|
|
67
|
+
const props = isRecord(properties) ? properties : undefined;
|
|
68
|
+
const info = isRecord(props?.info) ? props.info : undefined;
|
|
69
|
+
return stringField(props, "sessionID") ?? stringField(info, "sessionID") ?? stringField(info, "id");
|
|
70
|
+
}
|
|
71
|
+
function resolveEventModel(properties) {
|
|
72
|
+
const props = isRecord(properties) ? properties : undefined;
|
|
73
|
+
const info = isRecord(props?.info) ? props.info : undefined;
|
|
74
|
+
const providerID = stringField(props, "providerID");
|
|
75
|
+
const modelID = stringField(props, "modelID");
|
|
76
|
+
return normalizeModel(props?.model) ?? normalizeModel(info?.model) ?? (providerID && modelID ? `${providerID}/${modelID}` : undefined);
|
|
77
|
+
}
|
|
78
|
+
function statusCode(error) {
|
|
79
|
+
if (!isRecord(error))
|
|
80
|
+
return;
|
|
81
|
+
const direct = error.statusCode ?? error.status ?? error.code;
|
|
82
|
+
if (typeof direct === "number")
|
|
83
|
+
return direct;
|
|
84
|
+
if (typeof direct === "string" && /^\d+$/.test(direct))
|
|
85
|
+
return Number(direct);
|
|
86
|
+
const data = isRecord(error.data) ? error.data : undefined;
|
|
87
|
+
const dataStatus = data?.statusCode ?? data?.status ?? data?.code;
|
|
88
|
+
if (typeof dataStatus === "number")
|
|
89
|
+
return dataStatus;
|
|
90
|
+
if (typeof dataStatus === "string" && /^\d+$/.test(dataStatus))
|
|
91
|
+
return Number(dataStatus);
|
|
92
|
+
const response = isRecord(error.response) ? error.response : undefined;
|
|
93
|
+
const responseStatus = response?.status;
|
|
94
|
+
return typeof responseStatus === "number" ? responseStatus : undefined;
|
|
95
|
+
}
|
|
96
|
+
function errorText(error) {
|
|
97
|
+
if (typeof error === "string")
|
|
98
|
+
return error;
|
|
99
|
+
if (error instanceof Error)
|
|
100
|
+
return `${error.name} ${error.message}`;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.stringify(error);
|
|
103
|
+
} catch {
|
|
104
|
+
return String(error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function isRetryableError(error, retryOnErrors) {
|
|
108
|
+
const status = statusCode(error);
|
|
109
|
+
if (status !== undefined && retryOnErrors.includes(status))
|
|
110
|
+
return true;
|
|
111
|
+
const text = errorText(error);
|
|
112
|
+
return RETRYABLE_PATTERNS.some((pattern) => pattern.test(text));
|
|
113
|
+
}
|
|
114
|
+
function extractMessages(response) {
|
|
115
|
+
if (Array.isArray(response))
|
|
116
|
+
return response;
|
|
117
|
+
if (isRecord(response) && Array.isArray(response.data))
|
|
118
|
+
return response.data;
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
function extractParts(value) {
|
|
122
|
+
if (!Array.isArray(value))
|
|
123
|
+
return [];
|
|
124
|
+
return value.filter((part) => {
|
|
125
|
+
return isRecord(part) && typeof part.type === "string";
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function getLastUserPayload(messagesResponse) {
|
|
129
|
+
const lastUserMessage = extractMessages(messagesResponse).filter((message) => isRecord(message) && isRecord(message.info) && message.info.role === "user").pop();
|
|
130
|
+
if (!isRecord(lastUserMessage))
|
|
131
|
+
return { parts: [] };
|
|
132
|
+
const info = isRecord(lastUserMessage.info) ? lastUserMessage.info : undefined;
|
|
133
|
+
const messageParts = extractParts(lastUserMessage.parts);
|
|
134
|
+
const infoID = stringField(info, "id");
|
|
135
|
+
const parts = messageParts.length > 0 ? messageParts : extractParts(info?.parts);
|
|
136
|
+
return {
|
|
137
|
+
parts,
|
|
138
|
+
...infoID ? { messageID: infoID } : {}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function getState(states, sessionID, model, agent) {
|
|
142
|
+
let state = states.get(sessionID);
|
|
143
|
+
if (!state) {
|
|
144
|
+
state = {
|
|
145
|
+
originalModel: model,
|
|
146
|
+
currentModel: model,
|
|
147
|
+
agent,
|
|
148
|
+
attempts: 0,
|
|
149
|
+
failedUntil: new Map
|
|
150
|
+
};
|
|
151
|
+
states.set(sessionID, state);
|
|
152
|
+
return state;
|
|
153
|
+
}
|
|
154
|
+
if (agent)
|
|
155
|
+
state.agent = agent;
|
|
156
|
+
if (!state.originalModel && model)
|
|
157
|
+
state.originalModel = model;
|
|
158
|
+
if (!state.currentModel && model)
|
|
159
|
+
state.currentModel = model;
|
|
160
|
+
return state;
|
|
161
|
+
}
|
|
162
|
+
function isEnabled(config, state) {
|
|
163
|
+
return state?.enabledOverride ?? config.enabled;
|
|
164
|
+
}
|
|
165
|
+
function nextModel(config, state) {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
return config.fallbackModels.find((model) => {
|
|
168
|
+
if (model === state.currentModel)
|
|
169
|
+
return false;
|
|
170
|
+
return (state.failedUntil.get(model) ?? 0) <= now;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function firstFallbackFor(config, model) {
|
|
174
|
+
return config.fallbackModels.find((fallbackModel) => fallbackModel !== model);
|
|
175
|
+
}
|
|
176
|
+
function fallbackForUnavailable(config, model) {
|
|
177
|
+
if (!model || !config.unavailableModels.includes(model))
|
|
178
|
+
return;
|
|
179
|
+
return firstFallbackFor(config, model);
|
|
180
|
+
}
|
|
181
|
+
function resetState(state) {
|
|
182
|
+
state.currentModel = state.originalModel;
|
|
183
|
+
state.attempts = 0;
|
|
184
|
+
state.failedUntil.clear();
|
|
185
|
+
state.enabledOverride = undefined;
|
|
186
|
+
state.pendingModel = undefined;
|
|
187
|
+
state.awaitingModel = undefined;
|
|
188
|
+
}
|
|
189
|
+
function statusText(config, state) {
|
|
190
|
+
const enabled = isEnabled(config, state);
|
|
191
|
+
return JSON.stringify({
|
|
192
|
+
enabled,
|
|
193
|
+
defaultEnabled: config.enabled,
|
|
194
|
+
override: state?.enabledOverride ?? null,
|
|
195
|
+
currentModel: state?.currentModel ?? null,
|
|
196
|
+
originalModel: state?.originalModel ?? null,
|
|
197
|
+
attempts: state?.attempts ?? 0,
|
|
198
|
+
maxAttempts: config.maxAttempts,
|
|
199
|
+
fallbackModels: config.fallbackModels,
|
|
200
|
+
unavailableModels: config.unavailableModels
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function createModelFallbackPlugin(input, options = {}) {
|
|
204
|
+
const config = normalizeOptions(options);
|
|
205
|
+
const states = new Map;
|
|
206
|
+
const inFlight = new Set;
|
|
207
|
+
async function showToast(message) {
|
|
208
|
+
if (!config.notify)
|
|
209
|
+
return;
|
|
210
|
+
await input.client.tui?.showToast?.({
|
|
211
|
+
body: {
|
|
212
|
+
title: "Model Fallback",
|
|
213
|
+
message,
|
|
214
|
+
variant: "warning",
|
|
215
|
+
duration: 5000
|
|
216
|
+
}
|
|
217
|
+
}).catch(() => {
|
|
218
|
+
return;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async function retryWithModel(sessionID, state, model) {
|
|
222
|
+
const parsed = parseModel(model);
|
|
223
|
+
if (!parsed)
|
|
224
|
+
return false;
|
|
225
|
+
const messages = await input.client.session.messages({
|
|
226
|
+
path: { id: sessionID },
|
|
227
|
+
query: { directory: input.directory }
|
|
228
|
+
});
|
|
229
|
+
const payload = getLastUserPayload(messages);
|
|
230
|
+
if (payload.parts.length === 0)
|
|
231
|
+
return false;
|
|
232
|
+
await input.client.session.promptAsync({
|
|
233
|
+
path: { id: sessionID },
|
|
234
|
+
query: { directory: input.directory },
|
|
235
|
+
body: {
|
|
236
|
+
model: parsed,
|
|
237
|
+
parts: payload.parts,
|
|
238
|
+
...state.agent ? { agent: state.agent } : {},
|
|
239
|
+
...payload.messageID ? { messageID: payload.messageID } : {}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
config: async (opencodeConfig) => {
|
|
246
|
+
if (!config.enabled)
|
|
247
|
+
return;
|
|
248
|
+
const rootFallback = fallbackForUnavailable(config, opencodeConfig.model);
|
|
249
|
+
if (rootFallback)
|
|
250
|
+
opencodeConfig.model = rootFallback;
|
|
251
|
+
for (const agent of Object.values(opencodeConfig.agent ?? {})) {
|
|
252
|
+
if (!agent)
|
|
253
|
+
continue;
|
|
254
|
+
const agentFallback = fallbackForUnavailable(config, agent.model);
|
|
255
|
+
if (agentFallback)
|
|
256
|
+
agent.model = agentFallback;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
tool: {
|
|
260
|
+
model_fallback_control: tool({
|
|
261
|
+
description: "Enable, disable, inspect, or reset model fallback for the current OpenCode session.",
|
|
262
|
+
args: {
|
|
263
|
+
action: tool.schema.string().describe("Session fallback action: enable, disable, status, or reset")
|
|
264
|
+
},
|
|
265
|
+
execute: async (args, context) => {
|
|
266
|
+
const state = getState(states, context.sessionID, undefined, context.agent);
|
|
267
|
+
if (args.action === "enable") {
|
|
268
|
+
state.enabledOverride = true;
|
|
269
|
+
return statusText(config, state);
|
|
270
|
+
}
|
|
271
|
+
if (args.action === "disable") {
|
|
272
|
+
state.enabledOverride = false;
|
|
273
|
+
return statusText(config, state);
|
|
274
|
+
}
|
|
275
|
+
if (args.action === "reset") {
|
|
276
|
+
resetState(state);
|
|
277
|
+
return statusText(config, state);
|
|
278
|
+
}
|
|
279
|
+
if (args.action !== "status") {
|
|
280
|
+
return `Unknown action: ${args.action}. Use enable, disable, status, or reset.`;
|
|
281
|
+
}
|
|
282
|
+
return statusText(config, state);
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
},
|
|
286
|
+
event: async ({ event }) => {
|
|
287
|
+
const props = isRecord(event.properties) ? event.properties : {};
|
|
288
|
+
const sessionID = resolveSessionID(props);
|
|
289
|
+
if (!sessionID)
|
|
290
|
+
return;
|
|
291
|
+
if (event.type === "session.created") {
|
|
292
|
+
const info = isRecord(props.info) ? props.info : undefined;
|
|
293
|
+
getState(states, sessionID, resolveEventModel(props), stringField(info, "agent"));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (event.type === "session.deleted") {
|
|
297
|
+
states.delete(sessionID);
|
|
298
|
+
inFlight.delete(sessionID);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (event.type !== "session.error")
|
|
302
|
+
return;
|
|
303
|
+
if (inFlight.has(sessionID))
|
|
304
|
+
return;
|
|
305
|
+
const state = getState(states, sessionID, resolveEventModel(props), stringField(props, "agent"));
|
|
306
|
+
if (!isEnabled(config, state))
|
|
307
|
+
return;
|
|
308
|
+
if (config.fallbackModels.length === 0)
|
|
309
|
+
return;
|
|
310
|
+
if (!isRetryableError(props.error, config.retryOnErrors))
|
|
311
|
+
return;
|
|
312
|
+
if (state.attempts >= config.maxAttempts)
|
|
313
|
+
return;
|
|
314
|
+
const eventModel = resolveEventModel(props);
|
|
315
|
+
if (state.awaitingModel && eventModel && eventModel !== state.awaitingModel)
|
|
316
|
+
return;
|
|
317
|
+
state.awaitingModel = undefined;
|
|
318
|
+
const failedModel = eventModel ?? state.currentModel;
|
|
319
|
+
if (failedModel) {
|
|
320
|
+
state.currentModel = failedModel;
|
|
321
|
+
state.failedUntil.set(failedModel, Date.now() + config.cooldownMs);
|
|
322
|
+
}
|
|
323
|
+
const model = nextModel(config, state);
|
|
324
|
+
if (!model)
|
|
325
|
+
return;
|
|
326
|
+
inFlight.add(sessionID);
|
|
327
|
+
try {
|
|
328
|
+
const accepted = await retryWithModel(sessionID, state, model);
|
|
329
|
+
if (!accepted)
|
|
330
|
+
return;
|
|
331
|
+
state.attempts += 1;
|
|
332
|
+
state.currentModel = model;
|
|
333
|
+
state.pendingModel = model;
|
|
334
|
+
state.awaitingModel = model;
|
|
335
|
+
await showToast(`Switched to ${model}`);
|
|
336
|
+
} finally {
|
|
337
|
+
inFlight.delete(sessionID);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"chat.message": async (chatInput, output) => {
|
|
341
|
+
const requestedModel = normalizeModel(chatInput.model);
|
|
342
|
+
const state = states.get(chatInput.sessionID) ?? (requestedModel ? getState(states, chatInput.sessionID, requestedModel, chatInput.agent) : undefined);
|
|
343
|
+
if (!state || !isEnabled(config, state))
|
|
344
|
+
return;
|
|
345
|
+
if (requestedModel === state.pendingModel) {
|
|
346
|
+
state.pendingModel = undefined;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (requestedModel && config.unavailableModels.includes(requestedModel)) {
|
|
350
|
+
const fallbackModel = firstFallbackFor(config, requestedModel);
|
|
351
|
+
const parsed2 = fallbackModel ? parseModel(fallbackModel) : undefined;
|
|
352
|
+
if (!parsed2)
|
|
353
|
+
return;
|
|
354
|
+
state.originalModel = requestedModel;
|
|
355
|
+
state.currentModel = fallbackModel;
|
|
356
|
+
state.pendingModel = fallbackModel;
|
|
357
|
+
output.message.model = parsed2;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!state.currentModel)
|
|
361
|
+
return;
|
|
362
|
+
if (requestedModel && requestedModel !== state.currentModel) {
|
|
363
|
+
state.originalModel = requestedModel;
|
|
364
|
+
state.currentModel = requestedModel;
|
|
365
|
+
state.attempts = 0;
|
|
366
|
+
state.failedUntil.clear();
|
|
367
|
+
state.pendingModel = undefined;
|
|
368
|
+
state.awaitingModel = undefined;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (state.currentModel === state.originalModel)
|
|
372
|
+
return;
|
|
373
|
+
const parsed = parseModel(state.currentModel);
|
|
374
|
+
if (parsed)
|
|
375
|
+
output.message.model = parsed;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
var plugin = async (input, options) => createModelFallbackPlugin(input, options);
|
|
380
|
+
var ModelFallbackPlugin = plugin;
|
|
381
|
+
var plugin_default = plugin;
|
|
382
|
+
export {
|
|
383
|
+
plugin_default as default,
|
|
384
|
+
ModelFallbackPlugin
|
|
385
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export type Options = {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
fallback_models?: string[];
|
|
4
|
+
unavailable_models?: string[];
|
|
5
|
+
retry_on_errors?: number[];
|
|
6
|
+
max_attempts?: number;
|
|
7
|
+
cooldown_ms?: number;
|
|
8
|
+
notify?: boolean;
|
|
9
|
+
};
|
|
10
|
+
type Config = {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
fallbackModels: string[];
|
|
13
|
+
unavailableModels: string[];
|
|
14
|
+
retryOnErrors: number[];
|
|
15
|
+
maxAttempts: number;
|
|
16
|
+
cooldownMs: number;
|
|
17
|
+
notify: boolean;
|
|
18
|
+
};
|
|
19
|
+
type ModelRef = {
|
|
20
|
+
providerID: string;
|
|
21
|
+
modelID: string;
|
|
22
|
+
};
|
|
23
|
+
type PromptPart = {
|
|
24
|
+
type: string;
|
|
25
|
+
text?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
type LastUserPayload = {
|
|
29
|
+
parts: PromptPart[];
|
|
30
|
+
messageID?: string;
|
|
31
|
+
};
|
|
32
|
+
type RuntimeEventInput = {
|
|
33
|
+
event: {
|
|
34
|
+
type: string;
|
|
35
|
+
properties?: unknown;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
type ChatMessageInput = {
|
|
39
|
+
sessionID: string;
|
|
40
|
+
agent?: string;
|
|
41
|
+
model?: ModelRef;
|
|
42
|
+
};
|
|
43
|
+
type ChatMessageOutput = {
|
|
44
|
+
message: {
|
|
45
|
+
model?: ModelRef;
|
|
46
|
+
};
|
|
47
|
+
parts: PromptPart[];
|
|
48
|
+
};
|
|
49
|
+
type OpenCodeConfigInput = {
|
|
50
|
+
model?: string;
|
|
51
|
+
agent?: Record<string, {
|
|
52
|
+
model?: string;
|
|
53
|
+
} | undefined>;
|
|
54
|
+
};
|
|
55
|
+
type RuntimeInput = {
|
|
56
|
+
directory: string;
|
|
57
|
+
client: {
|
|
58
|
+
session: {
|
|
59
|
+
messages(input: unknown): Promise<unknown>;
|
|
60
|
+
promptAsync(input: unknown): Promise<unknown>;
|
|
61
|
+
};
|
|
62
|
+
tui?: {
|
|
63
|
+
showToast?(input: {
|
|
64
|
+
body: {
|
|
65
|
+
title: string;
|
|
66
|
+
message: string;
|
|
67
|
+
variant: "warning";
|
|
68
|
+
duration: number;
|
|
69
|
+
};
|
|
70
|
+
}): Promise<unknown>;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
export declare function normalizeOptions(options?: Options): Config;
|
|
75
|
+
export declare function parseModel(model: string): ModelRef | undefined;
|
|
76
|
+
export declare function normalizeModel(model: unknown): string | undefined;
|
|
77
|
+
export declare function resolveSessionID(properties: unknown): string | undefined;
|
|
78
|
+
export declare function resolveEventModel(properties: unknown): string | undefined;
|
|
79
|
+
export declare function isRetryableError(error: unknown, retryOnErrors: number[]): boolean;
|
|
80
|
+
export declare function getLastUserPayload(messagesResponse: unknown): LastUserPayload;
|
|
81
|
+
export declare function createModelFallbackPlugin(input: RuntimeInput, options?: Options): {
|
|
82
|
+
config: (opencodeConfig: OpenCodeConfigInput) => Promise<void>;
|
|
83
|
+
tool: {
|
|
84
|
+
model_fallback_control: {
|
|
85
|
+
description: string;
|
|
86
|
+
args: {
|
|
87
|
+
action: import("zod").ZodString;
|
|
88
|
+
};
|
|
89
|
+
execute(args: {
|
|
90
|
+
action: string;
|
|
91
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
event: ({ event }: RuntimeEventInput) => Promise<void>;
|
|
95
|
+
"chat.message": (chatInput: ChatMessageInput, output: ChatMessageOutput) => Promise<void>;
|
|
96
|
+
};
|
|
97
|
+
declare const plugin: (input: import("@opencode-ai/plugin").PluginInput, options: import("@opencode-ai/plugin").PluginOptions | undefined) => Promise<{
|
|
98
|
+
config: (opencodeConfig: OpenCodeConfigInput) => Promise<void>;
|
|
99
|
+
tool: {
|
|
100
|
+
model_fallback_control: {
|
|
101
|
+
description: string;
|
|
102
|
+
args: {
|
|
103
|
+
action: import("zod").ZodString;
|
|
104
|
+
};
|
|
105
|
+
execute(args: {
|
|
106
|
+
action: string;
|
|
107
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
event: ({ event }: RuntimeEventInput) => Promise<void>;
|
|
111
|
+
"chat.message": (chatInput: ChatMessageInput, output: ChatMessageOutput) => Promise<void>;
|
|
112
|
+
}>;
|
|
113
|
+
export declare const ModelFallbackPlugin: (input: import("@opencode-ai/plugin").PluginInput, options: import("@opencode-ai/plugin").PluginOptions | undefined) => Promise<{
|
|
114
|
+
config: (opencodeConfig: OpenCodeConfigInput) => Promise<void>;
|
|
115
|
+
tool: {
|
|
116
|
+
model_fallback_control: {
|
|
117
|
+
description: string;
|
|
118
|
+
args: {
|
|
119
|
+
action: import("zod").ZodString;
|
|
120
|
+
};
|
|
121
|
+
execute(args: {
|
|
122
|
+
action: string;
|
|
123
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
event: ({ event }: RuntimeEventInput) => Promise<void>;
|
|
127
|
+
"chat.message": (chatInput: ChatMessageInput, output: ChatMessageOutput) => Promise<void>;
|
|
128
|
+
}>;
|
|
129
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shutovks/opencode-model-fallback",
|
|
3
|
+
"version": "1.0.7",
|
|
4
|
+
"description": "OpenCode plugin that retries failed sessions with fallback models.",
|
|
5
|
+
"homepage": "https://github.com/ShutovKS/opencode-model-fallback#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/ShutovKS/opencode-model-fallback/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ShutovKS/opencode-model-fallback.git"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\" && bun build ./src/index.ts --target bun --format esm --outfile dist/index.js --external @opencode-ai/plugin --external @opencode-ai/plugin/tool --external zod && tsc -p tsconfig.build.json",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"opencode",
|
|
28
|
+
"opencode-plugin",
|
|
29
|
+
"model-fallback",
|
|
30
|
+
"fallback",
|
|
31
|
+
"retry"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@opencode-ai/plugin": ">=1.15.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@opencode-ai/plugin": "^1.15.13",
|
|
39
|
+
"@types/bun": "latest",
|
|
40
|
+
"typescript": "^5.9.2"
|
|
41
|
+
}
|
|
42
|
+
}
|