@renjfk/opencode-model-fallback 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soner Koksal
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,240 @@
1
+ [![CI](https://github.com/renjfk/opencode-model-fallback/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/renjfk/opencode-model-fallback/actions/workflows/ci.yml)
2
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
3
+ [![npm](https://img.shields.io/npm/v/@renjfk/opencode-model-fallback)](https://www.npmjs.com/package/@renjfk/opencode-model-fallback)
4
+ [![Downloads](https://img.shields.io/npm/dm/@renjfk/opencode-model-fallback)](https://www.npmjs.com/package/@renjfk/opencode-model-fallback)
5
+
6
+ # opencode-model-fallback
7
+
8
+ Mapped model fallback router for [OpenCode](https://opencode.ai/).
9
+
10
+ There are situations where you may want to use the quota that comes with a
11
+ subscription first, then fall back to an API pay-as-you-go model only when that
12
+ subscription-backed model is rate-limited or usage-limited. You can solve that
13
+ with a local proxy, but maintaining a proxy server is often not worth it if all
14
+ you need is a simple one-to-one fallback inside OpenCode. This plugin handles
15
+ that routing directly in OpenCode.
16
+
17
+ When a configured model hits a retryable provider failure, this plugin aborts
18
+ the in-flight request, replays the latest user message on the mapped fallback
19
+ model, persists a global cooldown for the failed model, and routes back to the
20
+ original model after the cooldown expires.
21
+
22
+ ## Install
23
+
24
+ Add to your OpenCode config at `~/.config/opencode/config.json`:
25
+
26
+ ```json
27
+ {
28
+ "plugin": ["@renjfk/opencode-model-fallback"]
29
+ }
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ If you want to set plugin options, use the tuple form:
35
+
36
+ ```json
37
+ {
38
+ "plugin": [
39
+ [
40
+ "@renjfk/opencode-model-fallback",
41
+ {
42
+ "mappings": {
43
+ "openai/gpt-5.4": "azure-ai-foundry/gpt-5.4",
44
+ "openai/gpt-5.5": "azure-ai-foundry/gpt-5.5"
45
+ }
46
+ }
47
+ ]
48
+ ]
49
+ }
50
+ ```
51
+
52
+ ## Options
53
+
54
+ - `mappings`: map original model IDs to fallback model IDs.
55
+ - `retry_on_errors`: retryable HTTP status codes. Defaults to `429`.
56
+ - `retryable_error_patterns`: retryable error message patterns. Defaults to `["rate.?limit"]`.
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
+ - `notify_on_fallback`: show fallback/recovery toasts. Defaults to `true`.
60
+
61
+ ## How it works
62
+
63
+ The plugin watches OpenCode chat and session events. When a request uses a model
64
+ listed in `mappings`, that model is preferred unless it has an active global
65
+ cooldown. If OpenCode reports a retryable provider failure, the plugin switches
66
+ to the mapped fallback model and stores a global cooldown for the failed model.
67
+
68
+ Global model cooldowns are persisted at:
69
+
70
+ ```
71
+ ~/.local/share/opencode/mapped-fallback-router.json
72
+ ```
73
+
74
+ Persisted cooldowns let all sessions avoid immediately retrying a model that has
75
+ just failed. When the cooldown expires, mapped requests are routed back to the
76
+ original model.
77
+
78
+ The plugin does not load balance, race models, or retry through a chain. Each
79
+ mapping is one original model to one fallback model.
80
+
81
+ ## Scenarios
82
+
83
+ ### Normal request
84
+
85
+ If you send a message with `openai/gpt-5.5` and the model has a mapping, the
86
+ request goes to `openai/gpt-5.5` normally unless it has an active cooldown.
87
+ If you select the mapped fallback model directly, the plugin still routes back
88
+ to the original model unless the original has an active cooldown.
89
+
90
+ ### Retryable failure while streaming
91
+
92
+ If OpenCode reports a retryable provider failure such as a rate limit or a
93
+ configured retryable status code, the plugin aborts the current request and
94
+ replays the latest user message on the mapped fallback model.
95
+
96
+ For example:
97
+
98
+ ```json
99
+ {
100
+ "mappings": {
101
+ "openai/gpt-5.5": "azure-ai-foundry/gpt-5.5"
102
+ }
103
+ }
104
+ ```
105
+
106
+ If `openai/gpt-5.5` fails with a retryable error, the session continues on
107
+ `azure-ai-foundry/gpt-5.5`.
108
+
109
+ ### Active cooldown
110
+
111
+ After fallback is triggered, the original model is considered cooling down for
112
+ `cooldown_seconds`. During that cooldown, mapped requests use the fallback model
113
+ instead of switching back and immediately hitting the same provider
114
+ failure again.
115
+
116
+ All sessions are routed straight to the fallback while the original model is
117
+ cooling down.
118
+
119
+ ### Recovery
120
+
121
+ When the cooldown expires, mapped requests switch back to the original model.
122
+
123
+ ### Exhausted fallback
124
+
125
+ Mappings are one-to-one. If the fallback model also hits a retryable failure,
126
+ there is no next fallback to try. The plugin shows a fallback exhausted toast
127
+ when notifications are enabled.
128
+
129
+ ## Troubleshooting Retry Matching
130
+
131
+ Use OpenCode's provider logs to find the exact status code, headers, and error
132
+ body returned by a provider. This is the most reliable way to tune
133
+ `retry_on_errors` and `retryable_error_patterns`.
134
+
135
+ For a short headless reproduction, capture logs and stop the run after a few
136
+ seconds to avoid long retry loops:
137
+
138
+ ```bash
139
+ log="/tmp/opencode-provider.log"
140
+ : > "$log"
141
+ opencode run --print-logs --log-level DEBUG --model openai/gpt-5.3-codex --format json "Reply with OK only." 2> "$log" &
142
+ pid=$!
143
+ sleep 3
144
+ kill "$pid" 2>/dev/null || true
145
+ wait "$pid" 2>/dev/null || true
146
+ ```
147
+
148
+ Then inspect the captured provider errors:
149
+
150
+ ```bash
151
+ rg 'service=llm|AI_APICallError|statusCode|responseBody|x-codex|reset|usage_limit|rate.?limit' /tmp/opencode-provider.log
152
+ ```
153
+
154
+ Look for an OpenCode log line like:
155
+
156
+ ```text
157
+ ERROR ... service=llm providerID=openai modelID=gpt-5.3-codex ... error={...}
158
+ ```
159
+
160
+ Inside `error`, check fields such as `statusCode`, `responseHeaders`,
161
+ `responseBody`, `isRetryable`, and `data.error.message`. For example, OpenAI
162
+ usage limits can appear as `statusCode: 429` with a response body containing
163
+ `usage_limit_reached` and `The usage limit has been reached`. OpenAI Codex
164
+ responses can also include reset headers such as `x-codex-primary-reset-at`,
165
+ `x-codex-primary-reset-after-seconds`, `x-codex-secondary-reset-at`, and
166
+ `x-codex-secondary-reset-after-seconds`.
167
+
168
+ For TUI sessions, start OpenCode the same way and reproduce manually:
169
+
170
+ ```bash
171
+ opencode --print-logs --log-level DEBUG 2> /tmp/opencode-provider.log
172
+ ```
173
+
174
+ Use the provider `statusCode` and response body text to tune the retry rules:
175
+
176
+ ```json
177
+ {
178
+ "plugin": [
179
+ [
180
+ "@renjfk/opencode-model-fallback",
181
+ {
182
+ "retry_on_errors": [429, 403],
183
+ "retryable_error_patterns": ["rate.?limit", "usage.?limit"],
184
+ "mappings": {
185
+ "openai/gpt-5.5": "azure-ai-foundry/gpt-5.5"
186
+ }
187
+ }
188
+ ]
189
+ ]
190
+ }
191
+ ```
192
+
193
+ If the status code is not in `retry_on_errors`, add it. If the response body has
194
+ stable text or an error type, add a small regex matching it to
195
+ `retryable_error_patterns`. If there is no `service=llm` error line, OpenCode did
196
+ not reach the provider or the run was stopped before the provider returned.
197
+
198
+ ## Contributing
199
+
200
+ opencode-model-fallback is open to contributions and ideas!
201
+
202
+ ### Issue conventions
203
+
204
+ **Format:** `type: brief description`
205
+
206
+ - `feat:` new features or functionality
207
+ - `fix:` bug fixes
208
+ - `enhance:` improvements to existing features
209
+ - `chore:` maintenance tasks, dependencies, cleanup
210
+ - `docs:` documentation updates
211
+ - `build:` build system, CI/CD changes
212
+
213
+ ### Development
214
+
215
+ ```bash
216
+ npm run test # node test suite
217
+ npm run check # test + lint + fmt
218
+ npm run lint # oxlint
219
+ npm run fmt # oxfmt --check
220
+ npm run fmt:fix # oxfmt --write
221
+ ```
222
+
223
+ ### Test local plugin in OpenCode
224
+
225
+ To test unpublished changes in the OpenCode TUI, point `~/.config/opencode/config.json`
226
+ at the local repo path, not the npm package name:
227
+
228
+ ```json
229
+ {
230
+ "plugin": ["/Users/your-user/opencode-model-fallback"]
231
+ }
232
+ ```
233
+
234
+ ### Release process
235
+
236
+ Manual releases via opencode; see [RELEASE_PROCESS.md](RELEASE_PROCESS.md).
237
+
238
+ ## License
239
+
240
+ This project is licensed under the [MIT License](LICENSE).
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import { createMappedFallbackRouter } from "./lib/router.js";
2
+
3
+ export async function MappedFallbackRouterPlugin(ctx, rawOptions) {
4
+ return createMappedFallbackRouter(ctx, rawOptions);
5
+ }
6
+
7
+ export default MappedFallbackRouterPlugin;
package/lib/errors.js ADDED
@@ -0,0 +1,36 @@
1
+ export function isRetryable(error, options) {
2
+ const status = extractStatus(error);
3
+ if (status && options.retry_on_errors.includes(status)) return true;
4
+ const text = errorText(error).toLowerCase();
5
+ return options.retryable_error_patterns.some((pattern) => {
6
+ try {
7
+ return new RegExp(pattern, "i").test(text);
8
+ } catch {
9
+ return text.includes(pattern.toLowerCase());
10
+ }
11
+ });
12
+ }
13
+
14
+ function extractStatus(error) {
15
+ if (!error || typeof error !== "object") return undefined;
16
+ const value = error.statusCode ?? error.status ?? error.code;
17
+ if (typeof value === "number") return value;
18
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
19
+ return undefined;
20
+ }
21
+
22
+ export function extractErrorName(error) {
23
+ if (!error || typeof error !== "object") return undefined;
24
+ return typeof error.name === "string" ? error.name : undefined;
25
+ }
26
+
27
+ function errorText(error) {
28
+ if (!error) return "";
29
+ if (typeof error === "string") return error;
30
+ if (error instanceof Error) return `${error.name} ${error.message}`;
31
+ try {
32
+ return JSON.stringify(error);
33
+ } catch {
34
+ return String(error);
35
+ }
36
+ }
package/lib/models.js ADDED
@@ -0,0 +1,10 @@
1
+ export function modelObject(model) {
2
+ const [providerID, ...modelParts] = model.split("/");
3
+ if (!providerID || modelParts.length === 0) return undefined;
4
+ return { providerID, modelID: modelParts.join("/") };
5
+ }
6
+
7
+ export function modelString(model) {
8
+ if (!model?.providerID || !model?.modelID) return undefined;
9
+ return `${model.providerID}/${model.modelID}`;
10
+ }
package/lib/options.js ADDED
@@ -0,0 +1,25 @@
1
+ const DEFAULT_OPTIONS = {
2
+ mappings: {},
3
+ retry_on_errors: [429],
4
+ retryable_error_patterns: ["rate.?limit"],
5
+ cooldown_seconds: 3600,
6
+ timeout_seconds: 30,
7
+ notify_on_fallback: true,
8
+ };
9
+
10
+ export function normalizeOptions(rawOptions) {
11
+ return {
12
+ ...DEFAULT_OPTIONS,
13
+ ...rawOptions,
14
+ mappings: normalizeMappings(rawOptions?.mappings ?? {}),
15
+ };
16
+ }
17
+
18
+ function normalizeMappings(mappings) {
19
+ const normalized = {};
20
+ for (const [from, to] of Object.entries(mappings)) {
21
+ if (!from || typeof to !== "string" || !to.includes("/")) continue;
22
+ normalized[from] = to;
23
+ }
24
+ return normalized;
25
+ }
package/lib/router.js ADDED
@@ -0,0 +1,254 @@
1
+ import { isRetryable, extractErrorName } from "./errors.js";
2
+ import { modelObject, modelString } from "./models.js";
3
+ import { normalizeOptions } from "./options.js";
4
+ import { abortSession, getReplayParts } from "./session.js";
5
+ import { createStateStore } from "./store.js";
6
+
7
+ const POST_ABORT_DELAY_MS = 150;
8
+
9
+ export function createMappedFallbackRouter(ctx, rawOptions) {
10
+ const options = normalizeOptions(rawOptions);
11
+ const fallbackToOriginal = Object.fromEntries(
12
+ Object.entries(options.mappings).map(([original, fallback]) => [fallback, original]),
13
+ );
14
+ const store = createStateStore();
15
+ const retrying = new Set();
16
+ const timers = new Map();
17
+ const selfAbortAt = new Map();
18
+ const activeOriginals = new Map();
19
+ let agentConfigs;
20
+
21
+ function hasMapping(model) {
22
+ return !!model && !!options.mappings[model];
23
+ }
24
+
25
+ function mappedOriginal(model) {
26
+ if (hasMapping(model)) return model;
27
+ return fallbackToOriginal[model];
28
+ }
29
+
30
+ function modelFromAgent(agent) {
31
+ const agentConfig = agent && agentConfigs?.[agent];
32
+ return typeof agentConfig === "object" && agentConfig ? agentConfig.model : undefined;
33
+ }
34
+
35
+ function selectedModel(requested) {
36
+ const original = mappedOriginal(requested);
37
+ if (!original) return requested;
38
+ const fallback = options.mappings[original];
39
+ if (!fallback) return requested;
40
+ const cooldown = store.getModelCooldown(original);
41
+ return cooldown ? fallback : original;
42
+ }
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
+ function shouldRoute(requested) {
52
+ return hasMapping(requested) || !!fallbackToOriginal[requested];
53
+ }
54
+
55
+ function resolveErrorModels(model, agent) {
56
+ const failed = model ?? modelFromAgent(agent);
57
+ const original = mappedOriginal(failed);
58
+ return { failed, original };
59
+ }
60
+
61
+ function shouldFallbackFromError(failed, original) {
62
+ if (!original) return false;
63
+ if (failed !== original) return false;
64
+ const cooldown = store.getModelCooldown(original);
65
+ return !cooldown;
66
+ }
67
+
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
+ async function abortCurrentSession(sessionID) {
94
+ const aborted = await abortSession(ctx.client, sessionID);
95
+ if (aborted) selfAbortAt.set(sessionID, Date.now());
96
+ }
97
+
98
+ async function retryWithFallback(sessionID, original, agent, reason) {
99
+ const fallback = options.mappings[original];
100
+ const target = fallback ? modelObject(fallback) : undefined;
101
+ if (!target) return;
102
+ const failedAt = Date.now();
103
+ const cooldownUntil = failedAt + options.cooldown_seconds * 1000;
104
+ store.setModelCooldown(original, reason, failedAt, cooldownUntil);
105
+ const parts = await getReplayParts(ctx.client, ctx.directory, sessionID);
106
+ if (parts.length === 0) return;
107
+ clearTimer(sessionID);
108
+ try {
109
+ await new Promise((resolve) => setTimeout(resolve, POST_ABORT_DELAY_MS));
110
+ await ctx.client.session.promptAsync({
111
+ path: { id: sessionID },
112
+ body: { ...(agent ? { agent } : {}), model: target, parts },
113
+ query: { directory: ctx.directory },
114
+ });
115
+ await toast("Model Fallback", `${original} -> ${fallback} (${reason})`, "warning");
116
+ } catch {}
117
+ }
118
+
119
+ async function toast(title, message, variant) {
120
+ if (!options.notify_on_fallback) return;
121
+ await ctx.client.tui
122
+ .showToast({ body: { title, message, variant, duration: 5000 } })
123
+ .catch(() => {});
124
+ }
125
+
126
+ async function handleError(sessionID, error, model, agent, source) {
127
+ if (!sessionID || retrying.has(sessionID)) return;
128
+ const name = extractErrorName(error);
129
+ const selfAbort = selfAbortAt.get(sessionID);
130
+ if (name === "MessageAbortedError" && selfAbort && Date.now() - selfAbort < 2000) return;
131
+ const { failed, original } = resolveErrorModels(model, agent);
132
+ if (!original) return;
133
+ const retryable = isRetryable(error, options);
134
+ if (!retryable) return;
135
+ if (failed !== original) {
136
+ await toast("Model Fallback Exhausted", `No mapped fallback left for ${original}`, "error");
137
+ return;
138
+ }
139
+ if (!shouldFallbackFromError(failed, original)) return;
140
+ retrying.add(sessionID);
141
+ clearTimer(sessionID);
142
+ try {
143
+ await retryWithFallback(sessionID, original, agent, source);
144
+ } finally {
145
+ retrying.delete(sessionID);
146
+ }
147
+ }
148
+
149
+ async function handleProviderRetryStatus(props) {
150
+ const sessionID = props?.sessionID;
151
+ const status = props?.status;
152
+ if (!sessionID || !status || retrying.has(sessionID)) return;
153
+ const type = String(status.type ?? "").toLowerCase();
154
+ if (type !== "retry") return;
155
+ const agent = props?.agent;
156
+ const model =
157
+ props?.model ??
158
+ (typeof props?.providerID === "string" && typeof props?.modelID === "string"
159
+ ? `${props.providerID}/${props.modelID}`
160
+ : (activeOriginals.get(sessionID) ?? modelFromAgent(agent)));
161
+ const { failed, original } = resolveErrorModels(model, agent);
162
+ if (!original) return;
163
+ if (failed !== original) {
164
+ await toast("Model Fallback Exhausted", `No mapped fallback left for ${original}`, "error");
165
+ return;
166
+ }
167
+ if (!shouldFallbackFromError(failed, original)) return;
168
+ retrying.add(sessionID);
169
+ clearTimer(sessionID);
170
+ try {
171
+ await abortCurrentSession(sessionID);
172
+ await retryWithFallback(sessionID, original, agent, "session.status");
173
+ } finally {
174
+ retrying.delete(sessionID);
175
+ }
176
+ }
177
+
178
+ return {
179
+ name: "mapped-fallback-router",
180
+
181
+ config: (config) => {
182
+ const agentValue = config.agent;
183
+ agentConfigs =
184
+ agentValue && typeof agentValue === "object" && !Array.isArray(agentValue)
185
+ ? agentValue
186
+ : undefined;
187
+ },
188
+
189
+ "chat.message": async (input, output) => {
190
+ const sessionID = input.sessionID;
191
+ const requested = modelString(input.model) ?? modelFromAgent(input.agent);
192
+ if (!sessionID || !requested) return;
193
+ if (!shouldRoute(requested)) return;
194
+ const target = selectedModel(requested);
195
+ if (!target) return;
196
+ const original = mappedOriginal(requested);
197
+ if (original) activeOriginals.set(sessionID, original);
198
+ const model = modelObject(target);
199
+ 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);
210
+ },
211
+
212
+ event: async ({ event }) => {
213
+ const props = event.properties;
214
+ if (event.type === "session.deleted") {
215
+ const id = props?.info?.id;
216
+ if (id) {
217
+ retrying.delete(id);
218
+ activeOriginals.delete(id);
219
+ clearTimer(id);
220
+ }
221
+ return;
222
+ }
223
+ if (event.type === "session.status") {
224
+ await handleProviderRetryStatus(props);
225
+ return;
226
+ }
227
+ if (event.type === "session.error") {
228
+ await handleError(
229
+ props?.sessionID,
230
+ props?.error,
231
+ props?.model,
232
+ props?.agent,
233
+ "session.error",
234
+ );
235
+ return;
236
+ }
237
+ if (event.type === "message.updated") {
238
+ const info = props?.info;
239
+ if (info?.role !== "assistant") return;
240
+ const sessionID = info?.sessionID;
241
+ if (!info?.error) {
242
+ if (sessionID) clearTimer(sessionID);
243
+ return;
244
+ }
245
+ const model =
246
+ info?.model ??
247
+ (typeof info?.providerID === "string" && typeof info?.modelID === "string"
248
+ ? `${info.providerID}/${info.modelID}`
249
+ : undefined);
250
+ await handleError(sessionID, info.error, model, info?.agent, "message.updated");
251
+ }
252
+ },
253
+ };
254
+ }
package/lib/session.js ADDED
@@ -0,0 +1,25 @@
1
+ export async function abortSession(client, sessionID) {
2
+ try {
3
+ await client.session.abort({ path: { id: sessionID } });
4
+ return true;
5
+ } catch {
6
+ // Best effort. The session may already be idle after an error.
7
+ return false;
8
+ }
9
+ }
10
+
11
+ export async function getReplayParts(client, directory, sessionID) {
12
+ const response = await client.session.messages({
13
+ path: { id: sessionID },
14
+ query: { directory },
15
+ });
16
+ const messages = response.data ?? [];
17
+ for (let index = messages.length - 1; index >= 0; index--) {
18
+ const message = messages[index];
19
+ const role = String(message.info?.role ?? "").toLowerCase();
20
+ const parts = message.parts ?? message.info?.parts ?? [];
21
+ if (role !== "user" || parts.length === 0) continue;
22
+ return parts.filter((part) => typeof part.type === "string" && part.type !== "compaction");
23
+ }
24
+ return [];
25
+ }
package/lib/store.js ADDED
@@ -0,0 +1,51 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export const STORE_PATH = join(
5
+ process.env.XDG_DATA_HOME ?? join(process.env.HOME ?? "", ".local", "share"),
6
+ "opencode",
7
+ "mapped-fallback-router.json",
8
+ );
9
+
10
+ export function createStateStore(storePath = STORE_PATH) {
11
+ function read() {
12
+ try {
13
+ if (!existsSync(storePath)) return {};
14
+ const parsed = JSON.parse(readFileSync(storePath, "utf8"));
15
+ if (!parsed || typeof parsed !== "object") return {};
16
+ return parsed;
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function write(store) {
23
+ try {
24
+ mkdirSync(dirname(storePath), { recursive: true });
25
+ const tempPath = `${storePath}.${process.pid}.tmp`;
26
+ writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`);
27
+ renameSync(tempPath, storePath);
28
+ } catch {
29
+ // Persisted cooldown state is best effort. In-memory fallback still works.
30
+ }
31
+ }
32
+
33
+ return {
34
+ getModelCooldown(model) {
35
+ if (!model) return undefined;
36
+ const store = read();
37
+ const record = store[model];
38
+ if (!record) return undefined;
39
+ if (Date.now() < record.cooldownUntil) return record;
40
+ delete store[model];
41
+ write(store);
42
+ return undefined;
43
+ },
44
+
45
+ setModelCooldown(model, reason, failedAt, cooldownUntil) {
46
+ const store = read();
47
+ store[model] = { failedAt, cooldownUntil, reason };
48
+ write(store);
49
+ },
50
+ };
51
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@renjfk/opencode-model-fallback",
3
+ "version": "0.1.0",
4
+ "description": "Mapped model fallback router for OpenCode. Routes retryable model failures to configured fallback models and recovers after cooldown.",
5
+ "keywords": [
6
+ "fallback",
7
+ "model",
8
+ "opencode",
9
+ "plugin",
10
+ "router"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/renjfk/opencode-model-fallback.git"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "lib"
20
+ ],
21
+ "type": "module",
22
+ "main": "index.js",
23
+ "exports": {
24
+ ".": {
25
+ "import": "./index.js"
26
+ },
27
+ "./tui": {
28
+ "import": "./index.js"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "test": "vitest run",
33
+ "lint": "npx oxlint .",
34
+ "fmt": "npx oxfmt --check .",
35
+ "fmt:fix": "npx oxfmt --write .",
36
+ "check": "npm run test && npm run lint && npm run fmt",
37
+ "prepack": "npm run check"
38
+ },
39
+ "devDependencies": {
40
+ "oxfmt": "0.50.0",
41
+ "oxlint": "1.65.0",
42
+ "vitest": "latest"
43
+ }
44
+ }