@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 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
@@ -46,7 +46,9 @@ export declare class FallbackHandler {
46
46
  */
47
47
  private abortSession;
48
48
  /**
49
- * 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.
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
- * 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.
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
- await this.client.session.prompt({
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
  /**
@@ -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.40.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",