@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 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
- const { config, source: configSource } = loadConfig(directory, worktree);
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
- * Retry the prompt with a different model
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
- * Retry the prompt with a different model
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
- await this.client.session.prompt({
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
  /**
@@ -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;
@@ -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
- return { config: validateConfig(userConfig), source: configPath };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azumag/opencode-rate-limit-fallback",
3
- "version": "1.39.0",
3
+ "version": "1.43.0",
4
4
  "description": "OpenCode plugin that automatically switches to fallback models when rate limited",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",