@azumag/opencode-rate-limit-fallback 1.68.0 → 1.69.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 +22 -0
- package/dist/index.js +55 -1
- package/dist/src/types/index.d.ts +11 -0
- package/dist/src/types/index.js +4 -0
- package/dist/src/utils/config.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ OpenCode plugin that automatically switches to fallback models when rate limited
|
|
|
10
10
|
- Automatically aborts the current request and retries with a fallback model
|
|
11
11
|
- Configurable fallback model list with priority order
|
|
12
12
|
- Three fallback modes: `cycle`, `stop`, and `retry-last`
|
|
13
|
+
- **Headless mode support** (`opencode run`): disable fallback or abort on rate limit
|
|
13
14
|
- Session model tracking for sequential fallback across multiple rate limits
|
|
14
15
|
- Cooldown period to prevent immediate retry on rate-limited models
|
|
15
16
|
- **Exponential backoff with configurable retry policies**
|
|
@@ -117,6 +118,7 @@ Create a configuration file at one of these locations:
|
|
|
117
118
|
| `enabled` | boolean | `true` | Enable/disable the plugin |
|
|
118
119
|
| `cooldownMs` | number | `60000` | Cooldown period (ms) before retrying a rate-limited model |
|
|
119
120
|
| `fallbackMode` | string | `"cycle"` | Behavior when all models are exhausted (see below) |
|
|
121
|
+
| `headlessOnRateLimit` | string | `undefined` | Headless mode behavior on rate limit (see below) |
|
|
120
122
|
| `fallbackModels` | array | See below | List of fallback models in priority order |
|
|
121
123
|
| `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies |
|
|
122
124
|
| `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions |
|
|
@@ -236,6 +238,26 @@ my-repo/
|
|
|
236
238
|
|
|
237
239
|
> **Note**: If you're using git worktrees and want different configurations per worktree, create config files in the worktree directories (locations 1-2). Otherwise, a single project-level or global config is sufficient.
|
|
238
240
|
|
|
241
|
+
### Headless Mode (`opencode run`)
|
|
242
|
+
|
|
243
|
+
When running in headless mode (no TUI), model fallback is disabled by default because headless sessions should use their configured model only.
|
|
244
|
+
|
|
245
|
+
You can control what happens when a rate limit is detected in headless mode using the `headlessOnRateLimit` option:
|
|
246
|
+
|
|
247
|
+
| Value | Description |
|
|
248
|
+
|-------|-------------|
|
|
249
|
+
| *(not set)* | Default behavior — do nothing, let the server's retry loop handle it |
|
|
250
|
+
| `"ignore"` | Same as default — do nothing |
|
|
251
|
+
| `"abort"` | Abort the session immediately to terminate the prompt |
|
|
252
|
+
|
|
253
|
+
The `"abort"` option is useful when you want `opencode run` to fail fast on rate limits rather than waiting for the server's retry loop, which may retry indefinitely.
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
{
|
|
257
|
+
"headlessOnRateLimit": "abort"
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
239
261
|
### Fallback Modes
|
|
240
262
|
|
|
241
263
|
| Mode | Description |
|
package/dist/index.js
CHANGED
|
@@ -158,8 +158,62 @@ export const RateLimitFallback = async ({ client, directory, worktree }) => {
|
|
|
158
158
|
if (!config.enabled) {
|
|
159
159
|
return {};
|
|
160
160
|
}
|
|
161
|
-
//
|
|
161
|
+
// Headless mode — no model fallback, but optionally abort on rate limit
|
|
162
162
|
if (isHeadless) {
|
|
163
|
+
if (config.headlessOnRateLimit === "abort") {
|
|
164
|
+
logger.info("Headless mode — will abort session on rate limit");
|
|
165
|
+
// Minimal setup: only error pattern detection + abort
|
|
166
|
+
const errorPatternRegistry = new ErrorPatternRegistry(logger);
|
|
167
|
+
if (config.errorPatterns?.custom) {
|
|
168
|
+
errorPatternRegistry.registerMany(config.errorPatterns.custom);
|
|
169
|
+
}
|
|
170
|
+
// Track sessions already aborted to avoid duplicate abort calls
|
|
171
|
+
const abortedSessions = new Set();
|
|
172
|
+
const abortSession = async (sessionID, source) => {
|
|
173
|
+
if (abortedSessions.has(sessionID))
|
|
174
|
+
return;
|
|
175
|
+
abortedSessions.add(sessionID);
|
|
176
|
+
logger.info(`Rate limit detected (${source}) — aborting session ${sessionID}`);
|
|
177
|
+
try {
|
|
178
|
+
await client.session.abort({ path: { id: sessionID } });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
logger.warn(`Failed to abort session ${sessionID}`, {
|
|
182
|
+
error: err instanceof Error ? err.message : String(err),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
event: async ({ event }) => {
|
|
188
|
+
if (isSessionErrorEvent(event)) {
|
|
189
|
+
const { sessionID, error } = event.properties;
|
|
190
|
+
if (sessionID && error && errorPatternRegistry.isRateLimitError(error)) {
|
|
191
|
+
await abortSession(sessionID, "session.error");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (isMessageUpdatedEvent(event)) {
|
|
195
|
+
const info = event.properties.info;
|
|
196
|
+
if (info?.error && errorPatternRegistry.isRateLimitError(info.error)) {
|
|
197
|
+
await abortSession(info.sessionID, "message.updated");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (isSessionStatusEvent(event)) {
|
|
201
|
+
const props = event.properties;
|
|
202
|
+
const status = props?.status;
|
|
203
|
+
if (status?.type === "retry" && status?.message) {
|
|
204
|
+
const message = status.message.toLowerCase();
|
|
205
|
+
const isRateLimitRetry = message.includes("usage limit") ||
|
|
206
|
+
message.includes("rate limit") ||
|
|
207
|
+
message.includes("high concurrency") ||
|
|
208
|
+
message.includes("reduce concurrency");
|
|
209
|
+
if (isRateLimitRetry) {
|
|
210
|
+
await abortSession(props.sessionID, "session.status retry");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
163
217
|
logger.info("Headless mode detected — model fallback disabled");
|
|
164
218
|
return {};
|
|
165
219
|
}
|
|
@@ -17,6 +17,12 @@ export interface FallbackModel {
|
|
|
17
17
|
* - "retry-last": Try the last model once, then reset to first on next prompt
|
|
18
18
|
*/
|
|
19
19
|
export type FallbackMode = "cycle" | "stop" | "retry-last";
|
|
20
|
+
/**
|
|
21
|
+
* Headless mode behavior on rate limit:
|
|
22
|
+
* - "ignore": Do nothing, let server handle retries (default)
|
|
23
|
+
* - "abort": Abort the session to terminate the prompt immediately
|
|
24
|
+
*/
|
|
25
|
+
export type HeadlessOnRateLimit = "ignore" | "abort";
|
|
20
26
|
/**
|
|
21
27
|
* Retry strategy type
|
|
22
28
|
* - "immediate": Retry immediately without delay
|
|
@@ -234,6 +240,7 @@ export interface PluginConfig {
|
|
|
234
240
|
cooldownMs: number;
|
|
235
241
|
enabled: boolean;
|
|
236
242
|
fallbackMode: FallbackMode;
|
|
243
|
+
headlessOnRateLimit?: HeadlessOnRateLimit;
|
|
237
244
|
maxSubagentDepth?: number;
|
|
238
245
|
enableSubagentFallback?: boolean;
|
|
239
246
|
retryPolicy?: RetryPolicy;
|
|
@@ -547,6 +554,10 @@ export declare const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig;
|
|
|
547
554
|
* Valid fallback modes
|
|
548
555
|
*/
|
|
549
556
|
export declare const VALID_FALLBACK_MODES: FallbackMode[];
|
|
557
|
+
/**
|
|
558
|
+
* Valid headless on rate limit options
|
|
559
|
+
*/
|
|
560
|
+
export declare const VALID_HEADLESS_ON_RATE_LIMIT: HeadlessOnRateLimit[];
|
|
550
561
|
/**
|
|
551
562
|
* Valid retry strategies
|
|
552
563
|
*/
|
package/dist/src/types/index.js
CHANGED
|
@@ -46,6 +46,10 @@ export const DEFAULT_CIRCUIT_BREAKER_CONFIG = {
|
|
|
46
46
|
* Valid fallback modes
|
|
47
47
|
*/
|
|
48
48
|
export const VALID_FALLBACK_MODES = ["cycle", "stop", "retry-last"];
|
|
49
|
+
/**
|
|
50
|
+
* Valid headless on rate limit options
|
|
51
|
+
*/
|
|
52
|
+
export const VALID_HEADLESS_ON_RATE_LIMIT = ["ignore", "abort"];
|
|
49
53
|
/**
|
|
50
54
|
* Valid retry strategies
|
|
51
55
|
*/
|
package/dist/src/utils/config.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readFileSync } from "fs";
|
|
5
5
|
import { join, resolve, normalize, relative } from "path";
|
|
6
|
-
import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES, DEFAULT_CIRCUIT_BREAKER_CONFIG, } from '../types/index.js';
|
|
6
|
+
import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_HEADLESS_ON_RATE_LIMIT, VALID_RESET_INTERVALS, DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES, DEFAULT_CIRCUIT_BREAKER_CONFIG, } from '../types/index.js';
|
|
7
7
|
import { DEFAULT_HEALTH_TRACKER_CONFIG, DEFAULT_COOLDOWN_MS, DEFAULT_FALLBACK_MODE, DEFAULT_LOG_CONFIG, DEFAULT_METRICS_CONFIG, DEFAULT_CONFIG_RELOAD_CONFIG, DEFAULT_DYNAMIC_PRIORITIZATION_CONFIG, DEFAULT_ERROR_PATTERNS_CONFIG, DEFAULT_PATTERN_LEARNING_CONFIG, } from '../config/defaults.js';
|
|
8
8
|
/**
|
|
9
9
|
* Default plugin configuration
|
|
@@ -53,6 +53,7 @@ function validatePathSafety(path, allowedDirs) {
|
|
|
53
53
|
*/
|
|
54
54
|
export function validateConfig(config) {
|
|
55
55
|
const mode = config.fallbackMode;
|
|
56
|
+
const headlessOnRateLimit = config.headlessOnRateLimit;
|
|
56
57
|
const resetInterval = config.metrics?.resetInterval;
|
|
57
58
|
const strategy = config.retryPolicy?.strategy;
|
|
58
59
|
return {
|
|
@@ -60,6 +61,7 @@ export function validateConfig(config) {
|
|
|
60
61
|
...config,
|
|
61
62
|
fallbackModels: Array.isArray(config.fallbackModels) ? config.fallbackModels : DEFAULT_CONFIG.fallbackModels,
|
|
62
63
|
fallbackMode: mode && VALID_FALLBACK_MODES.includes(mode) ? mode : DEFAULT_CONFIG.fallbackMode,
|
|
64
|
+
headlessOnRateLimit: headlessOnRateLimit && VALID_HEADLESS_ON_RATE_LIMIT.includes(headlessOnRateLimit) ? headlessOnRateLimit : undefined,
|
|
63
65
|
retryPolicy: config.retryPolicy ? {
|
|
64
66
|
...DEFAULT_CONFIG.retryPolicy,
|
|
65
67
|
...config.retryPolicy,
|
package/package.json
CHANGED