@azumag/opencode-rate-limit-fallback 1.40.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 +3 -1
- package/dist/src/fallback/FallbackHandler.js +7 -4
- 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
|
|
@@ -46,7 +46,9 @@ export declare class FallbackHandler {
|
|
|
46
46
|
*/
|
|
47
47
|
private abortSession;
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
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.
|
|
50
52
|
*/
|
|
51
53
|
retryWithModel(targetSessionID: string, model: FallbackModel, parts: MessagePart[], hierarchy: SessionHierarchy | null): Promise<void>;
|
|
52
54
|
/**
|
|
@@ -95,7 +95,9 @@ export class FallbackHandler {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
/**
|
|
98
|
-
*
|
|
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.
|
|
99
101
|
*/
|
|
100
102
|
async retryWithModel(targetSessionID, model, parts, hierarchy) {
|
|
101
103
|
// Track the new model for this session
|
|
@@ -129,13 +131,16 @@ export class FallbackHandler {
|
|
|
129
131
|
}
|
|
130
132
|
// Convert internal MessagePart to SDK-compatible format
|
|
131
133
|
const sdkParts = convertPartsToSDKFormat(parts);
|
|
132
|
-
|
|
134
|
+
// 1. promptAsync: queue the new prompt (returns immediately, non-blocking)
|
|
135
|
+
await this.client.session.promptAsync({
|
|
133
136
|
path: { id: targetSessionID },
|
|
134
137
|
body: {
|
|
135
138
|
parts: sdkParts,
|
|
136
139
|
model: { providerID: model.providerID, modelID: model.modelID },
|
|
137
140
|
},
|
|
138
141
|
});
|
|
142
|
+
// 2. abort: cancel the retry loop; server sees queued prompt and processes it
|
|
143
|
+
await this.abortSession(targetSessionID);
|
|
139
144
|
await safeShowToast(this.client, {
|
|
140
145
|
body: {
|
|
141
146
|
title: "Fallback Successful",
|
|
@@ -176,8 +181,6 @@ export class FallbackHandler {
|
|
|
176
181
|
if (this.healthTracker && currentProviderID && currentModelID) {
|
|
177
182
|
this.healthTracker.recordFailure(currentProviderID, currentModelID);
|
|
178
183
|
}
|
|
179
|
-
// Abort current session with error handling
|
|
180
|
-
await this.abortSession(targetSessionID);
|
|
181
184
|
await safeShowToast(this.client, {
|
|
182
185
|
body: {
|
|
183
186
|
title: "Rate Limit Detected",
|
|
@@ -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