@azumag/opencode-rate-limit-fallback 1.39.0 → 1.43.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/dist/index.js +68 -5
- package/dist/src/fallback/FallbackHandler.d.ts +4 -1
- package/dist/src/fallback/FallbackHandler.js +22 -9
- package/dist/src/types/index.d.ts +23 -0
- package/dist/src/types/index.js +12 -5
- package/dist/src/utils/config.d.ts +3 -1
- package/dist/src/utils/config.js +43 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,6 +14,34 @@ import { ErrorPatternRegistry } from "./src/errors/PatternRegistry.js";
|
|
|
14
14
|
import { HealthTracker } from "./src/health/HealthTracker.js";
|
|
15
15
|
import { DiagnosticReporter } from "./src/diagnostics/Reporter.js";
|
|
16
16
|
// ============================================================================
|
|
17
|
+
// Helper Functions
|
|
18
|
+
// ============================================================================
|
|
19
|
+
/**
|
|
20
|
+
* Get the difference between two objects (returns keys with different values)
|
|
21
|
+
*/
|
|
22
|
+
function getObjectDiff(obj1, obj2) {
|
|
23
|
+
const diffs = [];
|
|
24
|
+
const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
|
|
25
|
+
for (const key of allKeys) {
|
|
26
|
+
const val1 = obj1[key];
|
|
27
|
+
const val2 = obj2[key];
|
|
28
|
+
if (typeof val1 !== typeof val2) {
|
|
29
|
+
diffs.push(`${key}: ${val1} → ${val2}`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (val1 === undefined && val2 !== undefined) {
|
|
33
|
+
diffs.push(`${key}: undefined → ${JSON.stringify(val2)}`);
|
|
34
|
+
}
|
|
35
|
+
else if (val1 !== undefined && val2 === undefined) {
|
|
36
|
+
diffs.push(`${key}: ${JSON.stringify(val1)} → undefined`);
|
|
37
|
+
}
|
|
38
|
+
else if (val1 !== val2) {
|
|
39
|
+
diffs.push(`${key}: ${JSON.stringify(val1)} → ${JSON.stringify(val2)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return diffs;
|
|
43
|
+
}
|
|
44
|
+
// ============================================================================
|
|
17
45
|
// Event Type Guards
|
|
18
46
|
// ============================================================================
|
|
19
47
|
/**
|
|
@@ -57,22 +85,58 @@ function isSubagentSessionCreatedEvent(event) {
|
|
|
57
85
|
// Main Plugin Export
|
|
58
86
|
// ============================================================================
|
|
59
87
|
export const RateLimitFallback = async ({ client, directory, worktree }) => {
|
|
60
|
-
|
|
61
|
-
// Detect headless mode (no TUI)
|
|
88
|
+
// Detect headless mode (no TUI) before loading config for logging
|
|
62
89
|
const isHeadless = !client.tui;
|
|
90
|
+
// We need a temporary logger to log config loading process
|
|
91
|
+
// Use a minimal config initially
|
|
92
|
+
const tempLogConfig = {
|
|
93
|
+
level: isHeadless ? 'info' : 'warn',
|
|
94
|
+
format: 'simple',
|
|
95
|
+
enableTimestamp: true,
|
|
96
|
+
};
|
|
97
|
+
const tempLogger = createLogger(tempLogConfig, "RateLimitFallback");
|
|
98
|
+
// Log headless mode detection
|
|
99
|
+
if (isHeadless) {
|
|
100
|
+
tempLogger.info("Running in headless mode (no TUI detected)");
|
|
101
|
+
}
|
|
102
|
+
const configLoadResult = loadConfig(directory, worktree, tempLogger);
|
|
103
|
+
const { config, source: configSource } = configLoadResult;
|
|
63
104
|
// Auto-adjust log level for headless mode to ensure visibility
|
|
64
105
|
const logConfig = {
|
|
65
106
|
...config.log,
|
|
66
107
|
level: isHeadless ? 'info' : (config.log?.level ?? 'warn'),
|
|
67
108
|
};
|
|
68
|
-
// Create logger instance
|
|
109
|
+
// Create final logger instance with loaded config
|
|
69
110
|
const logger = createLogger(logConfig, "RateLimitFallback");
|
|
70
111
|
if (configSource) {
|
|
71
|
-
logger.info(`Config loaded from ${configSource}`);
|
|
112
|
+
logger.info(`Config loaded from: ${configSource}`);
|
|
72
113
|
}
|
|
73
114
|
else {
|
|
74
115
|
logger.info("No config file found, using defaults");
|
|
75
116
|
}
|
|
117
|
+
// Log verbose mode status
|
|
118
|
+
if (config.verbose) {
|
|
119
|
+
logger.info("Verbose mode enabled - showing diagnostic information");
|
|
120
|
+
}
|
|
121
|
+
// Log config merge diff in verbose mode
|
|
122
|
+
if (config.verbose && configSource) {
|
|
123
|
+
if (configLoadResult.rawUserConfig &&
|
|
124
|
+
typeof configLoadResult.rawUserConfig === 'object' &&
|
|
125
|
+
configLoadResult.rawUserConfig !== null &&
|
|
126
|
+
!Array.isArray(configLoadResult.rawUserConfig) &&
|
|
127
|
+
Object.keys(configLoadResult.rawUserConfig).length > 0) {
|
|
128
|
+
logger.info("Configuration merge details:");
|
|
129
|
+
const diffs = getObjectDiff(configLoadResult.rawUserConfig, config);
|
|
130
|
+
if (diffs.length > 0) {
|
|
131
|
+
for (const diff of diffs) {
|
|
132
|
+
logger.info(` ${diff}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
logger.info(" No changes from defaults");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
76
140
|
// Initialize configuration validator
|
|
77
141
|
const validator = new ConfigValidator(logger);
|
|
78
142
|
const validation = configSource
|
|
@@ -108,7 +172,6 @@ export const RateLimitFallback = async ({ client, directory, worktree }) => {
|
|
|
108
172
|
errorPatternRegistry, logger);
|
|
109
173
|
// Log startup diagnostics if verbose mode
|
|
110
174
|
if (config.verbose) {
|
|
111
|
-
logger.debug("Verbose mode enabled - showing diagnostic information");
|
|
112
175
|
diagnostics.logCurrentConfig();
|
|
113
176
|
}
|
|
114
177
|
// Initialize components
|
|
@@ -19,6 +19,7 @@ export declare class FallbackHandler {
|
|
|
19
19
|
private retryState;
|
|
20
20
|
private fallbackInProgress;
|
|
21
21
|
private fallbackMessages;
|
|
22
|
+
private sessionLock;
|
|
22
23
|
private metricsManager;
|
|
23
24
|
private subagentTracker;
|
|
24
25
|
private retryManager;
|
|
@@ -45,7 +46,9 @@ export declare class FallbackHandler {
|
|
|
45
46
|
*/
|
|
46
47
|
private abortSession;
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
+
* Queue the prompt asynchronously (non-blocking), then abort the retry loop.
|
|
50
|
+
* promptAsync FIRST queues pending work so the server doesn't dispose on idle.
|
|
51
|
+
* abort SECOND cancels the retry loop; the server sees the queued prompt and processes it.
|
|
49
52
|
*/
|
|
50
53
|
retryWithModel(targetSessionID: string, model: FallbackModel, parts: MessagePart[], hierarchy: SessionHierarchy | null): Promise<void>;
|
|
51
54
|
/**
|
|
@@ -19,6 +19,7 @@ export class FallbackHandler {
|
|
|
19
19
|
retryState;
|
|
20
20
|
fallbackInProgress;
|
|
21
21
|
fallbackMessages;
|
|
22
|
+
sessionLock;
|
|
22
23
|
// Metrics manager reference
|
|
23
24
|
metricsManager;
|
|
24
25
|
// Subagent tracker reference
|
|
@@ -46,6 +47,7 @@ export class FallbackHandler {
|
|
|
46
47
|
this.retryState = new Map();
|
|
47
48
|
this.fallbackInProgress = new Map();
|
|
48
49
|
this.fallbackMessages = new Map();
|
|
50
|
+
this.sessionLock = new Set();
|
|
49
51
|
// Initialize retry manager
|
|
50
52
|
this.retryManager = new RetryManager(config.retryPolicy || {}, logger);
|
|
51
53
|
}
|
|
@@ -93,7 +95,9 @@ export class FallbackHandler {
|
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
/**
|
|
96
|
-
*
|
|
98
|
+
* Queue the prompt asynchronously (non-blocking), then abort the retry loop.
|
|
99
|
+
* promptAsync FIRST queues pending work so the server doesn't dispose on idle.
|
|
100
|
+
* abort SECOND cancels the retry loop; the server sees the queued prompt and processes it.
|
|
97
101
|
*/
|
|
98
102
|
async retryWithModel(targetSessionID, model, parts, hierarchy) {
|
|
99
103
|
// Track the new model for this session
|
|
@@ -127,13 +131,16 @@ export class FallbackHandler {
|
|
|
127
131
|
}
|
|
128
132
|
// Convert internal MessagePart to SDK-compatible format
|
|
129
133
|
const sdkParts = convertPartsToSDKFormat(parts);
|
|
130
|
-
|
|
134
|
+
// 1. promptAsync: queue the new prompt (returns immediately, non-blocking)
|
|
135
|
+
await this.client.session.promptAsync({
|
|
131
136
|
path: { id: targetSessionID },
|
|
132
137
|
body: {
|
|
133
138
|
parts: sdkParts,
|
|
134
139
|
model: { providerID: model.providerID, modelID: model.modelID },
|
|
135
140
|
},
|
|
136
141
|
});
|
|
142
|
+
// 2. abort: cancel the retry loop; server sees queued prompt and processes it
|
|
143
|
+
await this.abortSession(targetSessionID);
|
|
137
144
|
await safeShowToast(this.client, {
|
|
138
145
|
body: {
|
|
139
146
|
title: "Fallback Successful",
|
|
@@ -147,10 +154,16 @@ export class FallbackHandler {
|
|
|
147
154
|
* Handle the rate limit fallback process
|
|
148
155
|
*/
|
|
149
156
|
async handleRateLimitFallback(sessionID, currentProviderID, currentModelID) {
|
|
157
|
+
// Resolve target session before acquiring lock
|
|
158
|
+
const rootSessionID = this.subagentTracker.getRootSession(sessionID);
|
|
159
|
+
const targetSessionID = rootSessionID || sessionID;
|
|
160
|
+
// Session-level lock: prevent concurrent fallback processing
|
|
161
|
+
if (this.sessionLock.has(targetSessionID)) {
|
|
162
|
+
this.logger.debug(`Fallback already in progress for session ${targetSessionID}, skipping`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.sessionLock.add(targetSessionID);
|
|
150
166
|
try {
|
|
151
|
-
// Get root session and hierarchy using subagent tracker
|
|
152
|
-
const rootSessionID = this.subagentTracker.getRootSession(sessionID);
|
|
153
|
-
const targetSessionID = rootSessionID || sessionID;
|
|
154
167
|
const hierarchy = this.subagentTracker.getHierarchy(sessionID);
|
|
155
168
|
// If no model info provided, try to get from tracked session model
|
|
156
169
|
if (!currentProviderID || !currentModelID) {
|
|
@@ -168,8 +181,6 @@ export class FallbackHandler {
|
|
|
168
181
|
if (this.healthTracker && currentProviderID && currentModelID) {
|
|
169
182
|
this.healthTracker.recordFailure(currentProviderID, currentModelID);
|
|
170
183
|
}
|
|
171
|
-
// Abort current session with error handling
|
|
172
|
-
await this.abortSession(targetSessionID);
|
|
173
184
|
await safeShowToast(this.client, {
|
|
174
185
|
body: {
|
|
175
186
|
title: "Rate Limit Detected",
|
|
@@ -309,10 +320,11 @@ export class FallbackHandler {
|
|
|
309
320
|
name: errorName,
|
|
310
321
|
});
|
|
311
322
|
// Record retry failure on error
|
|
312
|
-
const rootSessionID = this.subagentTracker.getRootSession(sessionID);
|
|
313
|
-
const targetSessionID = rootSessionID || sessionID;
|
|
314
323
|
this.retryManager.recordFailure(targetSessionID);
|
|
315
324
|
}
|
|
325
|
+
finally {
|
|
326
|
+
this.sessionLock.delete(targetSessionID);
|
|
327
|
+
}
|
|
316
328
|
}
|
|
317
329
|
/**
|
|
318
330
|
* Handle message updated events for metrics recording
|
|
@@ -419,6 +431,7 @@ export class FallbackHandler {
|
|
|
419
431
|
this.retryState.clear();
|
|
420
432
|
this.fallbackInProgress.clear();
|
|
421
433
|
this.fallbackMessages.clear();
|
|
434
|
+
this.sessionLock.clear();
|
|
422
435
|
this.retryManager.destroy();
|
|
423
436
|
// Destroy circuit breaker
|
|
424
437
|
if (this.circuitBreaker) {
|
|
@@ -413,6 +413,18 @@ export type OpenCodeClient = {
|
|
|
413
413
|
};
|
|
414
414
|
};
|
|
415
415
|
}) => Promise<unknown>;
|
|
416
|
+
promptAsync: (args: {
|
|
417
|
+
path: {
|
|
418
|
+
id: string;
|
|
419
|
+
};
|
|
420
|
+
body: {
|
|
421
|
+
parts: SDKMessagePartInput[];
|
|
422
|
+
model: {
|
|
423
|
+
providerID: string;
|
|
424
|
+
modelID: string;
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
}) => Promise<unknown>;
|
|
416
428
|
};
|
|
417
429
|
tui?: {
|
|
418
430
|
showToast: (toast: ToastMessage) => Promise<unknown>;
|
|
@@ -427,6 +439,17 @@ export type PluginContext = {
|
|
|
427
439
|
};
|
|
428
440
|
/**
|
|
429
441
|
* Default fallback models
|
|
442
|
+
*
|
|
443
|
+
* NOTE: This is intentionally empty to force users to explicitly configure
|
|
444
|
+
* their fallback models. This prevents unintended model usage (e.g., gemini
|
|
445
|
+
* when not wanted) and makes configuration errors obvious immediately.
|
|
446
|
+
*
|
|
447
|
+
* Users must create a config file in one of these locations:
|
|
448
|
+
* - <worktree>/.opencode/rate-limit-fallback.json
|
|
449
|
+
* - <directory>/.opencode/rate-limit-fallback.json
|
|
450
|
+
* - <directory>/rate-limit-fallback.json
|
|
451
|
+
* - ~/.opencode/rate-limit-fallback.json
|
|
452
|
+
* - $XDG_CONFIG_HOME/opencode/rate-limit-fallback.json
|
|
430
453
|
*/
|
|
431
454
|
export declare const DEFAULT_FALLBACK_MODELS: FallbackModel[];
|
|
432
455
|
/**
|
package/dist/src/types/index.js
CHANGED
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
// ============================================================================
|
|
7
7
|
/**
|
|
8
8
|
* Default fallback models
|
|
9
|
+
*
|
|
10
|
+
* NOTE: This is intentionally empty to force users to explicitly configure
|
|
11
|
+
* their fallback models. This prevents unintended model usage (e.g., gemini
|
|
12
|
+
* when not wanted) and makes configuration errors obvious immediately.
|
|
13
|
+
*
|
|
14
|
+
* Users must create a config file in one of these locations:
|
|
15
|
+
* - <worktree>/.opencode/rate-limit-fallback.json
|
|
16
|
+
* - <directory>/.opencode/rate-limit-fallback.json
|
|
17
|
+
* - <directory>/rate-limit-fallback.json
|
|
18
|
+
* - ~/.opencode/rate-limit-fallback.json
|
|
19
|
+
* - $XDG_CONFIG_HOME/opencode/rate-limit-fallback.json
|
|
9
20
|
*/
|
|
10
|
-
export const DEFAULT_FALLBACK_MODELS = [
|
|
11
|
-
{ providerID: "anthropic", modelID: "claude-3-5-sonnet-20250514" },
|
|
12
|
-
{ providerID: "google", modelID: "gemini-2.5-pro" },
|
|
13
|
-
{ providerID: "google", modelID: "gemini-2.5-flash" },
|
|
14
|
-
];
|
|
21
|
+
export const DEFAULT_FALLBACK_MODELS = [];
|
|
15
22
|
/**
|
|
16
23
|
* Default retry policy
|
|
17
24
|
*/
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Configuration loading and validation
|
|
3
3
|
*/
|
|
4
4
|
import type { PluginConfig } from '../types/index.js';
|
|
5
|
+
import type { Logger } from '../../logger.js';
|
|
5
6
|
/**
|
|
6
7
|
* Default plugin configuration
|
|
7
8
|
*/
|
|
@@ -12,6 +13,7 @@ export declare const DEFAULT_CONFIG: PluginConfig;
|
|
|
12
13
|
export interface ConfigLoadResult {
|
|
13
14
|
config: PluginConfig;
|
|
14
15
|
source: string | null;
|
|
16
|
+
rawUserConfig?: Partial<PluginConfig>;
|
|
15
17
|
}
|
|
16
18
|
/**
|
|
17
19
|
* Validate configuration values
|
|
@@ -20,4 +22,4 @@ export declare function validateConfig(config: Partial<PluginConfig>): PluginCon
|
|
|
20
22
|
/**
|
|
21
23
|
* Load and validate config from file paths
|
|
22
24
|
*/
|
|
23
|
-
export declare function loadConfig(directory: string, worktree?: string): ConfigLoadResult;
|
|
25
|
+
export declare function loadConfig(directory: string, worktree?: string, logger?: Logger): ConfigLoadResult;
|
package/dist/src/utils/config.js
CHANGED
|
@@ -59,7 +59,7 @@ export function validateConfig(config) {
|
|
|
59
59
|
/**
|
|
60
60
|
* Load and validate config from file paths
|
|
61
61
|
*/
|
|
62
|
-
export function loadConfig(directory, worktree) {
|
|
62
|
+
export function loadConfig(directory, worktree, logger) {
|
|
63
63
|
const homedir = process.env.HOME || "";
|
|
64
64
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME || join(homedir, ".config");
|
|
65
65
|
// Build search paths: worktree first, then directory, then home locations
|
|
@@ -77,17 +77,57 @@ export function loadConfig(directory, worktree) {
|
|
|
77
77
|
}
|
|
78
78
|
configPaths.push(join(homedir, ".opencode", "rate-limit-fallback.json"));
|
|
79
79
|
configPaths.push(join(xdgConfigHome, "opencode", "rate-limit-fallback.json"));
|
|
80
|
+
// Log search paths for debugging
|
|
81
|
+
if (logger) {
|
|
82
|
+
logger.debug(`Searching for config file in ${configPaths.length} locations`);
|
|
83
|
+
for (const configPath of configPaths) {
|
|
84
|
+
const exists = existsSync(configPath);
|
|
85
|
+
logger.debug(` ${exists ? "✓" : "✗"} ${configPath}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
80
88
|
for (const configPath of configPaths) {
|
|
81
89
|
if (existsSync(configPath)) {
|
|
82
90
|
try {
|
|
83
91
|
const content = readFileSync(configPath, "utf-8");
|
|
84
92
|
const userConfig = JSON.parse(content);
|
|
85
|
-
|
|
93
|
+
if (logger) {
|
|
94
|
+
logger.info(`Config loaded from: ${configPath}`);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
config: validateConfig(userConfig),
|
|
98
|
+
source: configPath,
|
|
99
|
+
rawUserConfig: userConfig,
|
|
100
|
+
};
|
|
86
101
|
}
|
|
87
|
-
catch {
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (logger) {
|
|
104
|
+
logger.warn(`Failed to parse config file: ${configPath}`, { error: error instanceof Error ? error.message : String(error) });
|
|
105
|
+
}
|
|
88
106
|
// Skip invalid config files silently - caller will log via structured logger
|
|
89
107
|
}
|
|
90
108
|
}
|
|
91
109
|
}
|
|
110
|
+
if (logger) {
|
|
111
|
+
// Log that no config file was found
|
|
112
|
+
logger.info(`No config file found in any of the ${configPaths.length} search paths. Using default configuration.`);
|
|
113
|
+
// Show a warning if default fallback models is empty (which is now the case)
|
|
114
|
+
if (DEFAULT_CONFIG.fallbackModels.length === 0) {
|
|
115
|
+
logger.warn('No fallback models configured. The plugin will not be able to fallback when rate limited.');
|
|
116
|
+
logger.warn('Please create a config file with your fallback models.');
|
|
117
|
+
logger.warn('Config file locations (in order of priority):');
|
|
118
|
+
for (const configPath of configPaths) {
|
|
119
|
+
logger.warn(` - ${configPath}`);
|
|
120
|
+
}
|
|
121
|
+
logger.warn('Example config:');
|
|
122
|
+
logger.warn(JSON.stringify({
|
|
123
|
+
fallbackModels: [
|
|
124
|
+
{ providerID: "anthropic", modelID: "claude-3-5-sonnet-20250514" },
|
|
125
|
+
],
|
|
126
|
+
cooldownMs: 60000,
|
|
127
|
+
enabled: true,
|
|
128
|
+
fallbackMode: "cycle",
|
|
129
|
+
}, null, 2));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
92
132
|
return { config: DEFAULT_CONFIG, source: null };
|
|
93
133
|
}
|
package/package.json
CHANGED